diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 6c390af3b..7ecbe18b3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -394,7 +394,7 @@ extension HomeTimelineViewController { } @objc private func refreshControlValueChanged(_ sender: RefreshControl) { - guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { + guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.LoadingManually.self) else { sender.endRefreshing() return } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index fdb9da01e..932035f06 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -61,54 +61,17 @@ extension HomeTimelineViewModel.LoadLatestState { } override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - guard let viewModel else { return } - - let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount) - let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.parent = parentManagedObjectContext - - Task { - let start = CACurrentMediaTime() - let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in - guard let feed = record.object(in: managedObjectContext) else { return nil } - return feed.status?.id - } - let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) - - do { - let response = try await viewModel.context.apiService.homeTimeline( - authenticationBox: viewModel.authContext.mastodonAuthenticationBox - ) - - await enter(state: Idle.self) - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) - - viewModel.context.instanceService.updateMutesAndBlocks() - - // stop refresher if no new statuses - let statuses = response.value - let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load \(newStatuses.count) new statuses") - - if newStatuses.isEmpty { - viewModel.didLoadLatest.send() - } else { - if !latestStatusIDs.isEmpty { - viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() - } - } - viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty - - } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statuses failed: \(error.localizedDescription)") - await enter(state: Idle.self) - viewModel.didLoadLatest.send() - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) - } - } // end Task + didEnter(from: previousState, viewModel: viewModel, isUserInitiated: false) + } + } + + class LoadingManually: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + didEnter(from: previousState, viewModel: viewModel, isUserInitiated: true) } } @@ -124,4 +87,60 @@ extension HomeTimelineViewModel.LoadLatestState { } } + private func didEnter(from previousState: GKState?, viewModel: HomeTimelineViewModel?, isUserInitiated: Bool) { + super.didEnter(from: previousState) + + guard let viewModel else { return } + + let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount) + let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + Task { + let start = CACurrentMediaTime() + let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in + guard let feed = record.object(in: managedObjectContext) else { return nil } + return feed.status?.id + } + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + + do { + let response = try await viewModel.context.apiService.homeTimeline( + authenticationBox: viewModel.authContext.mastodonAuthenticationBox + ) + + await enter(state: Idle.self) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) + + viewModel.context.instanceService.updateMutesAndBlocks() + + // stop refresher if no new statuses + let statuses = response.value + let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load \(newStatuses.count) new statuses") + + if newStatuses.isEmpty { + viewModel.didLoadLatest.send() + } else { + if !latestStatusIDs.isEmpty { + viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() + } + } + viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty + + if !isUserInitiated { + await UIImpactFeedbackGenerator(style: .light) + .impactOccurred() + } + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statuses failed: \(error.localizedDescription)") + await enter(state: Idle.self) + viewModel.didLoadLatest.send() + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) + } + } // end Task + } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index a59ffe7c5..73ee99174 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -52,6 +52,7 @@ final class HomeTimelineViewModel: NSObject { let stateMachine = GKStateMachine(states: [ LoadLatestState.Initial(viewModel: self), LoadLatestState.Loading(viewModel: self), + LoadLatestState.LoadingManually(viewModel: self), LoadLatestState.Fail(viewModel: self), LoadLatestState.Idle(viewModel: self), ]) diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index bf4d6beb1..e09a0efbd 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -154,7 +154,10 @@ class MainTabBarController: UITabBarController { // output var avatarURLObserver: AnyCancellable? @Published var avatarURL: URL? - + + // haptic feedback + private let selectionFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + init( context: AppContext, coordinator: SceneCoordinator, @@ -378,6 +381,7 @@ extension MainTabBarController { @objc private func composeButtonDidPressed(_ sender: Any) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + selectionFeedbackGenerator.impactOccurred() guard let authContext = self.authContext else { return } let composeViewModel = ComposeViewModel( context: context, @@ -572,6 +576,11 @@ extension MainTabBarController: UITabBarControllerDelegate { composeButtonDidPressed(tabBarController) return false } + + // Different tab has been selected, send haptic feedback + if viewController.tabBarItem.tag != tabBarController.selectedIndex { + selectionFeedbackGenerator.impactOccurred() + } // Assert index is as same as the tab rawValue. This check needs to be done `shouldSelect` // because the nav controller has already popped in `didSelect`.