Transfer timeline position in handoff user activity

Closes #315
This commit is contained in:
Shadowfacts 2023-02-25 15:01:19 -05:00
parent d74be9d81d
commit a3e64703ab
5 changed files with 91 additions and 17 deletions

View File

@ -74,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? { private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
switch UserActivityType(rawValue: activity.activityType) { switch UserActivityType(rawValue: activity.activityType) {
case .showTimeline: 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) return timelineViewController(for: timeline, mastodonController: mastodonController)
case .showConversation: case .showConversation:

View File

@ -70,7 +70,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account") stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account")
return return
} else { } else {
context = ActiveAccountUserActivityHandlingContext(root: rootViewController!) context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
} }
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context)) _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
} }
@ -184,7 +184,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if activity.isStateRestorationActivity { if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!)) doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
} else if activity.activityType != UserActivityType.mainScene.rawValue { } else if activity.activityType != UserActivityType.mainScene.rawValue {
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(root: rootViewController!)) doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!))
} else { } else {
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)") stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
} }

View File

@ -43,6 +43,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
private var contentOffsetObservation: NSKeyValueObservation? 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 // 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? private var disappearedAt: Date?
@ -171,6 +172,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) 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 // separate method because InstanceTimelineViewController needs to be able to customize it
@ -294,15 +307,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return centerVisible return centerVisible
} }
private func saveState() { private func timelinePositionInfo() -> (statusIDs: [String], centerStatusID: String)? {
guard isViewLoaded, guard isViewLoaded else {
persistsState, return nil
let accountInfo = mastodonController.accountInfo else {
return
} }
let snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else { guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else {
return return nil
} }
let allItems = snapshot.itemIdentifiers(inSection: .statuses) let allItems = snapshot.itemIdentifiers(inSection: .statuses)
@ -342,6 +353,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
fatalError() fatalError()
} }
return (ids, centerVisibleID)
}
private func saveState() {
guard persistsState,
let accountInfo = mastodonController.accountInfo,
let (ids, centerVisibleID) = timelinePositionInfo() else {
return
}
switch Preferences.shared.timelineSyncMode { switch Preferences.shared.timelineSyncMode {
case .icloud: case .icloud:
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
@ -409,6 +429,22 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return loaded 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 @MainActor
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
let originalPositionStatusIDs = position.statusIDs let originalPositionStatusIDs = position.statusIDs
@ -1293,6 +1329,16 @@ extension TimelineViewController: UICollectionViewDelegate {
removeTimelineDescriptionCell() removeTimelineDescriptionCell()
} }
} }
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
userActivityNeedsUpdate.send()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userActivityNeedsUpdate.send()
}
} }
extension TimelineViewController: UICollectionViewDragDelegate { extension TimelineViewController: UICollectionViewDragDelegate {

View File

@ -11,6 +11,8 @@ import Duckable
@MainActor @MainActor
protocol UserActivityHandlingContext { protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
func select(route: TuskerRoute) func select(route: TuskerRoute)
func present(_ vc: UIViewController) func present(_ vc: UIViewController)
@ -24,6 +26,7 @@ protocol UserActivityHandlingContext {
} }
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext { struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
let root: TuskerRootViewController let root: TuskerRootViewController
var navigationDelegate: TuskerNavigationDelegate { var navigationDelegate: TuskerNavigationDelegate {
root.getNavigationDelegate()! root.getNavigationDelegate()!
@ -63,6 +66,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
self.root = root self.root = root
} }
var isHandoff: Bool { false }
func select(route: TuskerRoute) { func select(route: TuskerRoute) {
root.select(route: route, animated: false) root.select(route: route, animated: false)
state = .selectedRoute state = .selectedRoute

View File

@ -189,30 +189,53 @@ class UserActivityManager {
return activity 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, 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 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) { 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), if let pinned = PinnedTimeline(timeline: timeline),
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
context.select(route: .timelines) context.select(route: .timelines)
context.popToRoot() context.popToRoot()
let rootController = context.topViewController as! TimelinesPageViewController let pageController = context.topViewController as! TimelinesPageViewController
rootController.selectTimeline(pinned, animated: false) pageController.selectTimeline(pinned, animated: false)
timelineVC = pageController.currentViewController as! TimelineViewController
} else if case .list(let id) = timeline { } else if case .list(let id) = timeline {
context.select(route: .list(id: id)) context.select(route: .list(id: id))
timelineVC = context.topViewController! as! TimelineViewController
} else { } else {
context.select(route: .explore) context.select(route: .explore)
context.popToRoot() context.popToRoot()
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timeline) context.push(timelineVC)
}
if let positionInfo,
context.isHandoff {
Task {
await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID)
}
} }
} }