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:
parent
3691c3f483
commit
ba2c34fdd6
|
@ -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 */,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)...]
|
||||
}
|
|
@ -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>
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in New Issue