diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index 25ef592f..211f7c63 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -74,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? { switch UserActivityType(rawValue: activity.activityType) { case .showTimeline: - guard let timeline = UserActivityManager.getTimeline(from: activity) else { return nil } + guard let (timeline, _) = UserActivityManager.getTimeline(from: activity) else { return nil } return timelineViewController(for: timeline, mastodonController: mastodonController) case .showConversation: diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 62441910..395e107c 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -70,7 +70,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account") return } else { - context = ActiveAccountUserActivityHandlingContext(root: rootViewController!) + context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!) } _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context)) } @@ -184,7 +184,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate if activity.isStateRestorationActivity { doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!)) } else if activity.activityType != UserActivityType.mainScene.rawValue { - doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(root: rootViewController!)) + doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!)) } else { stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)") } diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index ea5fd0bf..2bd629c7 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -43,6 +43,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro private(set) var dataSource: UICollectionViewDiffableDataSource! private var cancellables = Set() + private var userActivityNeedsUpdate = PassthroughSubject() private var contentOffsetObservation: NSKeyValueObservation? // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing private var disappearedAt: Date? @@ -171,6 +172,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } .store(in: &cancellables) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + + if userActivity != nil { + userActivityNeedsUpdate + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [unowned self] _ in + if let info = self.timelinePositionInfo(), + let userActivity = self.userActivity { + UserActivityManager.addTimelinePositionInfo(to: userActivity, statusIDs: info.statusIDs, centerStatusID: info.centerStatusID) + } + } + .store(in: &cancellables) + } } // separate method because InstanceTimelineViewController needs to be able to customize it @@ -294,15 +307,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return centerVisible } - private func saveState() { - guard isViewLoaded, - persistsState, - let accountInfo = mastodonController.accountInfo else { - return + private func timelinePositionInfo() -> (statusIDs: [String], centerStatusID: String)? { + guard isViewLoaded else { + return nil } let snapshot = dataSource.snapshot() guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else { - return + return nil } let allItems = snapshot.itemIdentifiers(inSection: .statuses) @@ -342,6 +353,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } else { fatalError() } + return (ids, centerVisibleID) + } + + private func saveState() { + guard persistsState, + let accountInfo = mastodonController.accountInfo, + let (ids, centerVisibleID) = timelinePositionInfo() else { + return + } switch Preferences.shared.timelineSyncMode { case .icloud: stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") @@ -409,6 +429,22 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return loaded } + func restoreStateFromHandoff(statusIDs: [String], centerStatusID: String) async { + let crumb = Breadcrumb(level: .debug, category: "TimelineViewController") + crumb.message = "Restoring state from handoff activity" + SentrySDK.addBreadcrumb(crumb) + await controller.restoreInitial { @MainActor in + let position = TimelinePosition(context: mastodonController.persistentContainer.viewContext) + position.statusIDs = statusIDs + position.centerStatusID = centerStatusID + let hasStatusesToRestore = await loadStatusesToRestore(position: position) + if hasStatusesToRestore { + applyItemsToRestore(position: position) + } + mastodonController.persistentContainer.viewContext.delete(position) + } + } + @MainActor private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { let originalPositionStatusIDs = position.statusIDs @@ -1293,6 +1329,16 @@ extension TimelineViewController: UICollectionViewDelegate { removeTimelineDescriptionCell() } } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + userActivityNeedsUpdate.send() + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + userActivityNeedsUpdate.send() + } } extension TimelineViewController: UICollectionViewDragDelegate { diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift index 70022a29..9931d962 100644 --- a/Tusker/Shortcuts/UserActivityHandlingContext.swift +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -11,6 +11,8 @@ import Duckable @MainActor protocol UserActivityHandlingContext { + var isHandoff: Bool { get } + func select(route: TuskerRoute) func present(_ vc: UIViewController) @@ -24,6 +26,7 @@ protocol UserActivityHandlingContext { } struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext { + let isHandoff: Bool let root: TuskerRootViewController var navigationDelegate: TuskerNavigationDelegate { root.getNavigationDelegate()! @@ -63,6 +66,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext { self.root = root } + var isHandoff: Bool { false } + func select(route: TuskerRoute) { root.select(route: route, animated: false) state = .selectedRoute diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index 6f4ed91e..6240e837 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -189,30 +189,53 @@ class UserActivityManager { return activity } - static func getTimeline(from activity: NSUserActivity) -> Timeline? { + static func addTimelinePositionInfo(to activity: NSUserActivity, statusIDs: [String], centerStatusID: String) { + activity.addUserInfoEntries(from: [ + "statusIDs": statusIDs, + "centerStatusID": centerStatusID + ]) + } + + static func getTimeline(from activity: NSUserActivity) -> (Timeline, (statusIDs: [String], centerStatusID: String)?)? { guard activity.activityType == UserActivityType.showTimeline.rawValue, - let data = activity.userInfo?["timelineData"] as? Data else { + let data = activity.userInfo?["timelineData"] as? Data, + let timeline = try? UserActivityManager.decoder.decode(Timeline.self, from: data) else { return nil } - return try? UserActivityManager.decoder.decode(Timeline.self, from: data) + var positionInfo: ([String], String)? + if let ids = activity.userInfo?["statusIDs"] as? [String], + let center = activity.userInfo?["centerStatusID"] as? String { + positionInfo = (ids, center) + } + return (timeline, positionInfo) } func handleShowTimeline(activity: NSUserActivity) { - guard let timeline = Self.getTimeline(from: activity) else { return } + guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return } + let timelineVC: TimelineViewController if let pinned = PinnedTimeline(timeline: timeline), mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { context.select(route: .timelines) context.popToRoot() - let rootController = context.topViewController as! TimelinesPageViewController - rootController.selectTimeline(pinned, animated: false) + let pageController = context.topViewController as! TimelinesPageViewController + pageController.selectTimeline(pinned, animated: false) + timelineVC = pageController.currentViewController as! TimelineViewController } else if case .list(let id) = timeline { context.select(route: .list(id: id)) + timelineVC = context.topViewController! as! TimelineViewController } else { context.select(route: .explore) context.popToRoot() - let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) - context.push(timeline) + timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController) + context.push(timelineVC) + } + + if let positionInfo, + context.isHandoff { + Task { + await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID) + } } }