Compare commits

...

5 Commits

5 changed files with 133 additions and 56 deletions

View File

@ -259,6 +259,7 @@
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; }; D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; }; D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; }; D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; }; D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; }; D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; }; D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
@ -687,6 +688,7 @@
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; }; D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; }; D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; }; D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; }; D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; }; D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; }; D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1051,6 +1053,7 @@
D608470E2A245D1F00C17380 /* ActiveInstance.swift */, D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
); );
path = CoreData; path = CoreData;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2412,6 +2415,7 @@
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */, D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
); );

View File

@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return context return context
}() }()
private var lastRemoteChangeToken: NSPersistentHistoryToken?
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily // TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
// would need to audit existing uses to make sure everything happens on the main thread // would need to audit existing uses to make sure everything happens on the main thread
// and when updating things on the background context would need to switch to main, refetch, and then publish // and when updating things on the background context would need to switch to main, refetch, and then publish
@ -190,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
viewContext.name = "View" viewContext.name = "View"
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) if accountInfo != nil {
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
} }
func save(context: NSManagedObjectContext) { func save(context: NSManagedObjectContext) {
@ -521,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
@objc private func remoteChanges(_ notification: Foundation.Notification) { @objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { guard let accountInfo,
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return return
} }
remoteChangesBackgroundContext.perform { PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
defer { self.remoteChangesBackgroundContext.perform {
self.lastRemoteChangeToken = token defer {
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
}
let transactions: [NSPersistentHistoryTransaction]
do {
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
} else {
logger.error("Unexpectedly non-NSPersistentHistoryResult")
return
}
} catch {
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
return
}
if !transactions.isEmpty {
self.processPersistentHistoryTransactions(transactions)
}
// NB: We deliberately do not purge old persistent history.
// Doing so causes the CoreData+CloudKit integration to replay all of
// the server's changes on initialization, which takes a long time
// and produces a bunch of intermediate UI updates we don't want.
} }
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken) }
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult, }
let transactions = result.result as? [NSPersistentHistoryTransaction],
!transactions.isEmpty { private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
var changedHashtags = false logger.info("Processing \(transactions.count) persistent history transactions")
var changedInstances = false var changedHashtags = false
var changedTimelinePositions = Set<NSManagedObjectID>() var changedInstances = false
var changedAccountPrefs = false var changedTimelinePositions = Set<NSManagedObjectID>()
outer: for transaction in transactions { var changedAccountPrefs = false
for change in transaction.changes ?? [] { outer: for transaction in transactions {
if change.changedObjectID.entity.name == "SavedHashtag" { logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
changedHashtags = true for change in transaction.changes ?? [] {
} else if change.changedObjectID.entity.name == "SavedInstance" { if change.changedObjectID.entity.name == "SavedHashtag" {
changedInstances = true changedHashtags = true
} else if change.changedObjectID.entity.name == "TimelinePosition" { } else if change.changedObjectID.entity.name == "SavedInstance" {
changedTimelinePositions.insert(change.changedObjectID) changedInstances = true
} else if change.changedObjectID.entity.name == "AccountPreferences" { } else if change.changedObjectID.entity.name == "TimelinePosition" {
changedAccountPrefs = true changedTimelinePositions.insert(change.changedObjectID)
} } else if change.changedObjectID.entity.name == "AccountPreferences" {
} changedAccountPrefs = true
} }
// Can't capture vars in concurrently-executing closure }
let hashtags = changedHashtags }
let instances = changedInstances // Can't capture vars in concurrently-executing closure
let timelinePositions = changedTimelinePositions let hashtags = changedHashtags
let accountPrefs = changedAccountPrefs let instances = changedInstances
DispatchQueue.main.async { let timelinePositions = changedTimelinePositions
if hashtags { let accountPrefs = changedAccountPrefs
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) DispatchQueue.main.async {
} if hashtags {
if instances { NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) }
} if instances {
for id in timelinePositions { NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else { }
continue for id in timelinePositions {
} guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually continue
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
} }
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
} }
} }
} }

View File

@ -0,0 +1,47 @@
//
// PersistentHistoryTokenStore.swift
// Tusker
//
// Created by Shadowfacts on 6/8/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import UserAccounts
struct PersistentHistoryTokenStore {
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
private static func load() throws -> [String: NSPersistentHistoryToken]? {
let data = try Data(contentsOf: storeURL)
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
return unarchived as? [String: NSPersistentHistoryToken]
}
private static func save() throws {
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
try data.write(to: PersistentHistoryTokenStore.storeURL)
}
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
queue.async {
completion(tokens[account.id])
}
}
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
queue.async {
tokens[account.id] = token
try? save()
}
}
private init() {}
}

View File

@ -188,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
} }
@objc func handleSidebarCommandTimelines() { @objc func handleSidebarCommandTimelines() {
sidebar.select(item: .tab(.timelines), animated: false)
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem) select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.timelines), animated: false)
} }
@objc func handleSidebarCommandNotifications() { @objc func handleSidebarCommandNotifications() {
sidebar.select(item: .tab(.notifications), animated: false)
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem) select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.notifications), animated: false)
} }
@objc func handleSidebarCommandExplore() { @objc func handleSidebarCommandExplore() {
sidebar.select(item: .tab(.explore), animated: false)
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem) select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.explore), animated: false)
} }
@objc func handleSidebarCommandBookmarks() { @objc func handleSidebarCommandBookmarks() {
sidebar.select(item: .bookmarks, animated: false)
select(newItem: .bookmarks, oldItem: sidebar.selectedItem) select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
sidebar.select(item: .bookmarks, animated: false)
} }
@objc func handleSidebarCommandMyProfile() { @objc func handleSidebarCommandMyProfile() {
sidebar.select(item: .tab(.myProfile), animated: false)
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem) select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.myProfile), animated: false)
} }
@objc private func sidebarTapped() { @objc private func sidebarTapped() {

View File

@ -256,6 +256,7 @@ extension StatusCollectionViewCell {
view.backgroundColor = .tintColor.withAlphaComponent(0.5) view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5 view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
view.layer.zPosition = -1
prevThreadLinkView = view prevThreadLinkView = view
contentView.addSubview(view) contentView.addSubview(view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -278,6 +279,7 @@ extension StatusCollectionViewCell {
view.backgroundColor = .tintColor.withAlphaComponent(0.5) view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5 view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.layer.zPosition = -1
nextThreadLinkView = view nextThreadLinkView = view
contentView.addSubview(view) contentView.addSubview(view)
let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)