diff --git a/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift b/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift index 3221633a9..71a390749 100644 --- a/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift +++ b/Mastodon/In Progress New Layout and Datamodel/BoutiqueStores.swift @@ -2,6 +2,7 @@ import MastodonSDK import Boutique +import MastodonCore extension MastodonFeedKind { var storageTag: String? { @@ -16,69 +17,120 @@ extension MastodonFeedKind { } } +fileprivate let storageFileNameComponentSeparator = "_" + +fileprivate func storeFilenameFromBasename(_ basename: String, kind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> String { + assert(kind.storageTag != nil, "ATTEMPTING TO CACHE A FEED TYPE THAT SHOULD NOT BE CACHED") + let components = [userIdentifier.globallyUniqueUserIdentifier, + basename, + (kind.storageTag ?? "UNEXPECTED")] + return components.joined(separator: storageFileNameComponentSeparator) +} + 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") + static func ungroupedNotificationStore(forKind feedKind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> Store { return Store( - storage: SQLiteStorageEngine.default(appendingPath: "ungrouped_notification_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + storage: SQLiteStorageEngine.default(appendingPath: storeFilenameFromBasename("ungrouped_notification_store", kind: feedKind, forUser: userIdentifier)), 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") + static func notificationGroupStore(forKind feedKind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> Store { return Store( - storage: SQLiteStorageEngine.default(appendingPath: "notification_group_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + storage: SQLiteStorageEngine.default(appendingPath: storeFilenameFromBasename("notification_group_store", kind: feedKind, forUser: userIdentifier)), 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") + static func notificationRelevantFullAccountStore(forKind feedKind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> Store { return Store( - storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_full_account_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + storage: SQLiteStorageEngine.default(appendingPath: storeFilenameFromBasename("notification_relevant_full_account_store", kind: feedKind, forUser: userIdentifier)), 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") + static func notificationRelevantPartialAccountStore(forKind feedKind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> Store { return Store( - storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_partial_account_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + storage: SQLiteStorageEngine.default(appendingPath: storeFilenameFromBasename("notification_relevant_partial_account_store", kind: feedKind, forUser: userIdentifier)), 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") + static func notificationRelevantStatusStore(forKind feedKind: MastodonFeedKind, forUser userIdentifier: MastodonUserIdentifier) -> Store { return Store( - storage: SQLiteStorageEngine.default(appendingPath: "notification_relevant_status_store_" + (tag ?? "UNEXPECTED") + userAcct), cacheIdentifier: \.id) + storage: SQLiteStorageEngine.default(appendingPath: storeFilenameFromBasename("notification_relevant_status_store", kind: feedKind, forUser: userIdentifier)), 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) +extension Store where Item == LastReadMarkers { + static func lastReadMarkersStore() -> Store { + return Store( + storage: SQLiteStorageEngine.default(appendingPath: "last_read_markers_store"), cacheIdentifier: \.id) } } + +struct LastReadMarkers: Identifiable, Codable { + enum MarkerPosition: Codable { + case local(lastReadID: String) + case fromServer(Mastodon.Entity.Marker.Position) + + var lastReadID: String { + switch self { + case .local(let lastReadID): + return lastReadID + case .fromServer(let position): + return position.lastReadID + } + } + } + + let userGUID: String + let homeTimelineLastRead: MarkerPosition? + let notificationsLastRead: MarkerPosition? + let mentionsLastRead: MarkerPosition? + + var id: String { + return userGUID + } + + init(userGUID: String, home: MarkerPosition?, notifications: MarkerPosition?, mentions: MarkerPosition?) { + self.userGUID = userGUID + self.homeTimelineLastRead = home + self.notificationsLastRead = notifications + if let notifications, let mentions { + if mentions.lastReadID > notifications.lastReadID { + self.mentionsLastRead = mentions + } else { + self.mentionsLastRead = nil + } + } else { + self.mentionsLastRead = mentions + } + } + + func lastRead(forKind kind: MastodonFeedKind) -> MarkerPosition? { + switch kind { + case .notificationsAll: + return notificationsLastRead + case .notificationsMentionsOnly: + return mentionsLastRead ?? notificationsLastRead + case .notificationsWithAccount: + return nil + } + } + + func bySettingLastRead(_ newPosition: MarkerPosition, forKind kind: MastodonFeedKind) -> LastReadMarkers { + if let previous = lastRead(forKind: kind) { + guard previous.lastReadID < newPosition.lastReadID else { return self } + } + switch kind { + case .notificationsAll: + return LastReadMarkers(userGUID: userGUID, home: homeTimelineLastRead, notifications: newPosition, mentions: mentionsLastRead) + case .notificationsMentionsOnly: + return LastReadMarkers(userGUID: userGUID, home: homeTimelineLastRead, notifications: notificationsLastRead, mentions: newPosition) + case .notificationsWithAccount: + return self + } + } +} + diff --git a/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift b/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift index d66cc9a84..a394f6cfc 100644 --- a/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift +++ b/Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift @@ -47,11 +47,16 @@ final public class GroupedNotificationFeedLoader { @Published private(set) var records: FeedLoadResult = FeedLoadResult( allRecords: [], canLoadOlder: true) + var lastReadMarker: LastReadMarkers.MarkerPosition? { + return cacheManager?.currentLastReadMarker + } private var isFetching: Bool = false private let useGroupedNotificationsApi: Bool - private let cacheManager: any NotificationsCacheManager + private let cacheManager: (any NotificationsCacheManager)? + + private let user: MastodonUserIdentifier? private let kind: MastodonFeedKind private let navigateToScene: ((SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void)? @@ -59,18 +64,18 @@ final public class GroupedNotificationFeedLoader { private var activeFilterBoxSubscription: AnyCancellable? - init( - kind: MastodonFeedKind, + init(kind: MastodonFeedKind, navigateToScene: ( (SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void )?, presentError: ((Error) -> Void)? ) { + self.user = AuthenticationServiceProvider.shared.currentActiveUser.value?.authentication.userIdentifier() 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 { @@ -82,10 +87,15 @@ final public class GroupedNotificationFeedLoader { useGrouped = false } self.useGroupedNotificationsApi = useGrouped - if useGrouped { - self.cacheManager = GroupedNotificationCacheManager(feedKind: kind, userAcct: currentUser) + if let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value { + let currentUserIdentifier = MastodonUserIdentifier(authenticationBox: authBox) + if useGrouped { + self.cacheManager = GroupedNotificationCacheManager(feedKind: kind, userIdentifier: currentUserIdentifier) + } else { + self.cacheManager = UngroupedNotificationCacheManager(feedKind: kind, userIdentifier: currentUserIdentifier) + } } else { - self.cacheManager = UngroupedNotificationCacheManager(feedKind: kind, userAcct: currentUser) + self.cacheManager = nil } activeFilterBoxSubscription = StatusFilterService.shared @@ -107,12 +117,26 @@ final public class GroupedNotificationFeedLoader { Task { do { try await loadCached() + } catch { + } + do { + if let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value { + let markers = try await APIService.shared.lastReadMarkers(authenticationBox: authBox) + cacheManager?.didFetchMarkers(markers) + } + } catch { + } + do { await asyncLoadMore(olderThan: nil, newerThan: records.allRecords.first?.newestID) } catch { presentError?(error) } } } + + public func commitToCache() async { + await cacheManager?.commitToCache() + } private func replaceRecordsAfterFiltering(_ unfiltered: [NotificationRowViewModel], canLoadOlder: Bool? = nil) async { let filtered: [NotificationRowViewModel] @@ -169,7 +193,7 @@ final public class GroupedNotificationFeedLoader { } private func loadCached() async throws { - guard !isFetching else { return } + guard !isFetching, let cacheManager else { return } isFetching = true defer { isFetching = false @@ -198,6 +222,7 @@ final public class GroupedNotificationFeedLoader { extension GroupedNotificationFeedLoader { private func updateAfterInserting(newlyFetchedResults: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) async { + guard let cacheManager else { assertionFailure(); return } do { cacheManager.updateByInserting(newlyFetched: newlyFetchedResults, at: insertionPoint) let unfiltered = try rowViewModels(from: cacheManager.currentResults) @@ -315,6 +340,20 @@ extension GroupedNotificationFeedLoader { } } +extension GroupedNotificationFeedLoader { + public func markAsRead(_ identifier: Mastodon.Entity.NotificationGroup.ID) { + cacheManager?.updateToNewerMarker(.local(lastReadID: identifier)) + } + + public func isUnread(_ identifier: Mastodon.Entity.NotificationGroup.ID) -> Bool { + if let lastRead = cacheManager?.currentLastReadMarker?.lastReadID { + return identifier > lastRead + } else { + return true + } + } +} + extension NotificationRowViewModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(identifier) diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift index 9108c7907..154b564c6 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift @@ -119,42 +119,37 @@ struct NotificationListView: View { .fixedSize() Spacer() } - - List { - ForEach(viewModel.notificationItems) { item in - rowView(item) - .onAppear { - switch item { - case .groupedNotification(let viewModel): - viewModel.prepareForDisplay() - case .bottomLoader: - loadMore() - default: - break + ScrollViewReader { proxy in + List { + ForEach(viewModel.notificationItems, id: \.self) { item in // without explicit id, scrollTo(:) does not work + let isUnread = viewModel.isUnread(item) + rowView(item, isUnread: isUnread ?? false) + .onAppear { + didAppear(item) } - } - .onTapGesture { - didTap(item: item) - } + .onDisappear { + didDisappear(item, wasUnread: isUnread ?? false) + } + .onTapGesture { + didTap(item: item) + } + } + } + .listStyle(.plain) + .refreshable { + await viewModel.refreshFeedFromTop() + } + .onAppear() { + viewDidAppear() + } + .onDisappear() { + viewDidDisappear() } } - .listStyle(.plain) - .refreshable { - await viewModel.refreshFeedFromTop() - } - } - .onAppear() { - NotificationService.shared.clearNotificationCountForActiveUser() - Task { - await viewModel.refreshFeedFromTop() - } - } - .onDisappear() { - NotificationService.shared.clearNotificationCountForActiveUser() } } - @ViewBuilder func rowView(_ notificationListItem: NotificationListItem) + @ViewBuilder func rowView(_ notificationListItem: NotificationListItem, isUnread: Bool) -> some View { switch notificationListItem { @@ -173,17 +168,60 @@ struct NotificationListView: View { case .notification: Text("obsolete item") case .groupedNotification(let viewModel): - // TODO: implement unread using Mastodon.Entity.Marker NotificationRowView(viewModel: viewModel) .padding(.vertical, 4) .listRowBackground( - Rectangle() - .fill(viewModel.usePrivateBackground ? Asset.Colors.accent.swiftUIColor : .clear) - .opacity(0.1) + backgroundView(isPrivate: viewModel.usePrivateBackground, isUnread: isUnread) ) } } + + + @ViewBuilder func backgroundView(isPrivate: Bool, isUnread: Bool) -> some View { + HStack(spacing: 0) { + Spacer().frame(width: 3) + if isUnread { + Rectangle() + .fill(Asset.Colors.accent.swiftUIColor) + .frame(width: 5) + } + Rectangle() + .fill(isPrivate ? Asset.Colors.accent.swiftUIColor : .clear) + .opacity(0.1) + } + } + + func didAppear(_ item: NotificationListItem) { + switch item { + case .groupedNotification(let viewModel): + viewModel.prepareForDisplay() + case .bottomLoader: + loadMore() + default: + break + } + } + func didDisappear(_ item: NotificationListItem, wasUnread: Bool) { + if wasUnread { + viewModel.markAsRead(item) + } + } + + func viewDidAppear() { + NotificationService.shared.clearNotificationCountForActiveUser() + Task { + await viewModel.refreshFeedFromTop() + } + } + + func viewDidDisappear() { + NotificationService.shared.clearNotificationCountForActiveUser() + Task { + await viewModel.commitToCache() + } + } + func loadMore() { viewModel.loadOlder() } @@ -231,11 +269,31 @@ private class NotificationListViewModel: ObservableObject { @Published var displayedNotifications: ListType = .everything { didSet { - createNewFeedLoader() + Task { + await feedLoader.commitToCache() + createNewFeedLoader() + } } } @Published var notificationItems: [NotificationListItem] = [] - + + private var firstUnreadItem: NotificationListItem? { + guard let marker = feedLoader.lastReadMarker else { return nil } + let firstUnread = notificationItems.reversed().first { item in + switch item { + case .groupedNotification(let itemViewModel): + if let itemNewestID = itemViewModel.newestID { + return itemNewestID > marker.lastReadID + } else { + return false + } + default: + return false + } + } + return firstUnread + } + var filteredNotificationsViewModel = FilteredNotificationsRowView.ViewModel(policy: nil) private var notificationPolicyBannerRow: [NotificationListItem] { @@ -250,8 +308,7 @@ private class NotificationListViewModel: ObservableObject { } private var feedSubscription: AnyCancellable? - private var feedLoader = GroupedNotificationFeedLoader( - kind: .notificationsAll, navigateToScene: { _, _ in }, + private var feedLoader = GroupedNotificationFeedLoader(kind: .notificationsAll, navigateToScene: { _, _ in }, presentError: { _ in }) fileprivate var navigateToScene: @@ -306,6 +363,36 @@ private class NotificationListViewModel: ObservableObject { await feedLoader.asyncLoadMore(olderThan: nil, newerThan: nil) } } + + func isUnread(_ item: NotificationListItem) -> Bool? { + switch item { + case .bottomLoader, .filteredNotificationsInfo: + return nil + case .groupedNotification(let viewModel): + if let id = viewModel.newestID { + return feedLoader.isUnread(id) + } else { + return false + } + case .notification: + assert(false) + return nil + } + } + + func markAsRead(_ item: NotificationListItem) { + switch item { + case .bottomLoader, .filteredNotificationsInfo: + break + case .groupedNotification(let viewModel): + if let id = viewModel.newestID { + feedLoader.markAsRead(id) + } + case .notification: + assert(false) + break + } + } private func createNewFeedLoader() { fetchFilteredNotificationsPolicy() @@ -339,4 +426,8 @@ private class NotificationListViewModel: ObservableObject { await feedLoader.asyncLoadMore(olderThan: oldestKnown, newerThan: nil) } } + + public func commitToCache() async { + await feedLoader.commitToCache() + } } diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift index 2dcdd139a..10f9f0030 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift @@ -2,17 +2,19 @@ import Boutique import MastodonSDK +import MastodonCore @MainActor protocol NotificationsCacheManager { associatedtype T: NotificationsResultType var currentResults: T? { get } - var currentMarker: Mastodon.Entity.Marker? { get } + var currentLastReadMarker: LastReadMarkers.MarkerPosition? { 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 + func didFetchMarkers(_ updatedMarkers: Mastodon.Entity.Marker) + func updateToNewerMarker(_ newMarker: LastReadMarkers.MarkerPosition) + func commitToCache() async } protocol NotificationsResultType {} @@ -22,33 +24,40 @@ extension Array: NotificationsResultType {} @MainActor class UngroupedNotificationCacheManager: NotificationsCacheManager { typealias T = [Mastodon.Entity.Notification] + private let userIdentifier: MastodonUserIdentifier + private let feedKind: MastodonFeedKind + private let lastReadMarkerStore: Store private let cachedNotifications: Store private var staleResults: T? - private var staleMarker: Mastodon.Entity.Marker? + private var staleMarkers: LastReadMarkers? internal var mostRecentlyFetchedResults: T? - private var mostRecentlyFetchedMarker: Mastodon.Entity.Marker? + private var mostRecentMarkers: LastReadMarkers? - init(feedKind: MastodonFeedKind, userAcct: String) { - self.cachedNotifications = Store.ungroupedNotificationStore(forKind: feedKind, forUserAcct: userAcct) + init(feedKind: MastodonFeedKind, userIdentifier: MastodonUserIdentifier) { + self.feedKind = feedKind + self.userIdentifier = userIdentifier + lastReadMarkerStore = Store.lastReadMarkersStore() + self.cachedNotifications = Store.ungroupedNotificationStore(forKind: feedKind, forUser: userIdentifier) self.staleResults = cachedNotifications.items switch feedKind { case .notificationsAll, .notificationsMentionsOnly: - self.staleMarker = LastReadMarkerCache().getCachedMarker(forUserAcct: userAcct) + self.staleMarkers = lastReadMarkerStore.items.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier }) case .notificationsWithAccount: - self.staleMarker = nil + self.staleMarkers = nil } self.mostRecentlyFetchedResults = nil - self.mostRecentlyFetchedMarker = nil + self.mostRecentMarkers = nil } var currentResults: T? { return mostRecentlyFetchedResults ?? staleResults } - var currentMarker: Mastodon.Entity.Marker? { - return mostRecentlyFetchedMarker ?? staleMarker ?? nil + var currentLastReadMarker: LastReadMarkers.MarkerPosition? { + guard let markers = mostRecentMarkers ?? staleMarkers else { return nil } + return markers.lastRead(forKind: feedKind) } func updateByInserting(newlyFetched: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) { @@ -79,15 +88,23 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager { mostRecentlyFetchedResults = updatedMostRecentChunk } } - - func updateToNewerMarker(_ newMarker: Mastodon.Entity.Marker) { - mostRecentlyFetchedMarker = newMarker + func didFetchMarkers(_ updatedMarkers: Mastodon.Entity.Marker) { + var updatable = mostRecentMarkers ?? staleMarkers ?? LastReadMarkers(userGUID: userIdentifier.globallyUniqueUserIdentifier, home: nil, notifications: nil, mentions: nil) + if let notifications = updatedMarkers.notifications { + updatable = updatable.bySettingLastRead(.fromServer(notifications), forKind: .notificationsAll) + } + mostRecentMarkers = updatable + } + + func updateToNewerMarker(_ newMarker: LastReadMarkers.MarkerPosition) { + let updatable = mostRecentMarkers ?? staleMarkers ?? LastReadMarkers(userGUID: userIdentifier.globallyUniqueUserIdentifier, home: nil, notifications: nil, mentions: nil) + mostRecentMarkers = updatable.bySettingLastRead(newMarker, forKind: feedKind) } - func commitToCache(forUserAcct userAcct: String) async { - if let mostRecentlyFetchedMarker { - LastReadMarkerCache().setCachedMarker(mostRecentlyFetchedMarker, forUserAcct: userAcct) + func commitToCache() async { + if let mostRecentMarkers { + try? await lastReadMarkerStore.insert(mostRecentMarkers) } if let mostRecentlyFetchedResults { try? await cachedNotifications @@ -104,30 +121,39 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager { class GroupedNotificationCacheManager: NotificationsCacheManager { typealias T = Mastodon.Entity.GroupedNotificationsResults + private let userIdentifier: MastodonUserIdentifier + private let feedKind: MastodonFeedKind + private var staleResults: T? - private var staleMarker: Mastodon.Entity.Marker? + private var staleMarkers: LastReadMarkers? internal var mostRecentlyFetchedResults: T? - private var mostRecentlyFetchedMarker: Mastodon.Entity.Marker? + private var mostRecentMarkers: LastReadMarkers? + private let lastReadMarkerStore: Store 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) + 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) 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) + staleMarkers = lastReadMarkerStore.items.first(where: { $0.userGUID == userIdentifier.globallyUniqueUserIdentifier }) case .notificationsWithAccount: - staleMarker = nil + staleMarkers = nil } } @@ -202,19 +228,37 @@ class GroupedNotificationCacheManager: NotificationsCacheManager { } } - func updateToNewerMarker(_ newMarker: MastodonSDK.Mastodon.Entity.Marker) { - mostRecentlyFetchedMarker = newMarker + func updateToNewerMarker(_ newMarker: LastReadMarkers.MarkerPosition) { + let updatable = mostRecentMarkers ?? staleMarkers ?? LastReadMarkers(userGUID: userIdentifier.globallyUniqueUserIdentifier, home: nil, notifications: nil, mentions: nil) + mostRecentMarkers = updatable.bySettingLastRead(newMarker, forKind: feedKind) + } + + func didFetchMarkers(_ updatedMarkers: Mastodon.Entity.Marker) { + var updatable = mostRecentMarkers ?? staleMarkers ?? LastReadMarkers(userGUID: userIdentifier.globallyUniqueUserIdentifier, home: nil, notifications: nil, mentions: nil) + if let notifications = updatedMarkers.notifications { + updatable = updatable.bySettingLastRead(.fromServer(notifications), forKind: .notificationsAll) + } + mostRecentMarkers = updatable } var currentResults: T? { return mostRecentlyFetchedResults ?? staleResults } - var currentMarker: Mastodon.Entity.Marker? { - return mostRecentlyFetchedMarker ?? staleMarker + var currentLastReadMarker: LastReadMarkers.MarkerPosition? { + guard let markers = mostRecentMarkers ?? staleMarkers else { + return nil + } + return markers.lastRead(forKind: feedKind) } - func commitToCache(forUserAcct userAcct: String) async { + func commitToCache() async { + if let mostRecentMarkers { + do { + try await lastReadMarkerStore.insert(mostRecentMarkers) + } catch { + } + } if let mostRecentlyFetchedResults { try? await notificationGroupStore .removeAll() diff --git a/Mastodon/Scene/Notification/NotificationListItem.swift b/Mastodon/Scene/Notification/NotificationListItem.swift index 148f7048b..47472060e 100644 --- a/Mastodon/Scene/Notification/NotificationListItem.swift +++ b/Mastodon/Scene/Notification/NotificationListItem.swift @@ -16,6 +16,15 @@ enum NotificationListItem { case notification(MastodonFeedItemIdentifier) // TODO: remove case groupedNotification(NotificationRowViewModel) case bottomLoader + + var rowViewModel: NotificationRowViewModel? { + switch self { + case .filteredNotificationsInfo, .notification, .bottomLoader: + return nil + case .groupedNotification(let model): + return model + } + } var fetchAnchor: MastodonFeedItemIdentifier? { switch self { diff --git a/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift b/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift index f72a93557..e6bb3c56a 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/UserIdentifier.swift @@ -31,4 +31,9 @@ public struct MastodonUserIdentifier: UserIdentifier { self.domain = domain self.userID = userID } + + public init(authenticationBox: MastodonAuthenticationBox) { + self.domain = authenticationBox.domain + self.userID = authenticationBox.userID + } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Marker.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Marker.swift new file mode 100644 index 000000000..5d861377a --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Marker.swift @@ -0,0 +1,22 @@ +// +// APIService+Marker.swift +// MastodonSDK +// +// Created by Shannon Hughes on 2/24/25. +// + +import MastodonSDK + +extension APIService { + + public func lastReadMarkers( + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Entity.Marker { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Marker.lastReadMarkers(domain: domain, session: session, authorization: authorization) + + return response + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Marker.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Marker.swift new file mode 100644 index 000000000..d5c261b9b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Marker.swift @@ -0,0 +1,51 @@ +// +// Mastodon+API+Marker.swift +// MastodonSDK +// +// Created by Shannon Hughes on 2/24/25. +// + +import Foundation + +extension Mastodon.API.Marker { + + static func markersEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("markers") + } + + /// Marker + /// + /// Save and restore your position in timelines. + /// + /// - Since: 3.0.0 + /// - Version: 3.0.0 + /// # Last Update + /// 2025/02/24 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/markers/) + /// - Headers: + /// - authorization: Provide this header with Bearer to gain authorized access to this API method. + /// - Parameters: + /// - timeline[]: Array of String. Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned. + /// - Returns: `Marker` + public static func lastReadMarkers( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) async throws -> Mastodon.Entity.Marker { + let url = markersEndpointURL(domain: domain) + let request = Mastodon.API.get(url: url, query: MarkerFetchQuery(), authorization: authorization) + let (data, response) = try await session.data(for: request) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Marker.self, from: data, response: response) + return value + } + + public struct MarkerFetchQuery: GetQuery { + var queryItems: [URLQueryItem]? { + return ["home", "notifications"].map { URLQueryItem(name: "timeline[]", value: $0) + } + } + } + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index a733a0705..92215dfe5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -113,6 +113,7 @@ extension Mastodon.API { public enum CustomEmojis { } public enum Favorites { } public enum Instance { } + public enum Marker { } public enum Media { } public enum OAuth { } public enum Onboarding { } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift index ba0986861..b6773fd83 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift @@ -20,8 +20,8 @@ extension Mastodon.Entity { public static let storageKey = "last_read_marker" // Base - public let home: Position - public let notifications: Position + public let home: Position? + public let notifications: Position? } }