From 8c3040c0f9ef495ea3fc8eb9d4fdb34eecc1858b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Tue, 30 Mar 2021 14:16:08 +0800 Subject: [PATCH 1/8] feat: add hashtag timeline API --- .../API/Mastodon+API+Timeline.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 03a718b5b..6ab897123 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -16,6 +16,10 @@ extension Mastodon.API.Timeline { static func homeTimelineEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home") } + static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("tag/\(hashtag)") + } /// View public timeline statuses /// @@ -81,6 +85,38 @@ extension Mastodon.API.Timeline { .eraseToAnyPublisher() } + /// View public statuses containing the given hashtag. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/29 + /// # Reference + /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `HashtagTimelineQuery` with query parameters + /// - hashtag: Content of a #hashtag, not including # symbol. + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func hashtag( + session: URLSession, + domain: String, + query: HashtagTimelineQuery, + hashtag: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } public protocol TimelineQueryType { @@ -167,4 +203,41 @@ extension Mastodon.API.Timeline { } } + public struct HashtagTimelineQuery: Codable, TimelineQuery, 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 local: Bool? + public let onlyMedia: Bool? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + local: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.local = local + self.onlyMedia = onlyMedia + } + + 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))) } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } + } From d548840bd913606e612a0eb885396614209a48a8 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Thu, 1 Apr 2021 10:12:57 +0800 Subject: [PATCH 2/8] feat: implement hashtag timeline --- Mastodon.xcodeproj/project.pbxproj | 44 +++ Mastodon/Coordinator/SceneCoordinator.swift | 7 + .../Extension/Array+removeDuplicates.swift | 23 ++ ...Provider+StatusTableViewCellDelegate.swift | 18 ++ Mastodon/Scene/Compose/ComposeViewModel.swift | 22 +- ...imelineViewController+StatusProvider.swift | 88 ++++++ .../HashtagTimelineViewController.swift | 279 ++++++++++++++++++ .../HashtagTimelineViewModel+Diffable.swift | 128 ++++++++ ...tagTimelineViewModel+LoadLatestState.swift | 104 +++++++ ...tagTimelineViewModel+LoadMiddleState.swift | 131 ++++++++ ...tagTimelineViewModel+LoadOldestState.swift | 121 ++++++++ .../HashtagTimelineViewModel.swift | 112 +++++++ .../Scene/Share/View/Content/StatusView.swift | 9 + .../TableviewCell/StatusTableViewCell.swift | 7 + .../APIService+HashtagTimeline.swift | 70 +++++ .../API/Mastodon+API+Timeline.swift | 8 +- 16 files changed, 1165 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Extension/Array+removeDuplicates.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+HashtagTimeline.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5179c3870..b56a389d9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -316,6 +325,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -634,6 +652,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { + isa = PBXGroup; + children = ( + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, + ); + path = HashtagTimeline; + sourceTree = ""; + }; 0FAA0FDD25E0B5700017CCDE /* Welcome */ = { isa = PBXGroup; children = ( @@ -1119,6 +1151,7 @@ DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, ); path = APIService; sourceTree = ""; @@ -1328,6 +1361,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 0F2021F5261325ED000C64BF /* HashtagTimeline */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, @@ -1369,6 +1403,7 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, ); path = Extension; sourceTree = ""; @@ -1892,6 +1927,7 @@ 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, @@ -1911,6 +1947,7 @@ 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, @@ -1941,6 +1978,7 @@ DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, @@ -1978,6 +2016,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, @@ -2005,6 +2044,7 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2012,7 +2052,9 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, @@ -2021,10 +2063,12 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..fb0603251 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -50,6 +50,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // Hashtag Timeline + case hashtagTimeline(viewModel: HashtagTimelineViewModel) + // misc case alertController(alertController: UIAlertController) @@ -206,6 +209,10 @@ private extension SceneCoordinator { ) } viewController = alertController + case .hashtagTimeline(let viewModel): + let _viewController = HashtagTimelineViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift new file mode 100644 index 000000000..c3a4b0384 --- /dev/null +++ b/Mastodon/Extension/Array+removeDuplicates.swift @@ -0,0 +1,23 @@ +// +// Array+removeDuplicates.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f3d31ff33..1ba6e60cf 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -192,3 +192,21 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +// MARK: - ActiveLabel didSelect ActiveEntity +extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let hashtag, let userInfo): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag) + coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show) + break + case .email(let content, let userInfo): + break + case .mention(let mention, let userInfo): + break + case .url(let content, let trimmed, let url, let userInfo): + break + } + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52ca4cc88..52a7bf2f4 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,6 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) + var injectedContent: String? = nil + // custom emojis var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) @@ -71,10 +73,12 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + injectedContent: String? = nil ) { self.context = context self.composeKind = composeKind + self.injectedContent = injectedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -195,9 +199,16 @@ final class ComposeViewModel { // bind modal dismiss state composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) - .map { content in + .map { [weak self] content in let content = content ?? "" - return content.isEmpty + if content.isEmpty { + return true + } + // if injectedContent plus a space is equal to the content, simply dismiss the modal + if let injectedContent = self?.injectedContent { + return content == (injectedContent + " ") + } + return false } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) @@ -304,6 +315,11 @@ final class ComposeViewModel { self.isPollToolbarButtonEnabled.value = !shouldPollDisable }) .store(in: &disposeBag) + + if let injectedContent = injectedContent { + // add a space after the injected text + composeStatusAttribute.composeContent.send(injectedContent + " ") + } } deinit { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..e4092ce0f --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -0,0 +1,88 @@ +// +// HashtagTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension HashtagTimelineViewController: StatusProvider { + + func toot() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .homeTimelineIndex(let objectID, _): + let managedObjectContext = self.viewModel.context.managedObjectContext + managedObjectContext.perform { + let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex + promise(.success(timelineIndex?.toot)) + } + default: + promise(.success(nil)) + } + } + } + + func toot(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.context.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift new file mode 100644 index 000000000..ba6d30e32 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -0,0 +1,279 @@ +// +// HashtagTimelineViewController.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit +import CoreData + +class HashtagTimelineViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: HashtagTimelineViewModel! + + let composeBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.Label.highlight.color + barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension HashtagTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "#\(viewModel.hashTag)" + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + navigationItem.rightBarButtonItem = composeBarButtonItem + + composeBarButtonItem.target = self + composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:)) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self + ) + + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + 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) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { _ in + // do nothing + } completion: { _ in + // fix AutoLayout cell height not update after rotate issue + self.viewModel.cellFrameCache.removeAllObjects() + self.tableView.reloadData() + } + } +} + +extension HashtagTimelineViewController { + + @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } +} + +// MARK: - UIScrollViewDelegate +extension HashtagTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) +// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + } +} + +extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +} + +// MARK: - UITableViewDelegate +extension HashtagTimelineViewController: UITableViewDelegate { + + // TODO: + // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + // + // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + // return 200 + // } + // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + // + // return ceil(frame.height) + // } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + + +// MARK: - UITableViewDataSourcePrefetching +extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineIndexObjectID = timelineIndexobjectID else { + return + } + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let _ = self else { return } + if let stateMachine = ids[upperTimelineIndexObjectID] { + guard let state = stateMachine.currentState else { + assertionFailure() + return + } + + // make success state same as loading due to snapshot updating delay + let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success + if isLoading { + cell.startAnimating() + } else { + cell.stopAnimating() + } + } else { + cell.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineIndexObjectID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + ]) + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self) + dict[upperTimelineIndexObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict + } + } + + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .homeMiddleLoader(let upper): + guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { + assertionFailure() + return + } + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self) + default: + assertionFailure() + } + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - StatusTableViewCellDelegate +extension HashtagTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..a41568787 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -0,0 +1,128 @@ +// +// HashtagTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + ) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + guard let tableView = self.tableView else { return } + guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + + let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + let oldSnapshot = diffableDataSource.snapshot() + let snapshot = snapshot as NSDiffableDataSourceSnapshot + + let statusItemList: [Item] = snapshot.itemIdentifiers.map { + let status = managedObjectContext.object(with: $0) as! Toot + + let isStatusTextSensitive: Bool = { + guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + } + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + + // Check if there is a `needLoadMiddleIndex` + if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { + // If yes, insert a `middleLoader` at the index + var newItems = statusItemList + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newSnapshot.appendItems(newItems, toSection: .main) + } else { + newSnapshot.appendItems(statusItemList, toSection: .main) + } + + if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + self.isFetchingLatestTimeline.value = false + return + } + + DispatchQueue.main.async { + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestTimeline.value = false + } + } + } + + private struct Difference { + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + + let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! + + guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil } + + if oldItemBeginIndexInNewSnapshot > 0 { + let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0) + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar) + return Difference( + targetIndexPath: targetIndexPath, + offset: offset + ) + } + return nil + } + +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..b3cb2cc3b --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,104 @@ +// +// HashtagTimelineViewModel+LoadLatestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +extension HashtagTimelineViewModel { + class LoadLatestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadLatestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadLatestState { + class Initial: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + // sign out when loading will enter here + stateMachine.enter(Fail.self) + return + } + // TODO: only set large count when using Wi-Fi + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + viewModel.isFetchingLatestTimeline.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + + } receiveValue: { response in + let newStatusIDList = response.value.map { $0.id } + + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) + } else { + viewModel.needLoadMiddleIndex = nil + } + + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) + viewModel.hashtagStatusIDList.removeDuplicates() + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..3c3b01d87 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,131 @@ +// +// HashtagTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: HashtagTimelineViewModel? + let upperStatusObjectID: NSManagedObjectID + + init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) { + self.viewModel = viewModel + self.upperStatusObjectID = upperStatusObjectID + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + var dict = viewModel.loadMiddleSateMachineList.value + dict[upperStatusObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict // trigger value change + } + } +} + +extension HashtagTimelineViewModel.LoadMiddleState { + + class Initial: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Success.self || stateClass == Fail.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + stateMachine.enter(Fail.self) + return + } + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + status.id + } + + // TODO: only set large count when using Wi-Fi + let maxID = upperStatusObject.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + // TODO: handle error + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(Success.self) + + let newStatusIDList = response.value.map { $0.id } + + if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count + } else { + viewModel.needLoadMiddleIndex = nil + } + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + viewModel.hashtagStatusIDList.removeDuplicates() + } else { + // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index + // Then there is no need to set a `loadMiddleState` cell + viewModel.needLoadMiddleIndex = nil + } + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..f503420a7 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,121 @@ +// +// HashtagTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadOldestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadOldestState { + class Initial: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + stateMachine.enter(Idle.self) + return + } + + // TODO: only set large count when using Wi-Fi + let maxID = last.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { response in + let toots = response.value + // enter no more state when no new toots + if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + let newStatusIDList = toots.map { $0.id } + viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift new file mode 100644 index 000000000..19144d199 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -0,0 +1,112 @@ +// +// HashtagTimelineViewModel.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class HashtagTimelineViewModel: NSObject { + + let hashTag: String + + var disposeBag = Set() + + var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() + var needLoadMiddleIndex: Int? = nil + + // input + let context: AppContext + let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) + let timelinePredicate = CurrentValueSubject(nil) + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + // top loader + private(set) lazy var loadLatestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadLatestState.Initial(viewModel: self), + LoadLatestState.Loading(viewModel: self), + LoadLatestState.Fail(viewModel: self), + LoadLatestState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadLatestState.Initial.self) + return stateMachine + }() + lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + // middle loader + let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine + var diffableDataSource: UITableViewDiffableDataSource? + var cellFrameCache = NSCache() + + + init(context: AppContext, hashTag: String) { + self.context = context + self.hashTag = hashTag + self.fetchedResultsController = { + let fetchRequest = Toot.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + timelinePredicate + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + self.diffableDataSource?.defaultRowAnimation = .fade + try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .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/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 6d7800b04..7db897f62 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -15,6 +15,7 @@ protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) } final class StatusView: UIView { @@ -400,6 +401,7 @@ extension StatusView { statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false playerContainerView.delegate = self + activeTextLabel.delegate = self contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) @@ -467,6 +469,13 @@ extension StatusView: AvatarConfigurableView { var configurableVerifiedBadgeImageView: UIImageView? { nil } } +// MARK: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity) + } +} + #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5f9bdf654..cb74bbf13 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -11,6 +11,7 @@ import AVKit import Combine import CoreData import CoreDataStack +import ActiveLabel protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } @@ -29,6 +30,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) } extension StatusTableViewCellDelegate { @@ -206,6 +209,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift new file mode 100644 index 000000000..d3e9d6208 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+HashtagTimeline.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func hashtagTimeline( + domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestTootMaxCount, + local: Bool? = nil, + hashtag: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Timeline.HashtagTimelineQuery( + maxID: maxID, + sinceID: sinceID, + minID: nil, // prefer sinceID + limit: limit, + local: local, + onlyMedia: false + ) + + return Mastodon.API.Timeline.hashtag( + session: session, + domain: domain, + query: query, + hashtag: hashtag, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, + response: response, + persistType: .lookUp, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 6ab897123..a9b5c4f12 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -18,7 +18,7 @@ extension Mastodon.API.Timeline { } static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { return Mastodon.API.endpointURL(domain: domain) - .appendingPathComponent("tag/\(hashtag)") + .appendingPathComponent("timelines/tag/\(hashtag)") } /// View public timeline statuses @@ -98,17 +98,19 @@ extension Mastodon.API.Timeline { /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `HashtagTimelineQuery` with query parameters /// - hashtag: Content of a #hashtag, not including # symbol. + /// - authorization: User token, auth is required if public preview is disabled /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func hashtag( session: URLSession, domain: String, query: HashtagTimelineQuery, - hashtag: String + hashtag: String, + authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), query: query, - authorization: nil + authorization: authorization ) return session.dataTaskPublisher(for: request) .tryMap { data, response in From b63a5ebe5faba3520375873469adf510333946ae Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 10:21:51 +0800 Subject: [PATCH 3/8] 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] } } From 4f77688d0393901269a934bb4600a429f209ac3f Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 16:38:33 +0800 Subject: [PATCH 4/8] feat: add nativation title view --- .../HashtagTimelineViewController.swift | 16 ++++-- .../View/HashtagTimelineTitleView.swift | 54 +++++++++++-------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index fd33cb883..58cf14d6d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -41,6 +41,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { let refreshControl = UIRefreshControl() + let titleView = HashtagTimelineNavigationBarTitleView() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } @@ -52,6 +54,9 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashTag)" + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil) + navigationItem.titleView = titleView + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color navigationItem.rightBarButtonItem = composeBarButtonItem @@ -133,20 +138,21 @@ extension HashtagTimelineViewController { } private func updatePromptTitle() { + var subtitle: String? + defer { + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle) + } 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 + return } 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) - } + subtitle = sortedHistory.first?.accounts } } } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift index 04782bf63..78d5a971c 100644 --- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -7,20 +7,26 @@ import UIKit -final class HashtagTimelineTitleView: UIView { +final class HashtagTimelineNavigationBarTitleView: UIView { let containerView = UIStackView() - let imageView = UIImageView() - let button = RoundedEdgesButton() - let label = UILabel() + let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() - // input - private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? - weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? - - // output - private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.isHidden = true + return label + }() override init(frame: CGRect) { super.init(frame: frame) @@ -34,8 +40,11 @@ final class HashtagTimelineTitleView: UIView { } -extension HomeTimelineNavigationBarTitleView { +extension HashtagTimelineNavigationBarTitleView { private func _init() { + containerView.axis = .vertical + containerView.alignment = .center + containerView.distribution = .fill containerView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerView) NSLayoutConstraint.activate([ @@ -45,15 +54,18 @@ extension HomeTimelineNavigationBarTitleView { 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) + containerView.addArrangedSubview(titleLabel) + containerView.addArrangedSubview(subtitleLabel) + } + + func updateTitle(hashtag: String, peopleNumber: String?) { + titleLabel.text = "#\(hashtag)" + if let peopleNumebr = peopleNumber { + subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) + subtitleLabel.isHidden = false + } else { + subtitleLabel.text = nil + subtitleLabel.isHidden = true + } } } From a9d35109fd73d5df2d84b0831ec5ad058ea7d39b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 17:05:06 +0800 Subject: [PATCH 5/8] feat: update mechanism of calculating number of people taking tags --- .../HashtagTimeline/HashtagTimelineViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 58cf14d6d..e82bb31ae 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -152,7 +152,11 @@ extension HashtagTimelineViewController { let sortedHistory = histories.sorted { (h1, h2) -> Bool in return h1.day > h2.day } - subtitle = sortedHistory.first?.accounts + let peopleTalkingNumber = sortedHistory + .prefix(2) + .compactMap({ Int($0.accounts) }) + .reduce(0, +) + subtitle = "\(peopleTalkingNumber)" } } } From 28cfe961715b16d36259d16bcdf854ea019ecb66 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 19:40:15 +0800 Subject: [PATCH 6/8] chore: rename Toot -> Status --- ...ashtagTimelineViewController+StatusProvider.swift | 8 ++++---- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 4 ++-- .../HashtagTimelineViewModel+LoadLatestState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadMiddleState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadOldestState.swift | 12 ++++++------ .../HashtagTimeline/HashtagTimelineViewModel.swift | 4 ++-- .../APIService/APIService+HashtagTimeline.swift | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index e4092ce0f..7263e6ab8 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -14,11 +14,11 @@ import CoreDataStack // MARK: - StatusProvider extension HashtagTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -36,7 +36,7 @@ extension HashtagTimelineViewController: StatusProvider { let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.toot)) + promise(.success(timelineIndex?.status)) } default: promise(.success(nil)) @@ -44,7 +44,7 @@ extension HashtagTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index e82bb31ae..c831cf215 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -234,7 +234,7 @@ extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a41568787..9a6102e09 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -56,13 +56,13 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let snapshot = snapshot as NSDiffableDataSourceSnapshot let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Toot + let status = managedObjectContext.object(with: $0) as! Status let isStatusTextSensitive: Bool = { guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } return true }() - return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index b3cb2cc3b..d8e286195 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadLatestState { case .failure(let error): // TODO: handle error viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -83,7 +83,7 @@ extension HashtagTimelineViewModel.LoadLatestState { viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) viewModel.hashtagStatusIDList.removeDuplicates() - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index 3c3b01d87..e971659e1 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -76,7 +76,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { switch completion { case .failure(let error): // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break @@ -105,7 +105,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { viewModel.needLoadMiddleIndex = nil } - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index f503420a7..d464d3a50 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -66,22 +66,22 @@ extension HashtagTimelineViewModel.LoadOldestState { // viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) } - let newStatusIDList = toots.map { $0.id } + let newStatusIDList = statuses.map { $0.id } viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 8f2e07874..e7f167f2a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -24,7 +24,7 @@ final class HashtagTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -70,7 +70,7 @@ final class HashtagTimelineViewModel: NSObject { self.context = context self.hashTag = hashTag self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest + let fetchRequest = Status.sortedFetchRequest fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift index d3e9d6208..69c2c7486 100644 --- a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, hashtag: String, authorizationBox: AuthenticationService.MastodonAuthenticationBox From a61e662f3891ed2f546639b6c628ef59e30bf4da Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 13:57:03 +0800 Subject: [PATCH 7/8] fix: resolve requested changes --- Mastodon.xcodeproj/project.pbxproj | 4 + .../StatusWithGapFetchResultController.swift | 85 +++++++++++++++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 17 ++-- ...imelineViewController+StatusProvider.swift | 6 +- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 13 +-- .../Welcome/WelcomeViewController.swift | 2 +- .../UserTimelineViewModel+State.swift | 7 ++ .../API/Mastodon+API+Favorites.swift | 2 +- 9 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 824b60ad1..9af5bd234 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.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 */; }; @@ -356,6 +357,7 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.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 = ""; }; @@ -1659,6 +1661,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2137,6 +2140,7 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift new file mode 100644 index 000000000..f392c893d --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift @@ -0,0 +1,85 @@ +// +// StatusWithGapFetchResultController.swift +// Mastodon +// +// Created by BradGao on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +class StatusWithGapFetchResultController: NSObject { + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + var needLoadMiddleIndex: Int? = nil + + // output + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Status.predicate(domain: domain ?? "", ids: ids), + additionalTweetPredicate + ]) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = statusIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.objectIDs.value = items + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52a7bf2f4..03211e3d3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,7 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) - var injectedContent: String? = nil + // In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users. + var preInsertedContent: String? = nil // custom emojis var customEmojiViewModelSubscription: AnyCancellable? @@ -74,11 +75,11 @@ final class ComposeViewModel { init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind, - injectedContent: String? = nil + preInsertedContent: String? = nil ) { self.context = context self.composeKind = composeKind - self.injectedContent = injectedContent + self.preInsertedContent = preInsertedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -204,9 +205,9 @@ final class ComposeViewModel { if content.isEmpty { return true } - // if injectedContent plus a space is equal to the content, simply dismiss the modal - if let injectedContent = self?.injectedContent { - return content == (injectedContent + " ") + // if preInsertedContent plus a space is equal to the content, simply dismiss the modal + if let preInsertedContent = self?.preInsertedContent { + return content == (preInsertedContent + " ") } return false } @@ -316,9 +317,9 @@ final class ComposeViewModel { }) .store(in: &disposeBag) - if let injectedContent = injectedContent { + if let preInsertedContent = preInsertedContent { // add a space after the injected text - composeStatusAttribute.composeContent.send(injectedContent + " ") + composeStatusAttribute.composeContent.send(preInsertedContent + " ") } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index 7263e6ab8..23068b7bc 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -32,11 +32,11 @@ extension HashtagTimelineViewController: StatusProvider { } switch item { - case .homeTimelineIndex(let objectID, _): + case .status(let objectID, _): let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.status)) + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) } default: promise(.success(nil)) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index c831cf215..1dbb0323c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -165,7 +165,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)") coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 9a6102e09..a0bf5d82d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -55,14 +55,17 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let oldSnapshot = diffableDataSource.snapshot() let snapshot = snapshot as NSDiffableDataSourceSnapshot + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + let statusItemList: [Item] = snapshot.itemIdentifiers.map { let status = managedObjectContext.object(with: $0) as! Status - let isStatusTextSensitive: Bool = { - guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() + return Item.status(objectID: $0, attribute: attribute) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 838f1327a..c647d04ca 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -222,6 +222,6 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { // make underneath view controller alive to fix layout issue due to view life cycle - return .overFullScreen + return .fullScreen } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..cbd87e335 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -258,5 +258,12 @@ extension UserTimelineViewModel.State { return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index cb01e83eb..64598bc14 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,PagedQueryType { + public struct ListQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? From 2d65bda7fe4dec0a7b31b0a0bb88a3f1a88902d4 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 16:37:05 +0800 Subject: [PATCH 8/8] chore: migrate HashtagViewModel to use `StatusFetchedResultsController` --- Mastodon.xcodeproj/project.pbxproj | 4 - .../StatusFetchedResultsController.swift | 11 +-- .../StatusWithGapFetchResultController.swift | 85 ------------------- .../HashtagTimelineViewController.swift | 2 + .../HashtagTimelineViewModel+Diffable.swift | 23 ++--- ...tagTimelineViewModel+LoadLatestState.swift | 12 +-- ...tagTimelineViewModel+LoadMiddleState.swift | 16 ++-- ...tagTimelineViewModel+LoadOldestState.swift | 12 +-- .../HashtagTimelineViewModel.swift | 38 ++------- 9 files changed, 42 insertions(+), 161 deletions(-) delete mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9af5bd234..824b60ad1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.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 */; }; @@ -357,7 +356,6 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.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 = ""; }; @@ -1661,7 +1659,6 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2140,7 +2137,6 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index a61429ab8..dd373b29f 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -25,7 +25,7 @@ final class StatusFetchedResultsController: NSObject { // output let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" self.fetchedResultsController = { let fetchRequest = Status.sortedFetchRequest @@ -52,10 +52,11 @@ final class StatusFetchedResultsController: NSObject { .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) + var predicates = [Status.predicate(domain: domain ?? "", ids: ids)] + if let additionalPredicate = additionalTweetPredicate { + predicates.append(additionalPredicate) + } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift deleted file mode 100644 index f392c893d..000000000 --- a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StatusWithGapFetchResultController.swift -// Mastodon -// -// Created by BradGao on 2021/4/7. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -class StatusWithGapFetchResultController: NSObject { - var disposeBag = Set() - - let fetchedResultsController: NSFetchedResultsController - - // input - let domain = CurrentValueSubject(nil) - let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) - - var needLoadMiddleIndex: Int? = nil - - // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { - self.domain.value = domain ?? "" - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - fetchedResultsController.delegate = self - - Publishers.CombineLatest( - self.domain.removeDuplicates().eraseToAnyPublisher(), - self.statusIDs.removeDuplicates().eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] domain, ids in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } -} - -// MARK: - NSFetchedResultsControllerDelegate -extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let indexes = statusIDs.value - let objects = fetchedResultsController.fetchedObjects ?? [] - - let items: [NSManagedObjectID] = objects - .compactMap { object in - indexes.firstIndex(of: object.id).map { index in (index, object) } - } - .sorted { $0.0 < $1.0 } - .map { $0.1.objectID } - self.objectIDs.value = items - } -} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 1dbb0323c..9d638e6c6 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -107,6 +107,8 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) + + } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a0bf5d82d..26f32a33c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -33,14 +33,9 @@ extension HashtagTimelineViewModel { } } -// MARK: - NSFetchedResultsControllerDelegate -extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { - - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { +// MARK: - Compare old & new snapshots and generate new items +extension HashtagTimelineViewModel { + func generateStatusItems(newObjectIDs: [NSManagedObjectID]) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let tableView = self.tableView else { return } @@ -48,12 +43,12 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { guard let diffableDataSource = self.diffableDataSource else { return } - let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext let oldSnapshot = diffableDataSource.snapshot() - let snapshot = snapshot as NSDiffableDataSourceSnapshot +// let snapshot = snapshot as NSDiffableDataSourceSnapshot var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { @@ -61,9 +56,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { oldSnapshotAttributeDict[objectID] = attribute } - let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Status - + let statusItemList: [Item] = newObjectIDs.map { let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() return Item.status(objectID: $0, attribute: attribute) } @@ -75,7 +68,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { // If yes, insert a `middleLoader` at the index var newItems = statusItemList - newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) newSnapshot.appendItems(newItems, toSection: .main) } else { newSnapshot.appendItems(statusItemList, toSection: .main) @@ -112,6 +105,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil } let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! @@ -127,5 +121,4 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { } return nil } - } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index d8e286195..e772e8ea0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -73,18 +73,18 @@ extension HashtagTimelineViewModel.LoadLatestState { // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load - if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last, + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0) + let newIDs = oldStatusIDs.removingDuplicates() - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = newIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index e971659e1..9bf87554b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -54,11 +54,11 @@ extension HashtagTimelineViewModel.LoadMiddleState { return } - guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { stateMachine.enter(Fail.self) return } - let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in status.id } @@ -86,27 +86,27 @@ extension HashtagTimelineViewModel.LoadMiddleState { let newStatusIDList = response.value.map { $0.id } - if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) { // When response data: // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load if let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + oldStatusIDs.removeDuplicates() } else { // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index // Then there is no need to set a `loadMiddleState` cell viewModel.needLoadMiddleIndex = nil } - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index d464d3a50..23ec99152 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -29,7 +29,7 @@ extension HashtagTimelineViewModel.LoadOldestState { class Initial: HashtagTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } return stateClass == Loading.self } } @@ -48,7 +48,7 @@ extension HashtagTimelineViewModel.LoadOldestState { return } - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else { stateMachine.enter(Idle.self) return } @@ -79,10 +79,10 @@ extension HashtagTimelineViewModel.LoadOldestState { } else { stateMachine.enter(Idle.self) } - let newStatusIDList = statuses.map { $0.id } - viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value + let fetchedStatusIDList = statuses.map { $0.id } + newStatusIDs.append(contentsOf: fetchedStatusIDList) + viewModel.fetchedResultsController.statusIDs.value = newStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index e7f167f2a..a6b1b0594 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -19,12 +19,11 @@ final class HashtagTimelineViewModel: NSObject { var disposeBag = Set() - var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() var needLoadMiddleIndex: Int? = nil // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: StatusFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -69,39 +68,14 @@ final class HashtagTimelineViewModel: NSObject { init(context: AppContext, hashTag: String) { self.context = context self.hashTag = hashTag - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() + let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value + self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) super.init() - fetchedResultsController.delegate = self - - timelinePredicate + fetchedResultsController.objectIDs .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - self.diffableDataSource?.defaultRowAnimation = .fade - try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } - } catch { - assertionFailure(error.localizedDescription) - } + .sink { [weak self] objectIds in + self?.generateStatusItems(newObjectIDs: objectIds) } .store(in: &disposeBag) }