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:
parent
1946a9b884
commit
0eb60db7d5
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 { }
|
||||
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user