2
2
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:
shannon 2025-02-21 13:40:34 -05:00
parent c55dbf0f05
commit 7ea113fa7f
9 changed files with 519 additions and 84 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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