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

Add unread indicator for notifications screen

Does not yet send updated last read info to server.

Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification
This commit is contained in:
shannon 2025-02-26 09:39:59 -05:00
parent 1946a9b884
commit 0eb60db7d5
10 changed files with 433 additions and 119 deletions

View File

@ -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<Mastodon.Entity.Notification> {
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<Mastodon.Entity.Notification> {
return Store<Mastodon.Entity.Notification>(
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<Mastodon.Entity.NotificationGroup> {
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<Mastodon.Entity.NotificationGroup> {
return Store<Mastodon.Entity.NotificationGroup>(
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<Mastodon.Entity.Account> {
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<Mastodon.Entity.Account> {
return Store<Mastodon.Entity.Account>(
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<Mastodon.Entity.PartialAccountWithAvatar> {
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<Mastodon.Entity.PartialAccountWithAvatar> {
return Store<Mastodon.Entity.PartialAccountWithAvatar>(
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<Mastodon.Entity.Status> {
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<Mastodon.Entity.Status> {
return Store<Mastodon.Entity.Status>(
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<LastReadMarkers> {
return Store<LastReadMarkers>(
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
}
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -2,17 +2,19 @@
import Boutique
import MastodonSDK
import MastodonCore
@MainActor
protocol NotificationsCacheManager<T> {
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<Mastodon.Entity.Notification>: NotificationsResultType {}
@MainActor
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 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<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, 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()

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 <user_token> 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)
}
}
}
}

View File

@ -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 { }

View File

@ -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?
}
}