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