2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Expand existing cache system to handle grouped notifications

Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification
This commit is contained in:
shannon 2025-03-05 15:23:14 -05:00
parent 535cb2429f
commit 5e6978248f
9 changed files with 150 additions and 109 deletions

View File

@ -626,9 +626,7 @@ extension SceneCoordinator: SettingsCoordinatorDelegate {
authentication: authenticationBox.authentication
)
let userIdentifier = authenticationBox
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
PersistenceManager.shared.removeAllCaches(forUser: userIdentifier)
self.setup()
}

View File

@ -422,7 +422,7 @@ private class NotificationListViewModel: ObservableObject {
var updatedItems = records.allRecords.map {
NotificationListItem.groupedNotification($0)
}
if records.canLoadOlder {
if !records.allRecords.isEmpty && records.canLoadOlder {
updatedItems.append(.bottomLoader)
}
updatedItems = self.notificationPolicyBannerRow + updatedItems

View File

@ -26,8 +26,6 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager {
typealias T = [Mastodon.Entity.Notification]
private let userIdentifier: MastodonUserIdentifier
private let feedKind: MastodonFeedKind
private let lastReadMarkerStore: Store<LastReadMarkers>
private let cachedNotifications: Store<Mastodon.Entity.Notification>
private var staleResults: T?
private var staleMarkers: LastReadMarkers?
@ -38,8 +36,6 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager {
init(feedKind: MastodonFeedKind, userIdentifier: MastodonUserIdentifier) {
self.feedKind = feedKind
self.userIdentifier = userIdentifier
lastReadMarkerStore = Store.lastReadMarkersStore()
self.cachedNotifications = Store.ungroupedNotificationStore(forKind: feedKind, forUser: userIdentifier)
staleResults = nil
staleMarkers = nil
self.mostRecentlyFetchedResults = nil
@ -49,17 +45,25 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager {
func currentResults() async -> T? {
if let mostRecentlyFetchedResults {
return mostRecentlyFetchedResults
} else if let staleResults {
return staleResults
} else {
do {
switch feedKind {
case .notificationsAll, .notificationsMentionsOnly:
try await lastReadMarkerStore.itemsHaveLoaded()
self.staleMarkers = lastReadMarkerStore.items.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier })
let cachedMarkers: [LastReadMarkers] = try PersistenceManager.shared.cached(.lastReadMarkers(userIdentifier))
self.staleMarkers = cachedMarkers.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier })
case .notificationsWithAccount:
self.staleMarkers = nil
}
try await cachedNotifications.itemsHaveLoaded()
staleResults = cachedNotifications.items
switch feedKind {
case .notificationsAll:
staleResults = try PersistenceManager.shared.cached(.notificationsAll(userIdentifier))
case .notificationsMentionsOnly:
staleResults = try PersistenceManager.shared.cached(.notificationsMentions(userIdentifier))
case .notificationsWithAccount(let string):
staleResults = nil
}
} catch {
assertionFailure("error reading notifications cache: \(error)")
}
@ -116,15 +120,17 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager {
func commitToCache() async {
if let mostRecentMarkers {
try? await lastReadMarkerStore.insert(mostRecentMarkers)
PersistenceManager.shared.cache([mostRecentMarkers], for: .lastReadMarkers(userIdentifier))
}
if let mostRecentlyFetchedResults {
try? await cachedNotifications
.removeAll()
.insert(mostRecentlyFetchedResults)
.run()
} else {
try? await cachedNotifications.removeAll()
switch feedKind {
case .notificationsAll:
PersistenceManager.shared.cache(mostRecentlyFetchedResults, for: .notificationsAll(userIdentifier))
case .notificationsMentionsOnly:
PersistenceManager.shared.cache(mostRecentlyFetchedResults, for: .notificationsMentions(userIdentifier))
case .notificationsWithAccount:
break
}
}
}
}
@ -144,22 +150,10 @@ class GroupedNotificationCacheManager: NotificationsCacheManager {
internal var mostRecentlyFetchedResults: T?
private var mostRecentMarkers: LastReadMarkers?
private let lastReadMarkerStore: Store<LastReadMarkers>
private let notificationGroupStore: Store<Mastodon.Entity.NotificationGroup>
private let fullAccountStore: Store<Mastodon.Entity.Account>
private let partialAccountStore: Store<Mastodon.Entity.PartialAccountWithAvatar>
private let statusStore: Store<Mastodon.Entity.Status>
init(feedKind: MastodonFeedKind, userIdentifier: MastodonUserIdentifier) {
self.feedKind = feedKind
self.userIdentifier = userIdentifier
lastReadMarkerStore = Store.lastReadMarkersStore()
notificationGroupStore = Store.notificationGroupStore(forKind: feedKind, forUser: userIdentifier)
fullAccountStore = Store.notificationRelevantFullAccountStore(forKind: feedKind, forUser: userIdentifier)
partialAccountStore = Store.notificationRelevantPartialAccountStore(forKind: feedKind, forUser: userIdentifier)
statusStore = Store.notificationRelevantStatusStore(forKind: feedKind, forUser: userIdentifier)
staleMarkers = nil
staleResults = nil
}
@ -271,18 +265,44 @@ class GroupedNotificationCacheManager: NotificationsCacheManager {
}
func currentResults() async -> T? {
do {
try await lastReadMarkerStore.itemsHaveLoaded()
try await notificationGroupStore.itemsHaveLoaded()
try await fullAccountStore.itemsHaveLoaded()
try await partialAccountStore.itemsHaveLoaded()
try await statusStore.itemsHaveLoaded()
staleMarkers = lastReadMarkerStore.items.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier })
staleResults = Mastodon.Entity.GroupedNotificationsResults(notificationGroups: notificationGroupStore.items, fullAccounts: fullAccountStore.items, partialAccounts: partialAccountStore.items, statuses: statusStore.items)
} catch {
assertionFailure("error loading notifications caches: \(error)")
if let mostRecentlyFetchedResults {
return mostRecentlyFetchedResults
} else if let staleResults {
return staleResults
} else {
do {
switch feedKind {
case .notificationsAll, .notificationsMentionsOnly:
let cachedMarkers: [LastReadMarkers] = try PersistenceManager.shared.cached(.lastReadMarkers(userIdentifier))
self.staleMarkers = cachedMarkers.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier })
case .notificationsWithAccount:
self.staleMarkers = nil
}
let notificationGroups: [Mastodon.Entity.NotificationGroup]
let accounts: [Mastodon.Entity.Account]
let partialAccounts: [Mastodon.Entity.PartialAccountWithAvatar]
let statuses: [Mastodon.Entity.Status]
switch feedKind {
case .notificationsAll:
notificationGroups = (try? PersistenceManager.shared.cached(.groupedNotificationsAll(userIdentifier))) ?? []
accounts = (try? PersistenceManager.shared.cached(.groupedNotificationsAllAccounts(userIdentifier))) ?? []
partialAccounts = (try? PersistenceManager.shared.cached(.groupedNotificationsAllPartialAccounts(userIdentifier))) ?? []
statuses = (try? PersistenceManager.shared.cached(.groupedNotificationsAllStatuses(userIdentifier))) ?? []
case .notificationsMentionsOnly:
notificationGroups = (try? PersistenceManager.shared.cached(.groupedNotificationsMentions(userIdentifier))) ?? []
accounts = (try? PersistenceManager.shared.cached(.groupedNotificationsMentionsAccounts(userIdentifier))) ?? []
partialAccounts = (try? PersistenceManager.shared.cached(.groupedNotificationsMentionsPartialAccounts(userIdentifier))) ?? []
statuses = (try? PersistenceManager.shared.cached(.groupedNotificationsMentionsStatuses(userIdentifier))) ?? []
case .notificationsWithAccount(let string):
return mostRecentlyFetchedResults
}
staleResults = Mastodon.Entity.GroupedNotificationsResults(notificationGroups: notificationGroups, fullAccounts: accounts, partialAccounts: partialAccounts, statuses: statuses)
} catch {
assertionFailure("error reading notifications cache: \(error)")
}
return mostRecentlyFetchedResults ?? staleResults
}
return mostRecentlyFetchedResults ?? staleResults
}
var currentLastReadMarker: LastReadMarkers.MarkerPosition? {
@ -296,31 +316,22 @@ class GroupedNotificationCacheManager: NotificationsCacheManager {
func commitToCache() async {
if let mostRecentMarkers {
do {
try await lastReadMarkerStore.insert(mostRecentMarkers)
} catch {
}
PersistenceManager.shared.cache([mostRecentMarkers], for: .lastReadMarkers(userIdentifier))
}
if let mostRecentlyFetchedResults {
do {
try await notificationGroupStore
.removeAll()
.insert(mostRecentlyFetchedResults.notificationGroups)
.run()
try await fullAccountStore
.removeAll()
.insert(mostRecentlyFetchedResults.accounts)
.run()
try await partialAccountStore
.removeAll()
.insert(mostRecentlyFetchedResults.partialAccounts ?? [])
.run()
try await statusStore
.removeAll()
.insert(mostRecentlyFetchedResults.statuses)
.run()
} catch {
assertionFailure("error comitting to store \(error)")
switch feedKind {
case .notificationsAll:
PersistenceManager.shared.cache(mostRecentlyFetchedResults.notificationGroups, for: .groupedNotificationsAll(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.accounts, for: .groupedNotificationsAllAccounts(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.partialAccounts ?? [], for: .groupedNotificationsAllPartialAccounts(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.statuses, for: .groupedNotificationsAllStatuses(userIdentifier))
case .notificationsMentionsOnly:
PersistenceManager.shared.cache(mostRecentlyFetchedResults.notificationGroups, for: .groupedNotificationsMentions(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.accounts, for: .groupedNotificationsMentionsAccounts(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.partialAccounts ?? [], for: .groupedNotificationsMentionsPartialAccounts(userIdentifier))
PersistenceManager.shared.cache(mostRecentlyFetchedResults.statuses, for: .groupedNotificationsMentionsStatuses(userIdentifier))
case .notificationsWithAccount:
break
}
}
}

View File

@ -106,12 +106,10 @@ extension AccountListViewController: UITableViewDelegate {
Task { @MainActor in
do {
try await AuthenticationServiceProvider.shared.signOutMastodonUser(authentication: record)
let userIdentifier = record
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
try await AuthenticationServiceProvider.shared.signOutMastodonUser(authentication: record)
PersistenceManager.shared.removeAllCaches(forUser: userIdentifier)
self.sceneCoordinator?.setup()
} catch {
@ -156,6 +154,9 @@ extension AccountListViewController: UITableViewDelegate {
self.sceneCoordinator?.showLoading()
for authenticationBox in AuthenticationServiceProvider.shared.mastodonAuthenticationBoxes {
try? await AuthenticationServiceProvider.shared.signOutMastodonUser(authentication: authenticationBox.authentication)
let userIdentifier = authenticationBox.authentication.userIdentifier()
PersistenceManager.shared.removeAllCaches(forUser: userIdentifier)
self.sceneCoordinator?.setup()
}
self.sceneCoordinator?.hideLoading()

View File

@ -651,9 +651,8 @@ extension HomeTimelineViewController {
Task { @MainActor in
try await AuthenticationServiceProvider.shared.signOutMastodonUser(authentication: authenticationBox.authentication)
let userIdentifier = authenticationBox
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
PersistenceManager.shared.removeAllCaches(forUser: userIdentifier)
self.sceneCoordinator?.setup()
self.sceneCoordinator?.setup()
}
}

View File

@ -92,8 +92,8 @@ final class HomeTimelineViewModel: NSObject {
self.authenticationBox = authenticationBox
self.dataController = FeedDataController(authenticationBox: authenticationBox, kind: .home(timeline: timelineContext))
super.init()
let initialRecords = (try? PersistenceManager.shared.cachedTimeline(.homeTimeline(authenticationBox)).map {
MastodonFeed.fromStatus($0, kind: .home)
let initialRecords = (try? PersistenceManager.shared.cached(.homeTimeline(authenticationBox)).map {
MastodonFeed.fromStatus(MastodonStatus.fromEntity($0), kind: .home)
}) ?? []
Task {
await self.dataController.setRecordsAfterFiltering(initialRecords)

View File

@ -30,19 +30,6 @@ public extension FileManager {
func cacheNotificationsMentions(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) {
cache(items, timeline: .notificationsMentions(userIdentifier))
}
// Delete
func invalidateHomeTimelineCache(for userId: UserIdentifier) {
invalidate(timeline: .homeTimeline(userId))
}
func invalidateNotificationsAll(for userId: UserIdentifier) {
invalidate(timeline: .notificationsAll(userId))
}
func invalidateNotificationsMentions(for userId: UserIdentifier) {
invalidate(timeline: .notificationsMentions(userId))
}
}
private extension FileManager {

View File

@ -13,20 +13,47 @@ public enum Persistence {
case homeTimeline(UserIdentifier)
case notificationsMentions(UserIdentifier)
case notificationsAll(UserIdentifier)
case groupedNotificationsMentions(UserIdentifier)
case groupedNotificationsMentionsAccounts(UserIdentifier)
case groupedNotificationsMentionsPartialAccounts(UserIdentifier)
case groupedNotificationsMentionsStatuses(UserIdentifier)
case groupedNotificationsAll(UserIdentifier)
case groupedNotificationsAllAccounts(UserIdentifier)
case groupedNotificationsAllPartialAccounts(UserIdentifier)
case groupedNotificationsAllStatuses(UserIdentifier)
case lastReadMarkers(UserIdentifier)
case accounts(UserIdentifier)
private var filename: String {
switch self {
case .searchHistory(let userIdentifier):
return "search_history_\(userIdentifier.globallyUniqueUserIdentifier))"
case let .homeTimeline(userIdentifier):
return "home_timeline_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .notificationsMentions(userIdentifier):
return "notifications_mentions_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .notificationsAll(userIdentifier):
return "notifications_all_\(userIdentifier.globallyUniqueUserIdentifier)"
case .accounts(let userIdentifier):
return "account_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .searchHistory(userIdentifier):
return "search_history_\(userIdentifier.globallyUniqueUserIdentifier))"
case let .homeTimeline(userIdentifier):
return "home_timeline_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .notificationsMentions(userIdentifier):
return "notifications_mentions_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .notificationsAll(userIdentifier):
return "notifications_all_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsMentions(userIdentifier):
return "grouped_notifications_mentions_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsMentionsStatuses(userIdentifier):
return "grouped_notifications_mentions_relevant_statuses_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsMentionsAccounts(userIdentifier):
return "grouped_notifications_mentions_relevant_accounts_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsMentionsPartialAccounts(userIdentifier):
return "grouped_notifications_mentions_relevant_partialAccounts_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsAll(userIdentifier):
return "grouped_notifications_all_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsAllStatuses(userIdentifier):
return "grouped_notifications_all_relevant_statuses_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsAllAccounts(userIdentifier):
return "grouped_notifications_all_relevant_accounts_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .groupedNotificationsAllPartialAccounts(userIdentifier):
return "grouped_notifications_all_relevant_partialAccounts_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .lastReadMarkers(userIdentifier):
return "last_read_markers_\(userIdentifier.globallyUniqueUserIdentifier)"
case let .accounts(userIdentifier):
return "account_\(userIdentifier.globallyUniqueUserIdentifier)"
}
}

View File

@ -42,10 +42,6 @@ public class PersistenceManager {
return coreDataStack.newTaskContext()
}
public func cachedTimeline(_ timeline: Persistence) throws -> [MastodonStatus] {
return try FileManager.default.cached(timeline: timeline).map(MastodonStatus.fromEntity)
}
public func cachedAccount(for authentication: MastodonAuthentication) -> Mastodon.Entity.Account? {
let account = FileManager
.default
@ -57,15 +53,38 @@ public class PersistenceManager {
public func cacheAccount(_ account: Mastodon.Entity.Account, forUserID userID: MastodonUserIdentifier) {
FileManager.default.store(account: account, forUserID: userID)
}
public func cached<T: Decodable>(_ cacheType: Persistence) throws -> [T] {
return try FileManager.default.cached(cacheType)
}
public func cache<T: Encodable>(_ items: [T], for cacheType: Persistence) {
FileManager.default.cache(items, for: cacheType)
}
public func removeAllCaches(forUser user: UserIdentifier) {
FileManager.default.invalidate(cache: .accounts(user))
FileManager.default.invalidate(cache: .groupedNotificationsAll(user))
FileManager.default.invalidate(cache: .groupedNotificationsAllAccounts(user))
FileManager.default.invalidate(cache: .groupedNotificationsAllPartialAccounts(user))
FileManager.default.invalidate(cache: .groupedNotificationsAllStatuses(user))
FileManager.default.invalidate(cache: .groupedNotificationsMentions(user))
FileManager.default.invalidate(cache: .groupedNotificationsMentionsAccounts(user))
FileManager.default.invalidate(cache: .groupedNotificationsMentionsPartialAccounts(user))
FileManager.default.invalidate(cache: .groupedNotificationsMentionsStatuses(user))
FileManager.default.invalidate(cache: .homeTimeline(user))
FileManager.default.invalidate(cache: .searchHistory(user))
FileManager.default.invalidate(cache: .notificationsAll(user))
}
}
private extension FileManager {
static let cacheItemsLimit: Int = 100 // max number of items to cache
func cached<T: Decodable>(timeline: Persistence) throws -> [T] {
func cached<T: Decodable>(_ cacheType: Persistence) throws -> [T] {
guard let cachesDirectory else { return [] }
let filePath = timeline.filepath(baseURL: cachesDirectory)
let filePath = cacheType.filepath(baseURL: cachesDirectory)
guard let data = try? Data(contentsOf: filePath) else { return [] }
@ -78,8 +97,7 @@ private extension FileManager {
}
}
func cache<T: Encodable>(_ items: [T], timeline: Persistence) {
func cache<T: Encodable>(_ items: [T], for cacheType: Persistence) {
guard let cachesDirectory else { return }
let processableItems: [T]
@ -92,17 +110,17 @@ private extension FileManager {
do {
let data = try JSONEncoder().encode(processableItems)
let filePath = timeline.filepath(baseURL: cachesDirectory)
let filePath = cacheType.filepath(baseURL: cachesDirectory)
try data.write(to: filePath)
} catch {
debugPrint(error.localizedDescription)
}
}
func invalidate(timeline: Persistence) {
func invalidate(cache: Persistence) {
guard let cachesDirectory else { return }
let filePath = timeline.filepath(baseURL: cachesDirectory)
let filePath = cache.filepath(baseURL: cachesDirectory)
try? removeItem(at: filePath)
}