Timeline state restoration

This commit is contained in:
Shadowfacts 2022-11-23 11:35:25 -05:00
parent 272f35417b
commit c2cb0a0c5a
10 changed files with 265 additions and 41 deletions

View File

@ -96,7 +96,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
if let mastodonController = window?.windowScene?.session.mastodonController { if let mastodonController = window?.windowScene?.session.mastodonController {
if let vcActivity = rootViewController?.stateRestorationActivity() {
vcActivity.isStateRestorationActivity = true
stateRestorationLogger.info("MainSceneDelegate returning stateRestorationActivity of type \(vcActivity.activityType, privacy: .public) from VC")
return vcActivity
} else {
// need to have an activity to make sure the same account is used
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id) return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
}
} else { } else {
return nil return nil
} }
@ -144,7 +151,6 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} }
} }
func showAppOrOnboardingUI(session: UISceneSession? = nil) { func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session let session = session ?? window!.windowScene!.session
if LocalData.shared.onboardingComplete { if LocalData.shared.onboardingComplete {
@ -162,10 +168,13 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
activateAccount(account, animated: false) activateAccount(account, animated: false)
if let activity = launchActivity, if let activity = launchActivity {
activity.activityType != UserActivityType.mainScene.rawValue { if activity.isStateRestorationActivity {
rootViewController?.restoreActivity(activity)
} else if activity.activityType != UserActivityType.mainScene.rawValue {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!)) _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
} }
}
} else { } else {
window!.rootViewController = createOnboardingUI() window!.rootViewController = createOnboardingUI()
} }

View File

@ -87,6 +87,16 @@ extension AccountSwitchingContainerViewController {
} }
extension AccountSwitchingContainerViewController: TuskerRootViewController { extension AccountSwitchingContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
loadViewIfNeeded()
return root.stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
loadViewIfNeeded()
root.restoreActivity(activity)
}
func presentCompose() { func presentCompose() {
loadViewIfNeeded() loadViewIfNeeded()
root.presentCompose() root.presentCompose()

View File

@ -11,6 +11,14 @@ import Duckable
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController { extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
(child as? TuskerRootViewController)?.stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
(child as? TuskerRootViewController)?.restoreActivity(activity)
}
func presentCompose() { func presentCompose() {
(child as? TuskerRootViewController)?.presentCompose() (child as? TuskerRootViewController)?.presentCompose()
} }

View File

@ -83,6 +83,14 @@ class MainSplitViewController: UISplitViewController {
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item) secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
} }
func navigationStackFor(item: MainSidebarViewController.Item) -> [UIViewController]? {
if sidebar.selectedItem == item {
return secondaryNavController.viewControllers
} else {
return navigationStacks[item]
}
}
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] { func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
if let existing = navigationStacks[item], existing.count > 0 { if let existing = navigationStacks[item], existing.count > 0 {
return existing return existing
@ -378,6 +386,36 @@ extension MainSplitViewController: TuskerNavigationDelegate {
} }
extension MainSplitViewController: TuskerRootViewController { extension MainSplitViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.stateRestorationActivity()
} else {
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController
return timeline.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
return nil
}
}
}
func restoreActivity(_ activity: NSUserActivity) {
if traitCollection.horizontalSizeClass == .compact {
tabBarViewController.restoreActivity(activity)
} else {
if activity.activityType == UserActivityType.showTimeline.rawValue {
guard let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController else {
stateRestorationLogger.fault("MainSplitViewController: Unable to restore timeline activity, couldn't find VC")
return
}
timelinePages.restoreActivity(activity)
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
}
}
}
@objc func presentCompose() { @objc func presentCompose() {
self.compose() self.compose()
} }

View File

@ -233,6 +233,30 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
} }
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC")
return nil
}
return timelineVC.stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
return
}
timelineVC.restoreActivity(activity)
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
}
}
@objc func presentCompose() { @objc func presentCompose() {
compose() compose()
} }

View File

@ -9,6 +9,8 @@
import UIKit import UIKit
protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController { protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController {
func stateRestorationActivity() -> NSUserActivity?
func restoreActivity(_ activity: NSUserActivity)
func presentCompose() func presentCompose()
func select(tab: MainTabBarViewController.Tab) func select(tab: MainTabBarViewController.Tab)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?

View File

@ -23,6 +23,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var contentOffsetObservation: NSKeyValueObservation? private var contentOffsetObservation: NSKeyValueObservation?
private var activityToRestore: NSUserActivity?
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
@ -156,12 +157,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
collectionView.deselectItem(at: $0, animated: true) collectionView.deselectItem(at: $0, animated: true)
} }
Task {
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
if doRestore() {
Task {
await checkPresent()
}
} else {
Task {
await controller.loadInitial() await controller.loadInitial()
} }
} }
} }
}
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
@ -176,6 +183,108 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
func stateRestorationActivity() -> NSUserActivity? {
let visible = collectionView.indexPathsForVisibleItems.sorted()
let snapshot = dataSource.snapshot()
guard let currentAccountID = mastodonController.accountInfo?.id,
!visible.isEmpty,
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
let firstVisible = visible.first(where: { $0.section == statusesSection }),
let lastVisible = visible.last(where: { $0.section == statusesSection }) else {
return nil
}
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
let startIndex = max(0, firstVisible.row - 20)
let endIndex = min(allItems.count - 1, lastVisible.row + 20)
let firstVisibleItem: Item
var items = allItems[startIndex...endIndex]
if let gapIndex = items.firstIndex(of: .gap) {
// if the gap is above the top visible item, we take everything below the gap
// otherwise, we take everything above the gap
if gapIndex <= firstVisible.row {
items = allItems[(gapIndex + 1)...endIndex]
if gapIndex == firstVisible.row {
firstVisibleItem = allItems.first!
} else {
assert(items.indices.contains(firstVisible.row))
firstVisibleItem = allItems[firstVisible.row]
}
} else {
items = allItems[startIndex..<gapIndex]
firstVisibleItem = allItems[firstVisible.row]
}
} else {
firstVisibleItem = allItems[firstVisible.row]
}
let ids = items.map {
if case .status(id: let id, state: _) = $0 {
return id
} else {
fatalError()
}
}
let firstVisibleID: String
if case .status(id: let id, state: _) = firstVisibleItem {
firstVisibleID = id
} else {
fatalError()
}
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(firstVisibleID)")
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
activity.addUserInfoEntries(from: [
"statusIDs": ids,
"topID": firstVisibleID,
])
activity.isEligibleForPrediction = false
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
self.activityToRestore = activity
}
private func doRestore() -> Bool {
guard let activity = activityToRestore,
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}
activityToRestore = nil
loadViewIfNeeded()
controller.restoreInitial {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses])
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let topID = activity.userInfo?["topID"] as? String,
let index = statusIDs.firstIndex(of: topID),
let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
var count = 0
while count < 5 {
count += 1
let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
let newOffset = self.collectionView.contentOffset
if abs(origOffset.y - newOffset.y) <= 1 {
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with top ID \(topID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find top ID")
}
}
}
return true
}
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header]) snapshot.deleteSections([.header])
@ -190,10 +299,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return return
} }
Task { Task {
if case .idle = controller.state, await checkPresent()
let presentItems = try? await loadInitial() {
insertPresentItemsIfNecessary(presentItems)
}
} }
} }
@ -214,43 +320,27 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
private func checkPresent() async {
if case .idle = controller.state,
let presentItems = try? await loadInitial() {
insertPresentItemsIfNecessary(presentItems)
}
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) { private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
let currentItems = snapshot.itemIdentifiers(inSection: .statuses) let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first, if case .status(id: let firstID, state: _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user // if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
!presentItems.contains(firstID) { !presentItems.contains(firstID) {
let applySnapshotBeforeScrolling: Bool
// remove any existing gap, if there is one // remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) { if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...])) snapshot.deleteItems(Array(currentItems[index...]))
let statusesSection = snapshot.indexOfSection(.statuses)!
if collectionView.indexPathsForVisibleItems.contains(IndexPath(row: index, section: statusesSection)) {
// the gap cell is on screen
applySnapshotBeforeScrolling = false
} else if let topMostVisibleCell = collectionView.indexPathsForVisibleItems.first(where: { $0.section == statusesSection }),
index < topMostVisibleCell.row {
// the gap cell is above the top, so applying the snapshot would remove the currently-viewed statuses
applySnapshotBeforeScrolling = false
} else {
// the gap cell is below the bottom of the screen
applySnapshotBeforeScrolling = true
}
} else {
// there is no existing gap
applySnapshotBeforeScrolling = true
} }
snapshot.insertItems([.gap], beforeItem: currentItems.first!) snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap) snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
if applySnapshotBeforeScrolling {
let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.min()!
let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath)!
applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstVisibleItem)
}
var config = ToastConfiguration(title: "Jump to present") var config = ToastConfiguration(title: "Jump to present")
config.edge = .top config.edge = .top
config.systemImageName = "arrow.up" config.systemImageName = "arrow.up"
@ -258,9 +348,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
if !applySnapshotBeforeScrolling {
self.dataSource.apply(snapshot, animatingDifferences: false) self.dataSource.apply(snapshot, animatingDifferences: false)
}
self.collectionView.scrollToTop() self.collectionView.scrollToTop()
} }

View File

@ -46,4 +46,22 @@ class TimelinesPageViewController: SegmentedPageViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return
}
switch timeline {
case .home:
selectPage(at: 0, animated: false)
case .public(local: false):
selectPage(at: 1, animated: false)
case .public(local: true):
selectPage(at: 2, animated: false)
default:
return
}
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
timelineVC.restoreActivity(activity)
}
} }

View File

@ -22,6 +22,18 @@ extension NSUserActivity {
} }
} }
var isStateRestorationActivity: Bool {
get {
(userInfo?["isStateRestorationActivity"] as? Bool) ?? false
}
set {
if userInfo == nil {
userInfo = [:]
}
userInfo!["isStateRestorationActivity"] = newValue
}
}
convenience init(type: UserActivityType) { convenience init(type: UserActivityType) {
self.init(activityType: type.rawValue) self.init(activityType: type.rawValue)
} }

View File

@ -79,6 +79,16 @@ class TimelineLikeController<Item> {
} }
} }
/// Used to indicate to the controller that the initial set of posts have been restored externally.
func restoreInitial(doRestore: () -> Void) {
guard state == .notLoadedInitial else {
return
}
state = .restoringInitial
doRestore()
state = .idle
}
func loadNewer() async { func loadNewer() async {
guard state == .idle else { guard state == .idle else {
return return
@ -188,6 +198,7 @@ class TimelineLikeController<Item> {
enum State: Equatable, CustomDebugStringConvertible { enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial case notLoadedInitial
case idle case idle
case restoringInitial
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken) case loadingNewer(LoadAttemptToken)
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
@ -199,6 +210,8 @@ class TimelineLikeController<Item> {
return "notLoadedInitial" return "notLoadedInitial"
case .idle: case .idle:
return "idle" return "idle"
case .restoringInitial:
return "restoringInitial"
case .loadingInitial(let token, let hasAddedLoadingIndicator): case .loadingInitial(let token, let hasAddedLoadingIndicator):
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))" return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingNewer(let token): case .loadingNewer(let token):
@ -214,7 +227,7 @@ class TimelineLikeController<Item> {
switch self { switch self {
case .notLoadedInitial: case .notLoadedInitial:
switch to { switch to {
case .loadingInitial(_, _): case .restoringInitial, .loadingInitial(_, _):
return true return true
default: default:
return false return false
@ -226,6 +239,8 @@ class TimelineLikeController<Item> {
default: default:
return false return false
} }
case .restoringInitial:
return to == .idle
case .loadingInitial(let token, let hasAddedLoadingIndicator): case .loadingInitial(let token, let hasAddedLoadingIndicator):
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true)) return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
case .loadingNewer(_): case .loadingNewer(_):