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
This commit is contained in:
Shadowfacts 2022-12-13 11:35:09 -05:00
parent 3691c3f483
commit ba2c34fdd6
7 changed files with 148 additions and 34 deletions

View File

@ -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 = "<group>"; };
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -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 */,

View File

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

View File

@ -18,6 +18,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
return NSFetchRequest<StatusMO>(entityName: "Status")
}
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<StatusMO> {
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

View File

@ -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<TimelineState> {
let req = NSFetchRequest<TimelineState>(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)...]
}

View File

@ -105,10 +105,16 @@
<attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="TimelineState" representedClassName="TimelineState" syncable="YES">
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
</entity>
</model>

View File

@ -15,6 +15,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
weak var mastodonController: MastodonController!
let filterer: Filterer
var persistsState = false
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
// 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() {

View File

@ -22,12 +22,15 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
home.persistsState = true
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle
federated.persistsState = true
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle
local.persistsState = true
super.init(pages: [
(.home, "Home", home),
@ -83,9 +86,6 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
return
}
selectPage(page, animated: false)
// can't use currentIndex here because the view isn't loaded yet, and so the page wasn't actually updated by the selectPage call
let timelineVC = pageControllers[pages.firstIndex(of: page)!] as! TimelineViewController
timelineVC.restoreActivity(activity)
}
@objc private func filtersPressed() {