From 7ea113fa7f0100cb65e6542bed09b3d2c37b540c Mon Sep 17 00:00:00 2001 From: shannon Date: Fri, 21 Feb 2025 13:40:34 -0500 Subject: [PATCH] Add caching to grouped notifications Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification --- Mastodon.xcodeproj/project.pbxproj | 2 + .../BoutiqueStores.swift | 84 ++++++ .../GroupedNotificationFeedLoader.swift | 220 +++++++++------ .../NotificationListViewController.swift | 2 +- .../NotificationsCacheManager.swift | 266 ++++++++++++++++++ .../MastodonCore/MastodonAuthentication.swift | 10 + .../Entity/Mastodon+Entity+InstanceV2.swift | 3 + .../Entity/Mastodon+Entity+Marker.swift | 2 + .../Entity/Mastodon+Entity+Notification.swift | 14 + 9 files changed, 519 insertions(+), 84 deletions(-) create mode 100644 Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift create mode 100644 Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b08cdba98..f97558913 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -1276,12 +1276,14 @@ FBBEA04F2D3819080000A900 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + BoutiqueStores.swift, GroupedNotificationFeedLoader.swift, InlinePostPreview.swift, NotificationInfo.swift, NotificationListViewController.swift, NotificationRowView.swift, NotificationRowViewModel.swift, + NotificationsCacheManager.swift, TimelinePostCell/ActionButtons.swift, TimelinePostCell/AuthorHeader.swift, TimelinePostCell/BoostHeader.swift, diff --git a/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift b/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift new file mode 100644 index 000000000..3221633a9 --- /dev/null +++ b/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift @@ -0,0 +1,84 @@ +// Copyright © 2025 Mastodon gGmbH. All rights reserved. + +import MastodonSDK +import Boutique + +extension MastodonFeedKind { + var storageTag: String? { + switch self { + case .notificationsAll: + return "all" + case .notificationsMentionsOnly: + return "mentions" + case .notificationsWithAccount: + return nil + } + } +} + +extension Store where Item == Mastodon.Entity.Notification { + static func ungroupedNotificationStore(forKind feedKind: MastodonFeedKind, forUserAcct userAcct: String) -> Store { + let tag = feedKind.storageTag + assert(tag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "ungrouped_notification_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + } +} + +extension Store where Item == Mastodon.Entity.NotificationGroup { + static func notificationGroupStore(forKind feedKind: MastodonFeedKind, forUserAcct userAcct: String) -> Store { + let tag = feedKind.storageTag + assert(tag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "notification_group_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + } +} + +extension Store where Item == Mastodon.Entity.Account { + static func notificationRelevantFullAccountStore(forKind feedKind: MastodonFeedKind, forUserAcct userAcct: String) -> Store { + let tag = feedKind.storageTag + assert(tag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_full_account_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + } +} + +extension Store where Item == Mastodon.Entity.PartialAccountWithAvatar { + static func notificationRelevantPartialAccountStore(forKind feedKind: MastodonFeedKind, forUserAcct userAcct: String) -> Store { + let tag = feedKind.storageTag + assert(tag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_partial_account_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + } +} + +extension Store where Item == Mastodon.Entity.Status { + static func notificationRelevantStatusStore(forKind feedKind: MastodonFeedKind, forUserAcct userAcct: String) -> Store { + let tag = feedKind.storageTag + assert(tag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_status_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + } +} + +struct LastReadMarkerCache { + @StoredValue(key: Mastodon.Entity.Marker.storageKey) private var userToMarkerMap = [ String : Mastodon.Entity.Marker ]() + + func getCachedMarker(forUserAcct userAcct: String) -> Mastodon.Entity.Marker? { + return userToMarkerMap[userAcct] + } + + @MainActor + func setCachedMarker(_ marker: Mastodon.Entity.Marker, forUserAcct userAcct: String) { + var newMap = userToMarkerMap + newMap[userAcct] = marker + $userToMarkerMap.set(newMap) + } + + @MainActor + func removeMarker(forUserAcct userAcct: String) { + var newMap = userToMarkerMap + newMap.removeValue(forKey: userAcct) + $userToMarkerMap.set(newMap) + } +} diff --git a/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift b/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift index a95c21d3f..c556182a2 100644 --- a/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift +++ b/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift @@ -48,6 +48,8 @@ final public class GroupedNotificationFeedLoader { @Published private(set) var records: FeedLoadResult = FeedLoadResult( allRecords: [], canLoadOlder: true) + private let useGroupedNotificationsApi: Bool + private let cacheManager: any NotificationsCacheManager private let kind: MastodonFeedKind private let navigateToScene: ((SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void)? @@ -64,7 +66,26 @@ final public class GroupedNotificationFeedLoader { self.kind = kind self.navigateToScene = navigateToScene self.presentError = presentError - + + let useGrouped: Bool + let currentUser = AuthenticationServiceProvider.shared.currentActiveUser.value?.uniqueUserDomainIdentifier ?? "ERROR_no_user_found" + switch kind { + case .notificationsAll, .notificationsMentionsOnly: + if let currentInstance = AuthenticationServiceProvider.shared.currentActiveUser.value?.authentication.instanceConfiguration { + useGrouped = currentInstance.canGroupNotifications + } else { assertionFailure("no instance configuration") + useGrouped = false + } + case .notificationsWithAccount: + useGrouped = false + } + self.useGroupedNotificationsApi = useGrouped + if useGrouped { + self.cacheManager = GroupedNotificationCacheManager(feedKind: kind, userAcct: currentUser) + } else { + self.cacheManager = UngroupedNotificationCacheManager(feedKind: kind, userAcct: currentUser) + } + activeFilterBoxSubscription = StatusFilterService.shared .$activeFilterBox .sink { filterBox in @@ -73,13 +94,58 @@ final public class GroupedNotificationFeedLoader { guard let self else { return } let curAllRecords = self.records.allRecords let curCanLoadOlder = self.records.canLoadOlder - await self.setRecordsAfterFiltering( + await self.replaceRecordsAfterFiltering( curAllRecords, canLoadOlder: curCanLoadOlder) } } } } + + public func doFirstLoad() { + Task { + do { + try await loadCached() + loadMore(olderThan: nil, newerThan: records.allRecords.first?.newestID) + } catch { + presentError?(error) + } + } + } + private func replaceRecordsAfterFiltering(_ unfiltered: [NotificationRowViewModel], canLoadOlder: Bool? = nil) async { + let filtered: [NotificationRowViewModel] + if let filterBox = StatusFilterService.shared.activeFilterBox { + filtered = await filter(unfiltered, forFeed: kind, with: filterBox) + } else { + filtered = unfiltered + } + + let actuallyCanLoadOlder = { + if let newLast = filtered.last?.identifier.id, let oldLast = records.allRecords.last?.identifier.id { + return canLoadOlder ?? (newLast != oldLast) + } else { + return canLoadOlder ?? true + } + }() + + records = FeedLoadResult(allRecords: checkForDuplicates(filtered), canLoadOlder: actuallyCanLoadOlder) + } + + private func checkForDuplicates(_ rowViewModels: [NotificationRowViewModel]) -> [NotificationRowViewModel] { + var added = Set() + var deduped = [NotificationRowViewModel]() + for (i, model) in rowViewModels.enumerated() { + let id = model.identifier.id + if added.contains(id) { + continue + } else { + deduped.append(model) + added.insert(id) + } + } + return deduped + } + public func loadMore( olderThan: String?, newerThan: String? @@ -87,10 +153,8 @@ final public class GroupedNotificationFeedLoader { let request = FeedLoadRequest( olderThan: olderThan, newerThan: newerThan) Task { - let unfiltered = try await load(request) - await insertRecordsAfterFiltering( - at: request.resultsInsertionPoint, additionalRecords: unfiltered - ) + let newlyFetched = try await load(request) + await updateAfterInserting(newlyFetchedResults: newlyFetched, at: request.resultsInsertionPoint) } } @@ -101,17 +165,19 @@ final public class GroupedNotificationFeedLoader { let request = FeedLoadRequest( olderThan: olderThan, newerThan: newerThan) do { - let unfiltered = try await load(request) - await insertRecordsAfterFiltering( - at: request.resultsInsertionPoint, additionalRecords: unfiltered - ) + let newlyFetched = try await load(request) + await updateAfterInserting(newlyFetchedResults: newlyFetched, at: request.resultsInsertionPoint) } catch { presentError?(error) } } + + private func loadCached() async throws { + try await replaceRecordsAfterFiltering(rowViewModels(from: cacheManager.currentResults), canLoadOlder: true) + } private func load(_ request: FeedLoadRequest) async throws - -> [NotificationRowViewModel] + -> NotificationsResultType { switch kind { case .notificationsAll: @@ -129,51 +195,27 @@ final public class GroupedNotificationFeedLoader { // MARK: - Filtering extension GroupedNotificationFeedLoader { - private func setRecordsAfterFiltering( - _ newRecords: [NotificationRowViewModel], - canLoadOlder: Bool - ) async { - guard let filterBox = StatusFilterService.shared.activeFilterBox else { - self.records = FeedLoadResult( - allRecords: newRecords.removingDuplicates(), - canLoadOlder: canLoadOlder) - return + private func updateAfterInserting(newlyFetchedResults: NotificationsResultType, + at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) async { + do { + cacheManager.updateByInserting(newlyFetched: newlyFetchedResults, at: insertionPoint) + let unfiltered = try rowViewModels(from: cacheManager.currentResults) + + let canLoadOlder: Bool? = { + switch insertionPoint { + case .start: + return records.canLoadOlder + case .end: + return nil + case .replace: + return nil + } + }() + + await replaceRecordsAfterFiltering(unfiltered, canLoadOlder: canLoadOlder) + } catch { + presentError?(error) } - let filtered = await self.filter( - newRecords, forFeed: kind, with: filterBox) - self.records = FeedLoadResult( - allRecords: filtered.removingDuplicates(), - canLoadOlder: canLoadOlder) - } - - private func insertRecordsAfterFiltering( - at insertionPoint: FeedLoadRequest.InsertLocation, - additionalRecords: [NotificationRowViewModel] - ) async { - let newRecords: [NotificationRowViewModel] - if let filterBox = StatusFilterService.shared.activeFilterBox { - newRecords = await self.filter( - additionalRecords, forFeed: kind, with: filterBox) - } else { - newRecords = additionalRecords - } - var canLoadOlder = self.records.canLoadOlder - var combinedRecords = self.records.allRecords - switch insertionPoint { - case .start: - combinedRecords = (newRecords + combinedRecords) - .removingDuplicates() - case .end: - let prevLast = combinedRecords.last - combinedRecords = (combinedRecords + newRecords) - .removingDuplicates() - let curLast = combinedRecords.last - canLoadOlder = !(prevLast == curLast) - case .replace: - combinedRecords = newRecords.removingDuplicates() - } - self.records = FeedLoadResult( - allRecords: combinedRecords, canLoadOlder: canLoadOlder) } private func filter( @@ -190,54 +232,42 @@ extension GroupedNotificationFeedLoader { private func loadNotifications( withScope scope: APIService.MastodonNotificationScope, olderThan maxID: String? = nil - ) async throws -> [NotificationRowViewModel] { - do { + ) async throws -> NotificationsResultType { + if useGroupedNotificationsApi { return try await getGroupedNotifications( withScope: scope, olderThan: maxID) - } catch { + } else { return try await getUngroupedNotifications(withScope: scope, olderThan: maxID) } } private func loadNotifications( withAccountID accountID: String, olderThan maxID: String? = nil - ) async throws -> [NotificationRowViewModel] { - return try await getGroupedNotifications( + ) async throws -> [Mastodon.Entity.Notification] { + return try await getUngroupedNotifications( accountID: accountID, olderThan: maxID) } private func getGroupedNotifications( - withScope scope: APIService.MastodonNotificationScope? = nil, - accountID: String? = nil, olderThan maxID: String? = nil - ) async throws -> [NotificationRowViewModel] { - - assert(scope != nil || accountID != nil, "need a scope or an accountID") - + withScope scope: APIService.MastodonNotificationScope, olderThan maxID: String? = nil + ) async throws -> Mastodon.Entity.GroupedNotificationsResults { guard let authenticationBox = AuthenticationServiceProvider.shared .currentActiveUser.value else { throw APIService.APIError.implicit(.authenticationMissing) } let results = try await APIService.shared.groupedNotifications( - olderThan: maxID, fromAccount: accountID, scope: scope, + olderThan: maxID, fromAccount: nil, scope: scope, authenticationBox: authenticationBox ) - return - NotificationRowViewModel - .viewModelsFromGroupedNotificationResults( - results, - myAccountID: authenticationBox.userID, - myAccountDomain: authenticationBox.domain, - navigateToScene: navigateToScene ?? { _, _ in }, - presentError: presentError ?? { _ in } - ) + return results } private func getUngroupedNotifications( withScope scope: APIService.MastodonNotificationScope? = nil, accountID: String? = nil, olderThan maxID: String? = nil - ) async throws -> [NotificationRowViewModel] { + ) async throws -> [Mastodon.Entity.Notification] { assert(scope != nil || accountID != nil, "need a scope or an accountID") @@ -251,12 +281,36 @@ extension GroupedNotificationFeedLoader { authenticationBox: authenticationBox ).value - return NotificationRowViewModel.viewModelsFromUngroupedNotifications( - ungrouped, myAccountID: authenticationBox.userID, - myAccountDomain: authenticationBox.domain, - navigateToScene: navigateToScene ?? { _, _ in }, - presentError: presentError ?? { _ in } - ) + return ungrouped + } + + private func rowViewModels(from results: NotificationsResultType?) throws -> [NotificationRowViewModel] { + guard let authenticationBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { throw APIService.APIError.explicit(.authenticationMissing) } + + if let ungrouped = results as? [Mastodon.Entity.Notification] { + return NotificationRowViewModel.viewModelsFromUngroupedNotifications( + ungrouped, myAccountID: authenticationBox.userID, + myAccountDomain: authenticationBox.domain, + navigateToScene: navigateToScene ?? { _, _ in }, + presentError: presentError ?? { _ in } + ) + } else if let grouped = results as? Mastodon.Entity.GroupedNotificationsResults { + return NotificationRowViewModel + .viewModelsFromGroupedNotificationResults( + grouped, + myAccountID: authenticationBox.userID, + myAccountDomain: authenticationBox.domain, + navigateToScene: navigateToScene ?? { _, _ in }, + presentError: presentError ?? { _ in } + ) + } else { + if results == nil { + return [] + } else { + assertionFailure("unexpected results type") + return [] + } + } } } diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift index 678ae98bb..14398d5f0 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift @@ -320,7 +320,7 @@ private class NotificationListViewModel: ObservableObject { updatedItems = self.notificationPolicyBannerRow + updatedItems self.notificationItems = updatedItems } - feedLoader.loadMore(olderThan: nil, newerThan: nil) + feedLoader.doFirstLoad() } public func refreshFeedFromTop() async { diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift new file mode 100644 index 000000000..2dcdd139a --- /dev/null +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift @@ -0,0 +1,266 @@ +// Copyright © 2025 Mastodon gGmbH. All rights reserved. + +import Boutique +import MastodonSDK + +@MainActor +protocol NotificationsCacheManager { + associatedtype T: NotificationsResultType + + var currentResults: T? { get } + var currentMarker: Mastodon.Entity.Marker? { get } + var mostRecentlyFetchedResults: T? { get } + func updateByInserting(newlyFetched: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) + func updateToNewerMarker(_ newMarker: Mastodon.Entity.Marker) + func commitToCache(forUserAcct userAcct: String) async +} + +protocol NotificationsResultType {} +extension Mastodon.Entity.GroupedNotificationsResults: NotificationsResultType {} +extension Array: NotificationsResultType {} + +@MainActor +class UngroupedNotificationCacheManager: NotificationsCacheManager { + typealias T = [Mastodon.Entity.Notification] + private let cachedNotifications: Store + + private var staleResults: T? + private var staleMarker: Mastodon.Entity.Marker? + + internal var mostRecentlyFetchedResults: T? + private var mostRecentlyFetchedMarker: Mastodon.Entity.Marker? + + init(feedKind: MastodonFeedKind, userAcct: String) { + self.cachedNotifications = Store.ungroupedNotificationStore(forKind: feedKind, forUserAcct: userAcct) + self.staleResults = cachedNotifications.items + switch feedKind { + case .notificationsAll, .notificationsMentionsOnly: + self.staleMarker = LastReadMarkerCache().getCachedMarker(forUserAcct: userAcct) + case .notificationsWithAccount: + self.staleMarker = nil + } + self.mostRecentlyFetchedResults = nil + self.mostRecentlyFetchedMarker = nil + } + + var currentResults: T? { + return mostRecentlyFetchedResults ?? staleResults + } + + var currentMarker: Mastodon.Entity.Marker? { + return mostRecentlyFetchedMarker ?? staleMarker ?? nil + } + + func updateByInserting(newlyFetched: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) { + + guard let newlyFetched = newlyFetched as? [Mastodon.Entity.Notification] else { + assertionFailure("unexpected type cannot be processed") + return + } + + var updatedMostRecentChunk: [Mastodon.Entity.Notification] + + if let previouslyFetched = mostRecentlyFetchedResults { + switch insertionPoint { + case .start: + updatedMostRecentChunk = (newlyFetched + previouslyFetched) + case .end: + updatedMostRecentChunk = (previouslyFetched + newlyFetched).removingDuplicates() + case .replace: + updatedMostRecentChunk = newlyFetched.removingDuplicates() + } + } else { + updatedMostRecentChunk = newlyFetched + } + if let staleResults, let combined = combineListsIfOverlapping(olderFeed: staleResults, newerFeed: updatedMostRecentChunk) { + mostRecentlyFetchedResults = Array(combined) + self.staleResults = nil + } else { + mostRecentlyFetchedResults = updatedMostRecentChunk + } + } + + + func updateToNewerMarker(_ newMarker: Mastodon.Entity.Marker) { + mostRecentlyFetchedMarker = newMarker + } + + func commitToCache(forUserAcct userAcct: String) async { + if let mostRecentlyFetchedMarker { + LastReadMarkerCache().setCachedMarker(mostRecentlyFetchedMarker, forUserAcct: userAcct) + } + if let mostRecentlyFetchedResults { + try? await cachedNotifications + .removeAll() + .insert(mostRecentlyFetchedResults) + .run() + } else { + try? await cachedNotifications.removeAll() + } + } +} + +@MainActor +class GroupedNotificationCacheManager: NotificationsCacheManager { + typealias T = Mastodon.Entity.GroupedNotificationsResults + + private var staleResults: T? + private var staleMarker: Mastodon.Entity.Marker? + + internal var mostRecentlyFetchedResults: T? + private var mostRecentlyFetchedMarker: Mastodon.Entity.Marker? + + private let notificationGroupStore: Store + private let fullAccountStore: Store + private let partialAccountStore: Store + private let statusStore: Store + + init(feedKind: MastodonFeedKind, userAcct: String) { + notificationGroupStore = Store.notificationGroupStore(forKind: feedKind, forUserAcct: userAcct) + fullAccountStore = Store.notificationRelevantFullAccountStore(forKind: feedKind, forUserAcct: userAcct) + partialAccountStore = Store.notificationRelevantPartialAccountStore(forKind: feedKind, forUserAcct: userAcct) + statusStore = Store.notificationRelevantStatusStore(forKind: feedKind, forUserAcct: userAcct) + + staleResults = Mastodon.Entity.GroupedNotificationsResults(notificationGroups: notificationGroupStore.items, fullAccounts: fullAccountStore.items, partialAccounts: partialAccountStore.items, statuses: statusStore.items) + + switch feedKind { + case .notificationsAll, .notificationsMentionsOnly: + staleMarker = LastReadMarkerCache().getCachedMarker(forUserAcct: userAcct) + case .notificationsWithAccount: + staleMarker = nil + } + } + + func updateByInserting(newlyFetched: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) { + + guard let newlyFetched = newlyFetched as? Mastodon.Entity.GroupedNotificationsResults else { + assertionFailure("unexpected type cannot be processed") + return + } + + let updatedNewerChunk: [Mastodon.Entity.NotificationGroup] + let includePreviouslyFetched: Bool + if let previouslyFetched = mostRecentlyFetchedResults { + switch insertionPoint { + case .start: + includePreviouslyFetched = true + updatedNewerChunk = newlyFetched.notificationGroups + previouslyFetched.notificationGroups + case .end: + includePreviouslyFetched = true + updatedNewerChunk = previouslyFetched.notificationGroups + newlyFetched.notificationGroups + case .replace: + includePreviouslyFetched = false + updatedNewerChunk = newlyFetched.notificationGroups + } + } else { + includePreviouslyFetched = false + updatedNewerChunk = newlyFetched.notificationGroups + } + let dedupedNewChunk = updatedNewerChunk.removingDuplicates() + + let updatedNewerAccounts: [Mastodon.Entity.Account] + let updatedNewerPartialAccounts: [Mastodon.Entity.PartialAccountWithAvatar]? + let updatedNewerStatuses: [Mastodon.Entity.Status] + if includePreviouslyFetched, let previouslyFetched = mostRecentlyFetchedResults { + updatedNewerAccounts = (newlyFetched.accounts + previouslyFetched.accounts).removingDuplicates() + updatedNewerPartialAccounts = ((newlyFetched.partialAccounts ?? []) + (previouslyFetched.partialAccounts ?? [])).removingDuplicates() + updatedNewerStatuses = (newlyFetched.statuses + previouslyFetched.statuses).removingDuplicates() + } else { + updatedNewerAccounts = newlyFetched.accounts + updatedNewerPartialAccounts = newlyFetched.partialAccounts + updatedNewerStatuses = newlyFetched.statuses + } + + if let staleResults, let combinedGroups = combineListsIfOverlapping(olderFeed: staleResults.notificationGroups, newerFeed: dedupedNewChunk) { + let accountsMap = (staleResults.accounts + updatedNewerAccounts).reduce(into: [ String : Mastodon.Entity.Account ]()) { partialResult, account in + partialResult[account.id] = account + } + let partialAccountsMap = ((staleResults.partialAccounts ?? []) + (updatedNewerPartialAccounts ?? [])).reduce(into: [ String : Mastodon.Entity.PartialAccountWithAvatar ]()) { partialResult, account in + partialResult[account.id] = account + } + let statusesMap = (staleResults.statuses + updatedNewerStatuses).reduce(into: [ String : Mastodon.Entity.Status ]()) { partialResult, status in + partialResult[status.id] = status + } + + var allRelevantAccountIds = Set() + for group in combinedGroups { + for accountID in group.sampleAccountIDs { + allRelevantAccountIds.insert(accountID) + } + } + let accounts = allRelevantAccountIds.compactMap { accountsMap[$0] } + let partialAccounts = allRelevantAccountIds.compactMap { partialAccountsMap[$0] } + let statuses = combinedGroups.compactMap { group -> Mastodon.Entity.Status? in + guard let statusID = group.statusID else { return nil } + return statusesMap[statusID] + } + + mostRecentlyFetchedResults = Mastodon.Entity.GroupedNotificationsResults(notificationGroups: Array(combinedGroups), fullAccounts: accounts, partialAccounts: partialAccounts, statuses: statuses) + self.staleResults = nil + } else { + mostRecentlyFetchedResults = Mastodon.Entity.GroupedNotificationsResults(notificationGroups: dedupedNewChunk, fullAccounts: updatedNewerAccounts.removingDuplicates(), partialAccounts: updatedNewerPartialAccounts?.removingDuplicates(), statuses: updatedNewerStatuses.removingDuplicates()) + } + } + + func updateToNewerMarker(_ newMarker: MastodonSDK.Mastodon.Entity.Marker) { + mostRecentlyFetchedMarker = newMarker + } + + var currentResults: T? { + return mostRecentlyFetchedResults ?? staleResults + } + + var currentMarker: Mastodon.Entity.Marker? { + return mostRecentlyFetchedMarker ?? staleMarker + } + + func commitToCache(forUserAcct userAcct: String) async { + if let mostRecentlyFetchedResults { + 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() + } + } +} + +fileprivate func combineListsIfOverlapping(olderFeed: [T], newerFeed: [T]) -> [T]? { + // if the last item in the new feed overlaps with something in the older feed, they can be combined + guard let oldestNewItem = newerFeed.last else { return olderFeed } + let overlapIndex = olderFeed.firstIndex { item in + oldestNewItem.overlaps(withOlder: item) + } + guard let overlapIndex else { return nil } + let suffixStart = overlapIndex + 1 + let olderChunk = (olderFeed.count > suffixStart) ? olderFeed.suffix(from: suffixStart) : [] + return newerFeed + olderChunk +} + +protocol Overlappable { + func overlaps(withOlder olderItem: Self) -> Bool +} + +extension Mastodon.Entity.Notification: Overlappable { + func overlaps(withOlder olderItem: Mastodon.Entity.Notification) -> Bool { + return self.id == olderItem.id + } +} + +extension Mastodon.Entity.NotificationGroup: Overlappable { + func overlaps(withOlder olderItem: Mastodon.Entity.NotificationGroup) -> Bool { + return self.id == olderItem.id + } +} + diff --git a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift index 1cb05deb4..02d9a6b3c 100644 --- a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift +++ b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift @@ -42,6 +42,16 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { return version?.majorServerVersion(greaterThanOrEquals: 4) ?? false // following Tags is support beginning with Mastodon v4.0.0 } + public var canGroupNotifications: Bool { + switch self { + case let .v1(instance): + return false + case let .v2(instance, _): + guard let apiVersion = instance.apiVersions?["mastodon"] else { return false } + return apiVersion >= 2 + } + } + public var charactersReservedPerURL: Int { switch self { case let .v1(instance): diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift index 9923f73ac..aa0b80dbd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+InstanceV2.swift @@ -16,6 +16,7 @@ extension Mastodon.Entity.V2 { public let description: String public let shortDescription: String? public let version: String? + public let apiVersions: [String : Int]? public let languages: [String]? // (ISO 639 Part 1-5 language codes) public let registrations: Mastodon.Entity.V2.Instance.Registrations? public let approvalRequired: Bool? @@ -37,6 +38,7 @@ extension Mastodon.Entity.V2 { self.shortDescription = nil self.contact = nil self.version = nil + self.apiVersions = nil self.languages = nil self.registrations = nil self.approvalRequired = approvalRequired @@ -54,6 +56,7 @@ extension Mastodon.Entity.V2 { case description case shortDescription = "short_description" case version + case apiVersions = "api_versions" case languages case registrations case approvalRequired = "approval_required" diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift index e4dcaf7ee..ba0986861 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift @@ -17,6 +17,8 @@ extension Mastodon.Entity { /// # Reference /// [Document](https://docs.joinmastodon.org/entities/marker/) public struct Marker: Codable { + public static let storageKey = "last_read_marker" + // Base public let home: Position public let notifications: Position diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 37aa2ed21..9759af909 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -94,6 +94,13 @@ extension Mastodon.Entity { case statuses case notificationGroups = "notification_groups" } + + public init(notificationGroups: [Mastodon.Entity.NotificationGroup], fullAccounts: [Mastodon.Entity.Account], partialAccounts: [Mastodon.Entity.PartialAccountWithAvatar]?, statuses: [Mastodon.Entity.Status]) { + self.notificationGroups = notificationGroups + self.accounts = fullAccounts + self.partialAccounts = partialAccounts + self.statuses = statuses + } } public struct PartialAccountWithAvatar: Codable, Sendable @@ -169,6 +176,13 @@ extension Mastodon.Entity { } } +extension Mastodon.Entity.PartialAccountWithAvatar: Hashable { + public func hash(into hasher: inout Hasher) { + // The URL seems to be the only thing that doesn't change across instances. + hasher.combine(url) + } +} + extension Mastodon.Entity { public struct AccountWarning: Codable, Sendable { public typealias ID = String