diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 76c4ab65..0ef105e3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -219,6 +219,7 @@ D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; + D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; }; D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; }; D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; }; @@ -602,6 +603,7 @@ D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = ""; }; D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = ""; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = ""; }; + D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = ""; }; D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = ""; }; D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = ""; }; D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupTableViewCell.swift; sourceTree = ""; }; @@ -916,6 +918,7 @@ D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D6D706A62948D4D0000827ED /* TimlineState.swift */, + D6A3A3812956123A0036B6EF /* TimelinePosition.swift */, D68A76E229524D2A001DA1B3 /* ListMO.swift */, D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, @@ -1984,6 +1987,7 @@ D61F75B7293C119700C0B37F /* Filterer.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */, + D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */, D61F75BD293D099600C0B37F /* Lazy.swift in Sources */, diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 63598f05..25f62436 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -18,6 +18,8 @@ fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, catego class MastodonCachePersistentStore: NSPersistentCloudKitContainer { + private let accountInfo: LocalData.UserAccountInfo? + private static let managedObjectModel: NSManagedObjectModel = { let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")! return NSManagedObjectModel(contentsOf: url)! @@ -50,6 +52,8 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { let relationshipSubject = PassthroughSubject() init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { + self.accountInfo = accountInfo + let group = DispatchGroup() var instancesToMigrate: [URL]? = nil var hashtagsToMigrate: [Hashtag]? = nil @@ -153,12 +157,17 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { } if description.configuration == "Cloud" { - self.backgroundContext.perform { + let context = self.backgroundContext + context.perform { instancesToMigrate?.forEach({ url in - _ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext) + if !context.objectExists(for: SavedInstance.fetchRequest(url: url, account: accountInfo!)) { + _ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext) + } }) hashtagsToMigrate?.forEach({ hashtag in - _ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext) + if !context.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name, account: accountInfo!)) { + _ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext) + } }) self.save(context: self.backgroundContext) } @@ -435,9 +444,12 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { } } - func getTimelineState(timeline: Timeline) -> TimelineState? { + func getTimelinePosition(timeline: Timeline) -> TimelinePosition? { + guard let accountInfo else { + return nil + } do { - let req = TimelineState.fetchRequest(timeline: timeline) + let req = TimelinePosition.fetchRequest(timeline: timeline, account: accountInfo) return try viewContext.fetch(req).first } catch { return nil @@ -483,7 +495,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { return changes } - // the remote change notifications only handle deletes, inserts get handled by the regular managed object did change notifications @objc private func remoteChanges(_ notification: Foundation.Notification) { guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { return @@ -497,27 +508,31 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult, let transactions = result.result as? [NSPersistentHistoryTransaction] { var changes: (hashtags: Bool, instances: Bool) = (false, false) + var changedTimelinePositions: [NSManagedObjectID] = [] outer: for transaction in transactions { for change in transaction.changes ?? [] { if change.changedObjectID.entity.name == "SavedHashtag" { changes.hashtags = true } else if change.changedObjectID.entity.name == "SavedInstance" { changes.instances = true - } - if changes.hashtags && changes.instances { - break outer + } else if change.changedObjectID.entity.name == "TimelinePosition" { + changedTimelinePositions.append(change.changedObjectID) } } } - if changes.hashtags { - DispatchQueue.main.async { + DispatchQueue.main.async { + if changes.hashtags { NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) } - } - if changes.instances { - DispatchQueue.main.async { + if changes.instances { NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) } + for id in changedTimelinePositions { + guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else { + continue + } + NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition) + } } } } @@ -525,3 +540,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { } } + +extension Foundation.Notification.Name { + static let timelinePositionChanged = Notification.Name("timelinePositionChanged") +} diff --git a/Tusker/CoreData/TimelinePosition.swift b/Tusker/CoreData/TimelinePosition.swift new file mode 100644 index 00000000..a4756dd6 --- /dev/null +++ b/Tusker/CoreData/TimelinePosition.swift @@ -0,0 +1,83 @@ +// +// TimelinePosition.swift +// Tusker +// +// Created by Shadowfacts on 12/23/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm + +@objc(TimelinePosition) +public final class TimelinePosition: NSManagedObject { + + @nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "TimelinePosition") + req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline)) + return req + } + + @NSManaged public var accountID: String + @NSManaged private var timelineKind: String + @NSManaged public var centerStatusID: String? + @NSManaged private var statusIDsData: Data? + + @LazilyDecoding(arrayFrom: \TimelinePosition.statusIDsData) + var statusIDs: [String] + + var timeline: Timeline { + get { fromTimelineKind(timelineKind) } + set { timelineKind = toTimelineKind(newValue) } + } + + convenience init(timeline: Timeline, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { + self.init(context: context) + self.timeline = timeline + self.accountID = account.id + } + +} + +// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate +func toTimelineKind(_ timeline: Timeline) -> String { + switch timeline { + case .home: + return "home" + case .public(local: true): + return "local" + case .public(local: false): + return "federated" + case .direct: + return "direct" + case .tag(hashtag: let name): + return "hashtag:\(name)" + case .list(id: let id): + return "list:\(id)" + } +} + +func fromTimelineKind(_ kind: String) -> Timeline { + if kind == "home" { + return .home + } else if kind == "local" { + return .public(local: true) + } else if kind == "federated" { + return .public(local: false) + } else if kind == "direct" { + return .direct + } else if kind.starts(with: "hashtag:") { + return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind))) + } else if kind.starts(with: "list:") { + return .list(id: String(trimmingPrefix("list:", of: kind))) + } else { + fatalError("invalid timeline kind \(kind)") + } +} + +// replace with Collection.trimmingPrefix +@available(iOS, obsoleted: 16.0) +private func trimmingPrefix(_ prefix: String, of str: String) -> Substring { + return str[str.index(str.startIndex, offsetBy: prefix.count)...] +} diff --git a/Tusker/CoreData/TimlineState.swift b/Tusker/CoreData/TimlineState.swift index 13f09244..d8050546 100644 --- a/Tusker/CoreData/TimlineState.swift +++ b/Tusker/CoreData/TimlineState.swift @@ -13,10 +13,8 @@ import Pachyderm @objc(TimelineState) public final class TimelineState: NSManagedObject { - @nonobjc public class func fetchRequest(timeline: Timeline) -> NSFetchRequest { - let req = NSFetchRequest(entityName: "TimelineState") - req.predicate = NSPredicate(format: "timelineKind = %@", toTimelineKind(timeline)) - return req + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TimelineState") } @NSManaged private var timelineKind: String @@ -25,65 +23,10 @@ public final class TimelineState: NSManagedObject { var timeline: Timeline { get { fromTimelineKind(timelineKind) } - set { timelineKind = toTimelineKind(newValue) } } var statusMOs: [StatusMO] { statuses.array as! [StatusMO] } - convenience init(timeline: Timeline, context: NSManagedObjectContext) { - self.init(context: context) - self.timeline = timeline - } - - func setStatuses(_ statusIDs: [String]) { - let context = managedObjectContext! - // todo: this feels really inefficient, but I'm not sure if it's better or worse than doing a single "id IN %@" fetch and sorting after - let mos = statusIDs.compactMap { try? context.fetch(StatusMO.fetchRequest(id: $0)).first } - self.statuses = NSOrderedSet(array: mos) - } - -} - -// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate -private func toTimelineKind(_ timeline: Timeline) -> String { - switch timeline { - case .home: - return "home" - case .public(local: true): - return "local" - case .public(local: false): - return "federated" - case .direct: - return "direct" - case .tag(hashtag: let name): - return "hashtag:\(name)" - case .list(id: let id): - return "list:\(id)" - } -} - -private func fromTimelineKind(_ kind: String) -> Timeline { - if kind == "home" { - return .home - } else if kind == "local" { - return .public(local: true) - } else if kind == "federated" { - return .public(local: false) - } else if kind == "direct" { - return .direct - } else if kind.starts(with: "hashtag:") { - return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind))) - } else if kind.starts(with: "list:") { - return .list(id: String(trimmingPrefix("list:", of: kind))) - } else { - fatalError("invalid timeline kind \(kind)") - } -} - -// replace with Collection.trimmingPrefix -@available(iOS, obsoleted: 16.0) -private func trimmingPrefix(_ prefix: String, of str: String) -> Substring { - return str[str.index(str.startIndex, offsetBy: prefix.count)...] } diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 4282f7c3..a3e87bf8 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -117,6 +117,12 @@ + + + + + + @@ -126,6 +132,7 @@ + diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 4de2434a..8d0a38a0 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -131,7 +131,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate minDate.addTimeInterval(-7 * 24 * 60 * 60) let statusReq: NSFetchRequest = StatusMO.fetchRequest() - statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (timelines.@count = 0)", minDate as NSDate) + statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) deleteStatusReq.resultType = .resultTypeCount if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 46b7d157..cd3a84f9 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -25,6 +25,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro private(set) var collectionView: UICollectionView! private(set) var dataSource: UICollectionViewDiffableDataSource! + private var cancellables = Set() private var contentOffsetObservation: NSKeyValueObservation? private var activityToRestore: NSUserActivity? // 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 @@ -121,6 +122,21 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.publisher(for: .timelinePositionChanged) + .filter { [unowned self] in + if let timelinePosition = $0.object as? TimelinePosition, + timelinePosition.accountID == self.mastodonController.accountInfo?.id, + timelinePosition.timeline == self.timeline { + return true + } else { + return false + } + } + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [unowned self] _ in + _ = promptToSyncPositionIfNecessary() + } + .store(in: &cancellables) } // separate method because InstanceTimelineViewController needs to be able to customize it @@ -209,7 +225,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } } else { - checkPresentIfEnoughTimeElapsed() + if promptToSyncPositionIfNecessary() { + // no-op + } else { + checkPresentIfEnoughTimeElapsed() + } } } @@ -233,19 +253,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro saveState() } - private func saveState() { - guard isViewLoaded, - persistsState else { - return - } + private func currentCenterVisibleIndexPath(snapshot: NSDiffableDataSourceSnapshot?) -> IndexPath? { + let snapshot = snapshot ?? dataSource.snapshot() + let visible = collectionView.indexPathsForVisibleItems.sorted() - let snapshot = dataSource.snapshot() let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) guard !visible.isEmpty, let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses), let rawCenterVisible = collectionView.indexPathForItem(at: midPoint), let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else { + return nil + } + return centerVisible + } + + private func saveState() { + guard isViewLoaded, + persistsState, + let accountInfo = mastodonController.accountInfo else { + return + } + let snapshot = dataSource.snapshot() + guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else { return } let allItems = snapshot.itemIdentifiers(inSection: .statuses) @@ -288,10 +318,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") - let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) ?? TimelineState(timeline: timeline, context: mastodonController.persistentContainer.viewContext) - state.setStatuses(ids) - state.centerStatusID = centerVisibleID - mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext) + let context = mastodonController.persistentContainer.viewContext + let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context) + position.statusIDs = ids + position.centerStatusID = centerVisibleID + mastodonController.persistentContainer.save(context: context) } func stateRestorationActivity() -> NSUserActivity? { @@ -304,45 +335,68 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return activity } - private func restoreState() -> Bool { + func restoreState() -> Bool { guard persistsState, Preferences.shared.timelineStateRestoration, - let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else { + let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { return false } - let statusIDs = state.statusMOs.map(\.id) loadViewIfNeeded() - controller.restoreInitial { - var snapshot = dataSource.snapshot() - snapshot.appendSections([.statuses]) - let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } - snapshot.appendItems(items, toSection: .statuses) - dataSource.apply(snapshot, animatingDifferences: false) { - if let centerID = state.centerStatusID, - let index = statusIDs.firstIndex(of: centerID), - 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: .centeredVertically, animated: false) - let newOffset = self.collectionView.contentOffset - if abs(origOffset.y - newOffset.y) <= 1 { - break - } - } - stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)") - } else { - stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") - } + Task { + await controller.restoreInitial { + await loadStatusesToRestore(position: position) + applyItemsToRestore(position: position) } } return true } + @MainActor + private func loadStatusesToRestore(position: TimelinePosition) async { + let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) + guard !unloaded.isEmpty else { + return + } + await withTaskGroup(of: Void.self) { group in + for id in unloaded { + group.addTask { @MainActor in + if let (status, _) = try? await self.mastodonController.run(Client.getStatus(id: id)) { + self.mastodonController.persistentContainer.addOrUpdate(status: status) + } + } + } + } + } + + private func applyItemsToRestore(position: TimelinePosition) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.statuses]) + let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } + snapshot.appendItems(items, toSection: .statuses) + dataSource.apply(snapshot, animatingDifferences: false) { + if let centerID = position.centerStatusID, + let index = position.statusIDs.firstIndex(of: centerID), + 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: .centeredVertically, animated: false) + let newOffset = self.collectionView.contentOffset + if abs(origOffset.y - newOffset.y) <= 1 { + break + } + } + stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)") + } else { + stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") + } + } + } + private func removeTimelineDescriptionCell() { var snapshot = dataSource.snapshot() snapshot.deleteItems([.publicTimelineDescription]) @@ -394,7 +448,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro view.window?.windowScene == scene else { return } - checkPresentIfEnoughTimeElapsed() + if promptToSyncPositionIfNecessary() { + // no-op + } else { + checkPresentIfEnoughTimeElapsed() + } } @objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) { @@ -407,6 +465,45 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro saveState() } + private func promptToSyncPositionIfNecessary() -> Bool { + guard persistsState, + let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { + return false + } + let snapshot = dataSource.snapshot() + guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot), + snapshot.sectionIdentifiers.contains(.statuses) else { + return false + } + let statusesSection = snapshot.itemIdentifiers(inSection: .statuses) + let centerVisibleStatusID: String + switch statusesSection[centerVisible.row] { + case .gap: + guard case .status(let id, _, _) = statusesSection[centerVisible.row + 1] else { + fatalError() + } + centerVisibleStatusID = id + case .status(let id, _, _): + centerVisibleStatusID = id + default: + fatalError() + } + guard timelinePosition.centerStatusID != centerVisibleStatusID else { + return false + } + var config = ToastConfiguration(title: "Sync Position") + config.edge = .top + config.dismissAutomaticallyAfter = 5 + config.systemImageName = "arrow.triangle.2.circlepath" + config.action = { [unowned self] toast in + toast.dismissToast(animated: true) + _ = self.restoreState() + } + showToast(configuration: config, animated: true) + UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated") + return true + } + @objc func refresh() { Task { if case .notLoadedInitial = controller.state { diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 43838751..cf64b7f9 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -53,7 +53,14 @@ class TimelinesPageViewController: SegmentedPageViewController { } /// Used to indicate to the controller that the initial set of posts have been restored externally. - func restoreInitial(doRestore: () -> Void) { - guard state == .notLoadedInitial else { + func restoreInitial(doRestore: () async -> Void) async { + guard state == .notLoadedInitial || state == .idle else { return } state = .restoringInitial - doRestore() + await doRestore() state = .idle } @@ -234,7 +234,7 @@ class TimelineLikeController { } case .idle: switch to { - case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _): + case .restoringInitial, .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _): return true default: return false