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? {
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:

View File

@ -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)")
}

View File

@ -43,6 +43,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
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 {

View File

@ -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

View File

@ -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)
}
}
}