diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c2798aec..478c4329 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; - 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */; }; 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; @@ -715,7 +715,7 @@ 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 = ""; }; 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; - 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+State.swift"; sourceTree = ""; }; 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.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 = ""; }; @@ -1489,7 +1489,7 @@ DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, - 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */, ); path = HashtagTimeline; sourceTree = ""; @@ -4109,7 +4109,7 @@ DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, - 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */, DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 4dab4933..aa35ed3c 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -114,7 +114,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 22 + 20 MastodonIntents.xcscheme_^#shared#^_ @@ -129,12 +129,12 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 20 + 21 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 21 + 22 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 31de401d..02738747 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -46,6 +46,8 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me return tableView }() + let refreshControl = UIRefreshControl() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } @@ -89,13 +91,23 @@ extension HashtagTimelineViewController { statusTableViewCellDelegate: self ) + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + .store(in: &disposeBag) + // setup batch fetch viewModel.listBatchFetchViewModel.setup(scrollView: tableView) viewModel.listBatchFetchViewModel.shouldFetch .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - self.viewModel.loadOldestStateMachine.enter(HashtagTimelineViewModel.LoadOldestState.Loading.self) + self.viewModel.stateMachine.enter(HashtagTimelineViewModel.State.Loading.self) } .store(in: &disposeBag) @@ -144,6 +156,13 @@ extension HashtagTimelineViewController { extension HashtagTimelineViewController { + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.stateMachine.enter(HashtagTimelineViewModel.State.Reloading.self) else { + sender.endRefreshing() + return + } + } + @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index c71d195c..fc80f484 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -27,6 +27,8 @@ extension HashtagTimelineViewModel { ) ) + stateMachine.enter(State.Reloading.self) + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) @@ -43,14 +45,17 @@ extension HashtagTimelineViewModel { let items = records.map { StatusItem.status(record: $0) } snapshot.appendItems(items, toSection: .main) - if let currentState = self.loadOldestStateMachine.currentState { + if let currentState = self.stateMachine.currentState { switch currentState { - case is LoadOldestState.Initial, - is LoadOldestState.Loading, - is LoadOldestState.Idle, - is LoadOldestState.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - case is LoadOldestState.NoMore: + case is State.Initial, + is State.Reloading, + is State.Loading, + is State.Idle, + is State.Fail: + if !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .main) + } + case is State.NoMore: break default: assertionFailure() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift similarity index 51% rename from Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift rename to Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift index eba85657..6b75d387 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+State.swift @@ -1,5 +1,5 @@ // -// HashtagTimelineViewModel+LoadOldestState.swift +// HashtagTimelineViewModel+State.swift // Mastodon // // Created by BradGao on 2021/3/31. @@ -11,7 +11,7 @@ import GameplayKit import CoreDataStack extension HashtagTimelineViewModel { - class LoadOldestState: GKState, NamingState { + class State: GKState, NamingState { let logger = Logger(subsystem: "HashtagTimelineViewModel.LoadOldestState", category: "StateMachine") @@ -28,14 +28,14 @@ extension HashtagTimelineViewModel { } override func didEnter(from previousState: GKState?) { - let previousState = previousState as? HashtagTimelineViewModel.LoadOldestState + let previousState = previousState as? HashtagTimelineViewModel.State logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "")") viewModel?.loadOldestStateMachinePublisher.send(self) } @MainActor - func enter(state: LoadOldestState.Type) { + func enter(state: State.Type) { stateMachine?.enter(state) } @@ -45,24 +45,96 @@ extension HashtagTimelineViewModel { } } -extension HashtagTimelineViewModel.LoadOldestState { - class Initial: HashtagTimelineViewModel.LoadOldestState { +extension HashtagTimelineViewModel.State { + class Initial: HashtagTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } } } - class Loading: HashtagTimelineViewModel.LoadOldestState { - var maxID: Status.ID? - + class Reloading: HashtagTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + switch stateClass { + case is Loading.Type: + return true + default: + return false + } } override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + stateMachine.enter(Loading.self) + } + } + + class Fail: HashtagTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: HashtagTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: HashtagTimelineViewModel.State { + var maxID: Status.ID? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + switch previousState { + case is Reloading: + maxID = nil + default: + break + } + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() stateMachine.enter(Fail.self) @@ -71,6 +143,8 @@ extension HashtagTimelineViewModel.LoadOldestState { // TODO: only set large count when using Wi-Fi let maxID = self.maxID + let isReloading = maxID == nil + Task { do { let response = try await viewModel.context.apiService.hashtagTimeline( @@ -79,24 +153,35 @@ extension HashtagTimelineViewModel.LoadOldestState { hashtag: viewModel.hashtag, authenticationBox: authenticationBox ) + + let newMaxID: String? = { + guard let maxID = response.link?.maxID else { return nil } + return maxID + }() - var hasMore = false + let hasMore: Bool = { + guard let newMaxID = newMaxID else { return false } + return newMaxID != maxID + }() - if let _maxID = response.link?.maxID, - _maxID != maxID - { - self.maxID = _maxID - hasMore = true + self.maxID = newMaxID + + var hasNewStatusesAppend = false + var statusIDs = isReloading ? [] : viewModel.fetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true } - if hasMore { + + if hasNewStatusesAppend, hasMore { await enter(state: Idle.self) } else { await enter(state: NoMore.self) } - let statusIDs = response.value.map { $0.id } viewModel.fetchedResultsController.append(statusIDs: statusIDs) - + viewModel.didLoadLatest.send() } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") await enter(state: Fail.self) @@ -104,22 +189,15 @@ extension HashtagTimelineViewModel.LoadOldestState { } // end Task } } - - 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 { + class NoMore: HashtagTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } } override func didEnter(from previousState: GKState?) { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index d63fad80..f2298727 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -36,19 +36,20 @@ final class HashtagTimelineViewModel { let didLoadLatest = PassthroughSubject() // bottom loader - private(set) lazy var loadOldestStateMachine: GKStateMachine = { + private(set) lazy var stateMachine: 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), + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), ]) - stateMachine.enter(LoadOldestState.Initial.self) + stateMachine.enter(State.Initial.self) return stateMachine }() - lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) init(context: AppContext, hashtag: String) { self.context = context