From b63a5ebe5faba3520375873469adf510333946ae Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 10:21:51 +0800 Subject: [PATCH] feat: use search api to fetch tag info --- Localization/app.json | 3 + Mastodon.xcodeproj/project.pbxproj | 12 ++ Mastodon/Generated/Strings.swift | 6 + .../Resources/en.lproj/Localizable.strings | 1 + .../HashtagTimelineViewController.swift | 28 +++- .../HashtagTimelineViewModel.swift | 19 +++ .../View/HashtagTimelineTitleView.swift | 59 +++++++++ .../API/Mastodon+API+Favorites.swift | 2 +- .../API/Mastodon+API+Notifications.swift | 125 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API+Search.swift | 19 ++- .../API/Mastodon+API+Timeline.swift | 4 +- .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+SearchResult.swift | 6 +- 13 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift diff --git a/Localization/app.json b/Localization/app.json index e2d64db0c..289f91277 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -240,6 +240,9 @@ "placeholder": "Search hashtags and users", "cancel": "Cancel" } + }, + "hashtag": { + "prompt": "%s people talking" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b56a389d9..9dc5cfb1d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -325,6 +326,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -652,9 +654,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F1E2D102615C39800C38565 /* View */ = { + isa = PBXGroup; + children = ( + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { isa = PBXGroup; children = ( + 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, @@ -1907,6 +1918,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1d2bd87a7..1afa816ff 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -249,6 +249,12 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") } } + internal enum Hashtag { + /// %@ people talking + internal static func prompt(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1)) + } + } internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index aecd96757..2dfa0ebc7 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -80,6 +80,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ba6d30e32..fd33cb883 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -95,13 +95,21 @@ extension HashtagTimelineViewController { } } .store(in: &disposeBag) + + viewModel.hashtagEntity + .receive(on: DispatchQueue.main) + .sink { [weak self] tag in + self?.updatePromptTitle() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + viewModel.fetchTag() guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } - tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + refreshControl.beginRefreshing() refreshControl.sendActions(for: .valueChanged) } @@ -123,6 +131,24 @@ extension HashtagTimelineViewController { self.tableView.reloadData() } } + + private func updatePromptTitle() { + guard let histories = viewModel.hashtagEntity.value?.history else { + navigationItem.prompt = nil + return + } + if histories.isEmpty { + // No tag history, remove the prompt title + navigationItem.prompt = nil + } else { + let sortedHistory = histories.sorted { (h1, h2) -> Bool in + return h1.day > h2.day + } + if let accountsNumber = sortedHistory.first?.accounts { + navigationItem.prompt = L10n.Scene.Hashtag.prompt(accountsNumber) + } + } + } } extension HashtagTimelineViewController { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 19144d199..8f2e07874 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -27,6 +27,7 @@ final class HashtagTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) + let hashtagEntity = CurrentValueSubject(nil) weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? @@ -105,6 +106,24 @@ final class HashtagTimelineViewModel: NSObject { .store(in: &disposeBag) } + func fetchTag() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags) + context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { _ in + + } receiveValue: { [weak self] response in + let matchedTag = response.value.hashtags.first { tag -> Bool in + return tag.name == self?.hashTag + } + self?.hashtagEntity.send(matchedTag) + } + .store(in: &disposeBag) + + } + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift new file mode 100644 index 000000000..04782bf63 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -0,0 +1,59 @@ +// +// HashtagTimelineTitleView.swift +// Mastodon +// +// Created by BradGao on 2021/4/1. +// + +import UIKit + +final class HashtagTimelineTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 3b01c2c13..cb01e83eb 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -121,7 +121,7 @@ extension Mastodon.API.Favorites { case destroy } - public struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,PagedQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift new file mode 100644 index 000000000..cdee82926 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -0,0 +1,125 @@ +// +// File.swift +// +// +// Created by BradGao on 2021/4/1. +// + +import Foundation +import Combine + +extension Mastodon.API.Notifications { + static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") + } + static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) + } + + /// Get all notifications + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `GetAllNotificationsQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getAll( + session: URLSession, + domain: String, + query: GetAllNotificationsQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: notificationsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Notification].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Get a single notification + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - notificationID: ID of the notification. + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func get( + session: URLSession, + domain: String, + notificationID: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Notification.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let excludeTypes: [String]? + public let accountID: String? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + excludeTypes: [String]? = nil, + accountID: String? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.excludeTypes = excludeTypes + self.accountID = accountID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let excludeTypes = excludeTypes { + excludeTypes.forEach { + items.append(URLQueryItem(name: "exclude_types[]", value: $0)) + } + } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 8f266437f..42dfc1e25 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -50,8 +50,21 @@ extension Mastodon.API.Search { } extension Mastodon.API.Search { + public enum SearchType: String, Codable { + case ccounts, hashtags, statuses + } + public struct Query: Codable, GetQuery { - public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { + public init(q: String, + type: SearchType? = nil, + accountID: Mastodon.Entity.Account.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + excludeUnreviewed: Bool? = nil, + resolve: Bool? = nil, + limit: Int? = nil, + offset: Int? = nil, + following: Bool? = nil) { self.accountID = accountID self.maxID = maxID self.minID = minID @@ -67,7 +80,7 @@ extension Mastodon.API.Search { public let accountID: Mastodon.Entity.Account.ID? public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? - public let type: String? + public let type: SearchType? public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. public let q: String public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false. @@ -80,7 +93,7 @@ extension Mastodon.API.Search { accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } - type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) } + type.flatMap { items.append(URLQueryItem(name: "type", value: $0.rawValue)) } excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } items.append(URLQueryItem(name: "q", value: q)) resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index a9b5c4f12..c1857ae82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -121,14 +121,14 @@ extension Mastodon.API.Timeline { } } -public protocol TimelineQueryType { +public protocol PagedQueryType { var maxID: Mastodon.Entity.Status.ID? { get } var sinceID: Mastodon.Entity.Status.ID? { get } } extension Mastodon.API.Timeline { - public typealias TimelineQuery = TimelineQueryType + public typealias TimelineQuery = PagedQueryType public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index ac960e710..cdb6c2f14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -107,6 +107,7 @@ extension Mastodon.API { public enum Search { } public enum Trends { } public enum Suggestions { } + public enum Notifications { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f06f1a54e..f10339664 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,9 +8,9 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { - let accounts: [Mastodon.Entity.Account] - let statuses: [Mastodon.Entity.Status] - let hashtags: [Mastodon.Entity.Tag] + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] } }