From ba2c34fdd61e17b76de3cd710887b3c41f4e5786 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 13 Dec 2022 11:35:09 -0500 Subject: [PATCH] Persist timeline state using CoreData, rather than NSUserActivity This allows persisting state for all the primary timelines, and across all accounts. Closes #297 Closes #293 --- Tusker.xcodeproj/project.pbxproj | 4 + .../MastodonCachePersistentStore.swift | 9 ++ Tusker/CoreData/StatusMO.swift | 6 ++ Tusker/CoreData/TimlineState.swift | 89 +++++++++++++++++++ .../Tusker.xcdatamodel/contents | 6 ++ .../Timeline/TimelineViewController.swift | 62 ++++++------- .../TimelinesPageViewController.swift | 6 +- 7 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 Tusker/CoreData/TimlineState.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 32542511ac..acd51d2219 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -305,6 +305,7 @@ D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; }; + D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; @@ -698,6 +699,7 @@ D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = ""; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = ""; }; + D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = ""; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; @@ -933,6 +935,7 @@ D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */, D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, + D6D706A62948D4D0000827ED /* TimlineState.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, ); @@ -2071,6 +2074,7 @@ D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, + D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index f37fde9027..668bc80fd3 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -355,6 +355,15 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } + func getTimelineState(timeline: Timeline) -> TimelineState? { + do { + let req = TimelineState.fetchRequest(timeline: timeline) + return try viewContext.fetch(req).first + } catch { + return nil + } + } + @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { let changes = hasChangedSavedHashtagsOrInstances(notification) if changes.hashtags { diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 24150709bc..b7444bbd6a 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -18,6 +18,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol { return NSFetchRequest(entityName: "Status") } + @nonobjc public class func fetchRequest(id: String) -> NSFetchRequest { + let req = Self.fetchRequest() + req.predicate = NSPredicate(format: "id = %@", id) + return req + } + @NSManaged public var applicationName: String? @NSManaged private var attachmentsData: Data? @NSManaged private var bookmarkedInternal: Bool diff --git a/Tusker/CoreData/TimlineState.swift b/Tusker/CoreData/TimlineState.swift new file mode 100644 index 0000000000..13f092443b --- /dev/null +++ b/Tusker/CoreData/TimlineState.swift @@ -0,0 +1,89 @@ +// +// TimlineState.swift +// Tusker +// +// Created by Shadowfacts on 12/13/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +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 + } + + @NSManaged private var timelineKind: String + @NSManaged public var centerStatusID: String? + @NSManaged private var statuses: NSOrderedSet + + 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 50afc1823f..241a1d570d 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -105,10 +105,16 @@ + + + + + + \ No newline at end of file diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 6b215429fa..d58642b263 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -15,6 +15,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro weak var mastodonController: MastodonController! let filterer: Filterer + var persistsState = false + private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() // stored separately because i don't want to query the snapshot every time the user scrolls @@ -196,7 +198,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } if case .notLoadedInitial = controller.state { - if doRestore() { + if restoreState() { Task { await checkPresent(jumpImmediately: false) } @@ -227,22 +229,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro super.viewDidDisappear(animated) disappearedAt = Date() + saveState() } - func stateRestorationActivity() -> NSUserActivity? { - guard isViewLoaded else { - return nil + private func saveState() { + guard isViewLoaded, + persistsState else { + return } 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 let currentAccountID = mastodonController.accountInfo?.id, - !visible.isEmpty, + 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 } let allItems = snapshot.itemIdentifiers(inSection: .statuses) @@ -282,44 +285,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } else { fatalError() } - stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(centerVisibleID)") + 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) + } + + func stateRestorationActivity() -> NSUserActivity? { + guard isViewLoaded, + let currentAccountID = mastodonController.accountInfo?.id else { + return nil + } let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)! - activity.addUserInfoEntries(from: [ - "statusIDs": ids, - "centerID": centerVisibleID, - ]) activity.isEligibleForPrediction = false return activity } - func restoreActivity(_ activity: NSUserActivity) { - self.activityToRestore = activity - } - - private func doRestore() -> Bool { - guard let activity = activityToRestore, - Preferences.shared.timelineStateRestoration else { - return false - } - guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else { - stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs") - return false - } - activityToRestore = nil - let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil } - guard !existingStatuses.isEmpty else { + private func restoreState() -> Bool { + guard persistsState, + Preferences.shared.timelineStateRestoration, + let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else { return false } + let statusIDs = state.statusMOs.map(\.id) loadViewIfNeeded() controller.restoreInitial { var snapshot = dataSource.snapshot() snapshot.appendSections([.statuses]) - let items = existingStatuses.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } + 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 = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String, - let index = existingStatuses.firstIndex(of: centerID), + 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 @@ -400,6 +399,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return } disappearedAt = Date() + saveState() } @objc func refresh() { diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 0cb4298a1d..dfcdbef9a0 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -22,12 +22,15 @@ class TimelinesPageViewController: SegmentedPageViewController