From 178a6e503a7d7b5f668341083aea4b31dfa879b7 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 23 Nov 2022 15:57:51 +0100 Subject: [PATCH] feat: Implement layout for hashtag timeline header view --- Mastodon.xcodeproj/project.pbxproj | 4 + .../HashtagTimelineHeaderView.swift | 109 ++++++++++++++++++ .../HashtagTimelineViewController.swift | 29 +++++ .../HashtagTimelineViewModel.swift | 18 ++- .../Service/API/APIService+Account.swift | 37 ++++++ .../Service/API/APIService+Tags.swift | 49 ++++++++ .../Mastodon+API+Account+FollowedTags.swift | 2 +- .../MastodonSDK/API/Mastodon+API+Tags.swift | 49 ++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 9 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 46d8bc2ea..2102647ec 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; + 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; @@ -1126,6 +1128,7 @@ 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */, + 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */, ); path = HashtagTimeline; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift new file mode 100644 index 000000000..80a6795f3 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -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) + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 4a0be3816..d48220615 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -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() 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 { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index af4d2a01a..86ae80704 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -23,7 +23,7 @@ final class HashtagTimelineViewModel { var disposeBag = Set() var needLoadMiddleIndex: Int? = nil - + // input let context: AppContext let authContext: AuthContext @@ -32,10 +32,11 @@ final class HashtagTimelineViewModel { let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) let listBatchFetchViewModel = ListBatchFetchViewModel() - + // output var diffableDataSource: UITableViewDiffableDataSource? let didLoadLatest = PassthroughSubject() + let hashtagDetails = CurrentValueSubject(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) + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index 1b6a57a83..c27f8cf67 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -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 +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift new file mode 100644 index 000000000..80e0d083f --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift @@ -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 { + 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 + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift index f1020180f..02c79e35b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowedTags.swift @@ -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, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift new file mode 100644 index 000000000..d8c8f6354 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Tags.swift @@ -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, 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() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5d507bace..a1eb47873 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -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 { }