feat: Implement layout for hashtag timeline header view
This commit is contained in:
parent
2987bb29fa
commit
178a6e503a
|
@ -24,6 +24,7 @@
|
|||
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; };
|
||||
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
|
||||
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
|
||||
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; };
|
||||
2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; };
|
||||
2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; };
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||
|
@ -522,6 +523,7 @@
|
|||
164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; };
|
||||
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
||||
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
|
||||
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; };
|
||||
2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = "<group>"; };
|
||||
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||
|
@ -1126,6 +1128,7 @@
|
|||
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
|
||||
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
|
||||
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */,
|
||||
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */,
|
||||
);
|
||||
path = HashtagTimeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3301,6 +3304,7 @@
|
|||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
DB025B78278D606A002F581E /* StatusItem.swift in Sources */,
|
||||
DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */,
|
||||
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */,
|
||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||
DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// HashtagTimelineHeaderView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Marcus Kida on 22.11.22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
import MastodonUI
|
||||
|
||||
fileprivate extension CGFloat {
|
||||
static let padding: CGFloat = 16
|
||||
static let descriptionLabelSpacing: CGFloat = 12
|
||||
}
|
||||
|
||||
final class HashtagTimelineHeaderView: UIView {
|
||||
let titleLabel = UILabel()
|
||||
|
||||
let postCountLabel = UILabel()
|
||||
let participantsLabel = UILabel()
|
||||
let postsTodayLabel = UILabel()
|
||||
|
||||
let postCountDescLabel = UILabel()
|
||||
let participantsDescLabel = UILabel()
|
||||
let postsTodayDescLabel = UILabel()
|
||||
|
||||
let followButton: UIButton = {
|
||||
let button = RoundedEdgesButton(type: .custom)
|
||||
button.cornerRadius = 10
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
button.backgroundColor = .black
|
||||
return button
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension HashtagTimelineHeaderView {
|
||||
func setupLayout() {
|
||||
[titleLabel, postCountLabel, participantsLabel, postsTodayLabel, postCountDescLabel, participantsDescLabel, postsTodayDescLabel, followButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview($0)
|
||||
}
|
||||
|
||||
// hashtag name / title
|
||||
titleLabel.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold))
|
||||
|
||||
[postCountLabel, participantsLabel, postsTodayLabel].forEach {
|
||||
$0.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: .systemFont(ofSize: 20, weight: .bold))
|
||||
$0.text = "999"
|
||||
}
|
||||
|
||||
[postCountDescLabel, participantsDescLabel, postsTodayDescLabel].forEach {
|
||||
$0.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
|
||||
}
|
||||
|
||||
postCountDescLabel.text = "posts"
|
||||
participantsDescLabel.text = "participants"
|
||||
postsTodayDescLabel.text = "posts today"
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: .padding),
|
||||
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .padding),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
|
||||
|
||||
postCountLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
|
||||
postCountLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
||||
postCountDescLabel.leadingAnchor.constraint(equalTo: postCountLabel.leadingAnchor),
|
||||
|
||||
participantsDescLabel.leadingAnchor.constraint(equalTo: postCountDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
|
||||
participantsLabel.centerXAnchor.constraint(equalTo: participantsDescLabel.centerXAnchor),
|
||||
participantsLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
|
||||
|
||||
postsTodayDescLabel.leadingAnchor.constraint(equalTo: participantsDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
|
||||
postsTodayLabel.centerXAnchor.constraint(equalTo: postsTodayDescLabel.centerXAnchor),
|
||||
postsTodayLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
|
||||
|
||||
postCountDescLabel.topAnchor.constraint(equalTo: postCountLabel.bottomAnchor),
|
||||
participantsDescLabel.topAnchor.constraint(equalTo: participantsLabel.bottomAnchor),
|
||||
postsTodayDescLabel.topAnchor.constraint(equalTo: postsTodayLabel.bottomAnchor),
|
||||
|
||||
postCountDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
|
||||
participantsDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
|
||||
postsTodayDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
|
||||
|
||||
followButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
|
||||
followButton.bottomAnchor.constraint(equalTo: postsTodayDescLabel.bottomAnchor),
|
||||
followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor),
|
||||
followButton.widthAnchor.constraint(equalToConstant: 84)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension HashtagTimelineHeaderView {
|
||||
func update(_ entity: Mastodon.Entity.Tag) {
|
||||
titleLabel.text = "#\(entity.name)"
|
||||
followButton.setTitle(entity.following == true ? "Unfollow" : "Follow", for: .normal)
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import MastodonAsset
|
|||
import MastodonCore
|
||||
import MastodonUI
|
||||
import MastodonLocalization
|
||||
import MastodonSDK
|
||||
|
||||
final class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
|
@ -27,6 +28,18 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
|
|||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: HashtagTimelineViewModel!
|
||||
|
||||
private lazy var headerView: HashtagTimelineHeaderView = {
|
||||
let headerView = HashtagTimelineHeaderView()
|
||||
headerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
headerView.heightAnchor.constraint(equalToConstant: 118),
|
||||
// headerView.widthAnchor.constraint(equalTo: tableView.widthAnchor)
|
||||
])
|
||||
|
||||
return headerView
|
||||
}()
|
||||
|
||||
let composeBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
|
@ -114,6 +127,14 @@ extension HashtagTimelineViewController {
|
|||
self?.updatePromptTitle()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.hashtagDetails
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] tag in
|
||||
guard let tag = tag else { return }
|
||||
self?.updateHeaderView(with: tag)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -148,7 +169,15 @@ extension HashtagTimelineViewController {
|
|||
subtitle = L10n.Plural.peopleTalking(peopleTalkingNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HashtagTimelineViewController {
|
||||
private func updateHeaderView(with tag: Mastodon.Entity.Tag) {
|
||||
if tableView.tableHeaderView == nil {
|
||||
tableView.tableHeaderView = headerView
|
||||
}
|
||||
headerView.update(tag)
|
||||
}
|
||||
}
|
||||
|
||||
extension HashtagTimelineViewController {
|
||||
|
|
|
@ -23,7 +23,7 @@ final class HashtagTimelineViewModel {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var needLoadMiddleIndex: Int? = nil
|
||||
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let authContext: AuthContext
|
||||
|
@ -32,10 +32,11 @@ final class HashtagTimelineViewModel {
|
|||
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
|
||||
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
|
||||
let didLoadLatest = PassthroughSubject<Void, Never>()
|
||||
let hashtagDetails = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
|
||||
|
||||
// bottom loader
|
||||
private(set) lazy var stateMachine: GKStateMachine = {
|
||||
|
@ -61,6 +62,7 @@ final class HashtagTimelineViewModel {
|
|||
domain: authContext.mastodonAuthenticationBox.domain,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
updateTagInformation()
|
||||
// end init
|
||||
}
|
||||
|
||||
|
@ -70,3 +72,15 @@ final class HashtagTimelineViewModel {
|
|||
|
||||
}
|
||||
|
||||
private extension HashtagTimelineViewModel {
|
||||
func updateTagInformation() {
|
||||
Task { @MainActor in
|
||||
let tag = try? await context.apiService.getTagInformation(
|
||||
for: hashtag,
|
||||
authenticationBox: authContext.mastodonAuthenticationBox
|
||||
).value
|
||||
|
||||
self.hashtagDetails.send(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,3 +161,40 @@ extension APIService {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension APIService {
|
||||
public func getFollowedTags(
|
||||
domain: String,
|
||||
query: Mastodon.API.Account.FollowedTagsQuery,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> {
|
||||
let domain = authenticationBox.domain
|
||||
let authorization = authenticationBox.userAuthorization
|
||||
|
||||
let response = try await Mastodon.API.Account.followedTags(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query,
|
||||
authorization: authorization
|
||||
).singleOutput()
|
||||
|
||||
let managedObjectContext = self.backgroundManagedObjectContext
|
||||
try await managedObjectContext.performChanges {
|
||||
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
|
||||
|
||||
for entity in response.value {
|
||||
_ = Persistence.Tag.createOrMerge(
|
||||
in: managedObjectContext,
|
||||
context: Persistence.Tag.PersistContext(
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
me: me,
|
||||
networkDate: response.networkDate
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} // end func
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// APIService+Tags.swift
|
||||
//
|
||||
//
|
||||
// Created by Marcus Kida on 23.11.22.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
|
||||
public func getTagInformation(
|
||||
for tag: String,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
|
||||
let domain = authenticationBox.domain
|
||||
let authorization = authenticationBox.userAuthorization
|
||||
|
||||
let response = try await Mastodon.API.Tags.tag(
|
||||
session: session,
|
||||
domain: domain,
|
||||
tagId: tag,
|
||||
authorization: authorization
|
||||
).singleOutput()
|
||||
|
||||
let managedObjectContext = self.backgroundManagedObjectContext
|
||||
try await managedObjectContext.performChanges {
|
||||
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
|
||||
|
||||
_ = Persistence.Tag.createOrMerge(
|
||||
in: managedObjectContext,
|
||||
context: Persistence.Tag.PersistContext(
|
||||
domain: domain,
|
||||
entity: response.value,
|
||||
me: me,
|
||||
networkDate: response.networkDate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
} // end func
|
||||
|
||||
}
|
|
@ -28,7 +28,7 @@ extension Mastodon.API.Account {
|
|||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `[Tag]` nested in the response
|
||||
public static func followers(
|
||||
public static func followedTags(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: FollowedTagsQuery,
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// Mastodin+API+Tags.swift
|
||||
//
|
||||
//
|
||||
// Created by Marcus Kida on 23.11.22.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.API.Tags {
|
||||
static func tagsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain)
|
||||
.appendingPathComponent("tags")
|
||||
}
|
||||
|
||||
/// Followed Tags
|
||||
///
|
||||
/// View your followed hashtags.
|
||||
///
|
||||
/// - Since: 4.0.0
|
||||
/// - Version: 4.0.3
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/tags/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - authorization: User token
|
||||
/// - tagId: The Hashtag
|
||||
/// - Returns: `AnyPublisher` contains `Tag` nested in the response
|
||||
public static func tag(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
tagId: String,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Tag>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId),
|
||||
query: nil,
|
||||
authorization: authorization
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -112,6 +112,7 @@ extension Mastodon.API {
|
|||
public enum Polls { }
|
||||
public enum Reblog { }
|
||||
public enum Statuses { }
|
||||
public enum Tags {}
|
||||
public enum Timeline { }
|
||||
public enum Trends { }
|
||||
public enum Suggestions { }
|
||||
|
|
Loading…
Reference in New Issue