From 100937187c5920a05a42b37ea80f8731eedc0b09 Mon Sep 17 00:00:00 2001 From: shannon Date: Thu, 9 Jan 2025 08:43:08 -0500 Subject: [PATCH] Data model changes to enable grouped notifications Work in progress. This will run but will not show any notifications. Contributes to IOS-355 Contributes to IOS-253 --- .../Status/MastodonItemIdentifier.swift | 1 + .../NotificationTableViewCell+ViewModel.swift | 23 +- .../Scene/Notification/NotificationItem.swift | 6 +- .../Notification/NotificationSection.swift | 12 +- ...ineViewController+DataSourceProvider.swift | 34 +- .../NotificationTimelineViewController.swift | 47 ++- ...tificationTimelineViewModel+Diffable.swift | 19 +- ...ionTimelineViewModel+LoadOldestState.swift | 10 +- .../NotificationTimelineViewModel.swift | 70 +--- .../NotificationView+Configuration.swift | 9 + .../DataController/FeedDataController.swift | 5 +- .../DataController/MastodonFeedLoader.swift | 313 ++++++++++++++++++ .../MastodonCore/Model/FilterBox.swift | 7 + .../Service/API/APIService+Notification.swift | 44 ++- .../Notification/NotificationService.swift | 2 +- .../API/Mastodon+API+Notifications.swift | 103 +++++- .../Entity/Mastodon+Entity+Notification.swift | 92 ++++- .../Sources/MastodonSDK/MastodonFeed.swift | 106 ++++++ .../Sources/MastodonSDK/MastodonStatus.swift | 1 + 19 files changed, 762 insertions(+), 142 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift diff --git a/Mastodon/Diffable/Status/MastodonItemIdentifier.swift b/Mastodon/Diffable/Status/MastodonItemIdentifier.swift index 12552d6f37..e385c4310f 100644 --- a/Mastodon/Diffable/Status/MastodonItemIdentifier.swift +++ b/Mastodon/Diffable/Status/MastodonItemIdentifier.swift @@ -10,6 +10,7 @@ import CoreDataStack import MastodonUI import MastodonSDK +//@available(*, deprecated, message: "migrate to MastodonFeedItemIdentifier") enum MastodonItemIdentifier: Hashable { case feed(MastodonFeed) case feedLoader(feed: MastodonFeed) diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift index bda9765097..04c313a27c 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -11,25 +11,11 @@ import CoreDataStack import MastodonSDK import MastodonCore -extension NotificationTableViewCell { - final class ViewModel { - let value: Value - - init(value: Value) { - self.value = value - } - - enum Value { - case feed(MastodonFeed) - } - } -} - extension NotificationTableViewCell { func configure( tableView: UITableView, - viewModel: ViewModel, + notificationIdentifier: MastodonFeedItemIdentifier, delegate: NotificationTableViewCellDelegate?, authenticationBox: MastodonAuthenticationBox ) { @@ -40,11 +26,8 @@ extension NotificationTableViewCell { notificationView.statusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin notificationView.quoteStatusView.frame.size.width = tableView.frame.width - containerViewHorizontalMargin // the as same width as statusView } - - switch viewModel.value { - case .feed(let feed): - notificationView.configure(feed: feed, authenticationBox: authenticationBox) - } + + notificationView.configure(notificationItem: notificationIdentifier) self.delegate = delegate } diff --git a/Mastodon/Scene/Notification/NotificationItem.swift b/Mastodon/Scene/Notification/NotificationItem.swift index 72ca3d8454..01ca02d3e5 100644 --- a/Mastodon/Scene/Notification/NotificationItem.swift +++ b/Mastodon/Scene/Notification/NotificationItem.swift @@ -10,8 +10,8 @@ import Foundation import MastodonSDK enum NotificationItem: Hashable { - case filteredNotifications(policy: Mastodon.Entity.NotificationPolicy) - case feed(record: MastodonFeed) - case feedLoader(record: MastodonFeed) + case filteredNotificationsInfo(policy: Mastodon.Entity.NotificationPolicy) + case notification(MastodonFeedItemIdentifier) + case feedLoader(MastodonFeedItemIdentifier) case bottomLoader } diff --git a/Mastodon/Scene/Notification/NotificationSection.swift b/Mastodon/Scene/Notification/NotificationSection.swift index 42070501d7..e9b3399bf8 100644 --- a/Mastodon/Scene/Notification/NotificationSection.swift +++ b/Mastodon/Scene/Notification/NotificationSection.swift @@ -41,8 +41,8 @@ extension NotificationSection { return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in switch item { - case .feed(let feed): - if let notification = feed.notification, let accountWarning = notification.accountWarning { + case .notification(let notificationItem): + if let notification = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.Notification, let accountWarning = notification.accountWarning { let cell = tableView.dequeueReusableCell(withIdentifier: AccountWarningNotificationCell.reuseIdentifier, for: indexPath) as! AccountWarningNotificationCell cell.configure(with: accountWarning) return cell @@ -51,7 +51,7 @@ extension NotificationSection { configure( tableView: tableView, cell: cell, - viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)), + itemIdentifier: notificationItem, configuration: configuration ) return cell @@ -66,7 +66,7 @@ extension NotificationSection { cell.activityIndicatorView.startAnimating() return cell - case .filteredNotifications(let policy): + case .filteredNotificationsInfo(let policy): let cell = tableView.dequeueReusableCell(withIdentifier: NotificationFilteringBannerTableViewCell.reuseIdentifier, for: indexPath) as! NotificationFilteringBannerTableViewCell cell.configure(with: policy) @@ -81,7 +81,7 @@ extension NotificationSection { static func configure( tableView: UITableView, cell: NotificationTableViewCell, - viewModel: NotificationTableViewCell.ViewModel, + itemIdentifier: MastodonFeedItemIdentifier, configuration: Configuration ) { StatusSection.setupStatusPollDataSource( @@ -96,7 +96,7 @@ extension NotificationSection { cell.configure( tableView: tableView, - viewModel: viewModel, + notificationIdentifier: itemIdentifier, delegate: configuration.notificationTableViewCellDelegate, authenticationBox: configuration.authenticationBox ) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index e9d43f402c..62c390e92a 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -29,21 +29,27 @@ extension NotificationTimelineViewController: DataSourceProvider { } switch item { - case .feed(let feed): - let item: DataSourceItem? = { - guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } - - if let notification = feed.notification { - let mastodonNotification = MastodonNotification.fromEntity(notification, relationship: nil) - return .notification(record: mastodonNotification) - } else { - return nil - } - }() - return item - case .filteredNotifications(let policy): + case .notification(let notificationItem): + switch notificationItem { + case .notification, .notificationGroup: + let item: DataSourceItem? = { + // guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } + + if let notification = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.Notification { + let mastodonNotification = MastodonNotification.fromEntity(notification, relationship: nil) + return .notification(record: mastodonNotification) + } else { + return nil + } + }() + return item + case .status: + assertionFailure("unexpected item in notifications feed") + return nil + } + case .filteredNotificationsInfo(let policy): return DataSourceItem.notificationBanner(policy: policy) - case .bottomLoader, .feedLoader(_): + case .bottomLoader, .feedLoader: return nil } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index c8df139a23..3a7acd0355 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreDataStack import MastodonCore +import MastodonSDK import MastodonLocalization class NotificationTimelineViewController: UIViewController, MediaPreviewableViewController { @@ -264,7 +265,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { static func validNavigateableItem(_ item: NotificationItem) -> Bool { switch item { - case .feed: + case .notification: return true default: return false @@ -278,10 +279,43 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { Task { @MainActor in switch item { - case .feed(let record): - guard let notification = record.notification else { return } + case .notification(let notificationItem): + let status: Mastodon.Entity.Status? + let account: Mastodon.Entity.Account? + switch notificationItem { + case .notification(let id): + guard let notification = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.Notification else { + status = nil + account = nil + break + } + status = notification.status + account = notification.account + + case .notificationGroup(let id): + guard let notificationGroup = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.NotificationGroup else { + status = nil + account = nil + break + } + if let statusID = notificationGroup.statusID { + status = MastodonFeedItemCacheManager.shared.cachedItem(.status(id: statusID)) as? Mastodon.Entity.Status + } else { + status = nil + } + if notificationGroup.sampleAccountIDs.count == 1, let theOneAccountID = notificationGroup.sampleAccountIDs.first { + account = MastodonFeedItemCacheManager.shared.fullAccount(theOneAccountID) + } else { + account = nil + } + case .status: + assertionFailure("unexpected element in notifications feed") + status = nil + account = nil + break + } - if let status = notification.status { + if let status { let threadViewModel = ThreadViewModel( authenticationBox: self.viewModel.authenticationBox, optionalRoot: .root(context: .init(status: .fromEntity(status))) @@ -291,9 +325,8 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { from: self, transition: .show ) - } else { - - await DataSourceFacade.coordinateToProfileScene(provider: self, account: notification.account) + } else if let account { + await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) } default: break diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index 3e01103167..1398d29ae9 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -29,7 +29,7 @@ extension NotificationTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - dataController.$records + feedLoader.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self else { return } @@ -39,30 +39,17 @@ extension NotificationTimelineViewModel { let oldSnapshot = diffableDataSource.snapshot() var newSnapshot: NSDiffableDataSourceSnapshot = { let newItems = records.map { record in - NotificationItem.feed(record: record) + NotificationItem.notification(record) } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) if self.scope == .everything, let notificationPolicy = self.notificationPolicy, notificationPolicy.summary.pendingRequestsCount > 0 { - snapshot.appendItems([.filteredNotifications(policy: notificationPolicy)]) + snapshot.appendItems([.filteredNotificationsInfo(policy: notificationPolicy)]) } snapshot.appendItems(newItems.removingDuplicates(), toSection: .main) return snapshot }() - let anchors: [MastodonFeed] = records.filter { $0.hasMore == true } - let itemIdentifiers = newSnapshot.itemIdentifiers - for (index, item) in itemIdentifiers.enumerated() { - guard case let .feed(record) = item else { continue } - guard anchors.contains(where: { feed in feed.id == record.id }) else { continue } - let isLast = index + 1 == itemIdentifiers.count - if isLast { - newSnapshot.insertItems([.bottomLoader], afterItem: item) - } else { - newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) - } - } - let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers if !hasChanges { self.didLoadLatest.send() diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index fcb35093aa..fd3fc76b90 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -34,7 +34,7 @@ extension NotificationTimelineViewModel.LoadOldestState { class Initial: NotificationTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !viewModel.dataController.records.isEmpty else { return false } + guard !viewModel.feedLoader.records.isEmpty else { return false } return stateClass == Loading.self } } @@ -50,7 +50,7 @@ extension NotificationTimelineViewModel.LoadOldestState { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let lastFeedRecord = viewModel.dataController.records.last else { + guard let lastFeedRecord = viewModel.feedLoader.records.last else { stateMachine.enter(Fail.self) return } @@ -71,7 +71,7 @@ extension NotificationTimelineViewModel.LoadOldestState { } Task { - let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id + let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.id guard let maxID = _maxID else { self.enter(state: Fail.self) @@ -80,8 +80,8 @@ extension NotificationTimelineViewModel.LoadOldestState { do { let response = try await APIService.shared.notifications( - maxID: maxID, - accountID: accountID, + olderThan: maxID, + fromAccount: accountID, scope: scope, authenticationBox: viewModel.authenticationBox ) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index c003ba9d82..3e09890ec2 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -22,7 +22,7 @@ final class NotificationTimelineViewModel { let authenticationBox: MastodonAuthenticationBox let scope: Scope var notificationPolicy: Mastodon.Entity.NotificationPolicy? - let dataController: FeedDataController + let feedLoader: MastodonFeedLoader @Published var isLoadingLatest = false @Published var lastAutomaticFetchTimestamp: Date? @@ -52,45 +52,9 @@ final class NotificationTimelineViewModel { ) { self.authenticationBox = authenticationBox self.scope = scope - self.dataController = FeedDataController(authenticationBox: authenticationBox, kind: scope.feedKind) + let useGroupedNotifications = false + self.feedLoader = MastodonFeedLoader(authenticationBox: authenticationBox, kind: scope.feedKind, dedupePolicy: useGroupedNotifications ? .removeOldest : .omitNewest) self.notificationPolicy = notificationPolicy - - Task { - switch scope { - case .everything: - let initialRecords = (try? FileManager.default.cachedNotificationsAll(for: authenticationBox))?.map({ notification in - MastodonFeed.fromNotification(notification, relationship: nil, kind: .notificationAll) - }) ?? [] - await self.dataController.setRecordsAfterFiltering(initialRecords) - case .mentions: - let initialRecords = (try? FileManager.default.cachedNotificationsMentions(for: authenticationBox))?.map({ notification in - MastodonFeed.fromNotification(notification, relationship: nil, kind: .notificationMentions) - }) ?? [] - await self.dataController.setRecordsAfterFiltering(initialRecords) - case .fromAccount(_): - await self.dataController.setRecordsAfterFiltering([]) - } - } - - self.dataController.$records - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveValue: { feeds in - let items: [Mastodon.Entity.Notification] = feeds.compactMap { feed -> Mastodon.Entity.Notification? in - guard let status = feed.notification else { return nil } - return status - } - switch self.scope { - case .everything: - FileManager.default.cacheNotificationsAll(items: items, for: authenticationBox) - case .mentions: - FileManager.default.cacheNotificationsMentions(items: items, for: authenticationBox) - case .fromAccount(_): - //NOTE: we don't persist these - break - } - }) - .store(in: &disposeBag) NotificationCenter.default.addObserver(self, selector: #selector(Self.notificationFilteringChanged(_:)), name: .notificationFilteringChanged, object: nil) } @@ -126,14 +90,14 @@ extension NotificationTimelineViewModel { } } - var feedKind: MastodonFeed.Kind { + var feedKind: MastodonFeedKind { switch self { case .everything: - return .notificationAll + return .notificationsAll case .mentions: - return .notificationMentions + return .notificationsMentionsOnly case .fromAccount(let account): - return .notificationAccount(account.id) + return .notificationsWithAccount(account.id) } } } @@ -145,28 +109,12 @@ extension NotificationTimelineViewModel { func loadLatest() async { isLoadingLatest = true defer { isLoadingLatest = false } - - switch scope { - case .everything: - dataController.loadInitial(kind: .notificationAll) - case .mentions: - dataController.loadInitial(kind: .notificationMentions) - case .fromAccount(let account): - dataController.loadInitial(kind: .notificationAccount(account.id)) - } - + feedLoader.loadInitial(kind: scope.feedKind) didLoadLatest.send() } // load timeline gap func loadMore(item: NotificationItem) async { - switch scope { - case .everything: - dataController.loadNext(kind: .notificationAll) - case .mentions: - dataController.loadNext(kind: .notificationMentions) - case .fromAccount(let account): - dataController.loadNext(kind: .notificationAccount(account.id)) - } + feedLoader.loadNext(kind: scope.feedKind) } } diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift index 830ec65be3..656d8c49a2 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -34,6 +34,11 @@ extension NotificationView { } extension NotificationView { + + public func configure(notificationItem: MastodonFeedItemIdentifier) { + assertionFailure("not implemented") + } + public func configure(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) { configureAuthor(notification: notification, authenticationBox: authenticationBox) @@ -66,6 +71,10 @@ extension NotificationView { } } + + private func configureAuthor(notificationItem: MastodonItemIdentifier) { + assertionFailure("not implemented") + } private func configureAuthor(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) { let author = notification.account diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index a791e649f0..82c99f2ec2 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -4,6 +4,7 @@ import Combine import MastodonSDK import os.log +//@available(*, deprecated, message: "migrate to MastodonFeedLoader") @MainActor final public class FeedDataController { private let logger = Logger(subsystem: "FeedDataController", category: "Data") @@ -22,7 +23,7 @@ final public class FeedDataController { StatusFilterService.shared.$activeFilterBox .sink { filterBox in - if let filterBox { + if filterBox != nil { Task { [weak self] in guard let self else { return } await self.setRecordsAfterFiltering(self.records) @@ -256,7 +257,7 @@ private extension FeedDataController { private func getFeeds(with scope: APIService.MastodonNotificationScope?, accountID: String? = nil) async throws -> [MastodonFeed] { - let notifications = try await APIService.shared.notifications(maxID: nil, accountID: accountID, scope: scope, authenticationBox: authenticationBox).value + let notifications = try await APIService.shared.notifications(olderThan: nil, fromAccount: accountID, scope: scope, authenticationBox: authenticationBox).value let accounts = notifications.map { $0.account } let relationships = try await APIService.shared.relationship(forAccounts: accounts, authenticationBox: authenticationBox).value diff --git a/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift b/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift new file mode 100644 index 0000000000..e97a11dfdd --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift @@ -0,0 +1,313 @@ +// +// MastodonFeedLoader.swift +// MastodonSDK +// +// Created by Shannon Hughes on 1/8/25. +// + +import Foundation +import UIKit +import Combine +import MastodonSDK +import os.log + +@MainActor +final public class MastodonFeedLoader { + + public enum DeduplicationPolicy { + case omitNewest + case removeOldest + } + + private let logger = Logger(subsystem: "MastodonFeedLoader", category: "Data") + private static let entryNotFoundMessage = "Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)." + + @Published public private(set) var records: [MastodonFeedItemIdentifier] = [] + + private let authenticationBox: MastodonAuthenticationBox + private let kind: MastodonFeedKind + private let dedupePolicy: DeduplicationPolicy + + private var subscriptions = Set() + + public init(authenticationBox: MastodonAuthenticationBox, kind: MastodonFeedKind, dedupePolicy: DeduplicationPolicy = .omitNewest) { + self.authenticationBox = authenticationBox + self.kind = kind + self.dedupePolicy = dedupePolicy + + StatusFilterService.shared.$activeFilterBox + .sink { filterBox in + if filterBox != nil { + Task { [weak self] in + guard let self else { return } + await self.setRecordsAfterFiltering(self.records) + } + } + } + .store(in: &subscriptions) + } + + private func setRecordsAfterFiltering(_ newRecords: [MastodonFeedItemIdentifier]) async { + guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records = newRecords; return } + let filtered = await self.filter(newRecords, forFeed: kind, with: filterBox) + self.records = filtered.removingDuplicates() + } + + private func appendRecordsAfterFiltering(_ additionalRecords: [MastodonFeedItemIdentifier]) async { + guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records += additionalRecords; return } + let newRecords = await self.filter(additionalRecords, forFeed: kind, with: filterBox) + switch dedupePolicy { + case .omitNewest: + self.records = (self.records + newRecords).removingDuplicates() + case .removeOldest: + assertionFailure("not implemented") + self.records = (self.records + newRecords).removingDuplicates() + } + } + + public func loadInitial(kind: MastodonFeedKind) { + Task { + let unfilteredRecords = try await load(kind: kind) + await setRecordsAfterFiltering(unfilteredRecords) + } + } + + public func loadNext(kind: MastodonFeedKind) { + Task { + guard let lastId = records.last?.id else { + return loadInitial(kind: kind) + } + + let unfiltered = try await load(kind: kind, olderThan: lastId) + await self.appendRecordsAfterFiltering(unfiltered) + } + } + + private func filter(_ records: [MastodonFeedItemIdentifier], forFeed feedKind: MastodonFeedKind, with filterBox: Mastodon.Entity.FilterBox) async -> [MastodonFeedItemIdentifier] { + + let filteredRecords = records.filter { itemIdentifier in + guard let status = MastodonFeedItemCacheManager.shared.filterableStatus(associatedWith: itemIdentifier) else { return true } + let filterResult = filterBox.apply(to: status, in: feedKind.filterContext) + switch filterResult { + case .hide: + return false + default: + return true + } + } + return filteredRecords + } + + // TODO: all of these updates should happen the cached item, and then any cells referencing them should be reconfigured +// @MainActor +// public func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { +// switch intent { +// case .delete: +// delete(status) +// case .edit: +// updateEdited(status) +// case let .bookmark(isBookmarked): +// updateBookmarked(status, isBookmarked) +// case let .favorite(isFavorited): +// updateFavorited(status, isFavorited) +// case let .reblog(isReblogged): +// updateReblogged(status, isReblogged) +// case let .toggleSensitive(isVisible): +// updateSensitive(status, isVisible) +// case .pollVote: +// updateEdited(status) // technically the data changed so refresh it to reflect the new data +// } +// } + +// @MainActor +// private func delete(_ status: MastodonStatus) { +// records.removeAll { $0.id == status.id } +// } +// +// @MainActor +// private func updateEdited(_ status: MastodonStatus) { +// var newRecords = Array(records) +// guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { +// logger.warning("\(Self.entryNotFoundMessage)") +// return +// } +// let existingRecord = newRecords[index] +// let newStatus = status.inheritSensitivityToggled(from: existingRecord.status) +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// records = newRecords +// } +// +// @MainActor +// private func updateBookmarked(_ status: MastodonStatus, _ isBookmarked: Bool) { +// var newRecords = Array(records) +// guard let index = newRecords.firstIndex(where: { $0.id == status.id }) else { +// logger.warning("\(Self.entryNotFoundMessage)") +// return +// } +// let existingRecord = newRecords[index] +// let newStatus = status.inheritSensitivityToggled(from: existingRecord.status) +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// records = newRecords +// } +// +// @MainActor +// private func updateFavorited(_ status: MastodonStatus, _ isFavorited: Bool) { +// var newRecords = Array(records) +// if let index = newRecords.firstIndex(where: { $0.id == status.id }) { +// // Replace old status entity +// let existingRecord = newRecords[index] +// let newStatus = status.inheritSensitivityToggled(from: existingRecord.status).withOriginal(status: existingRecord.status?.originalStatus) +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// } else if let index = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }) { +// // Replace reblogged entity of old "parent" status +// let newStatus: MastodonStatus +// if let existingEntity = newRecords[index].status?.entity { +// newStatus = .fromEntity(existingEntity) +// newStatus.originalStatus = newRecords[index].status?.originalStatus +// newStatus.reblog = status +// } else { +// newStatus = status +// } +// newRecords[index] = .fromStatus(newStatus, kind: newRecords[index].kind) +// } else { +// logger.warning("\(Self.entryNotFoundMessage)") +// } +// records = newRecords +// } +// +// @MainActor +// private func updateReblogged(_ status: MastodonStatus, _ isReblogged: Bool) { +// var newRecords = Array(records) +// +// switch isReblogged { +// case true: +// let index: Int +// if let idx = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.reblog?.id }) { +// index = idx +// } else if let idx = newRecords.firstIndex(where: { $0.id == status.reblog?.id }) { +// index = idx +// } else { +// logger.warning("\(Self.entryNotFoundMessage)") +// return +// } +// let existingRecord = newRecords[index] +// newRecords[index] = .fromStatus(status.withOriginal(status: existingRecord.status), kind: existingRecord.kind) +// case false: +// let index: Int +// if let idx = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }) { +// index = idx +// } else if let idx = newRecords.firstIndex(where: { $0.status?.id == status.id }) { +// index = idx +// } else { +// logger.warning("\(Self.entryNotFoundMessage)") +// return +// } +// let existingRecord = newRecords[index] +// let newStatus = existingRecord.status?.originalStatus ?? status.inheritSensitivityToggled(from: existingRecord.status) +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// } +// records = newRecords +// } +// +// @MainActor +// private func updateSensitive(_ status: MastodonStatus, _ isVisible: Bool) { +// var newRecords = Array(records) +// if let index = newRecords.firstIndex(where: { $0.status?.reblog?.id == status.id }), let existingEntity = newRecords[index].status?.entity { +// let existingRecord = newRecords[index] +// let newStatus: MastodonStatus = .fromEntity(existingEntity) +// newStatus.reblog = status +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// } else if let index = newRecords.firstIndex(where: { $0.id == status.id }), let existingEntity = newRecords[index].status?.entity { +// let existingRecord = newRecords[index] +// let newStatus: MastodonStatus = .fromEntity(existingEntity) +// .inheritSensitivityToggled(from: status) +// newRecords[index] = .fromStatus(newStatus, kind: existingRecord.kind) +// } else { +// logger.warning("\(Self.entryNotFoundMessage)") +// return +// } +// records = newRecords +// } +} + +private extension MastodonFeedLoader { + + func load(kind: MastodonFeedKind, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] { + switch kind { + case .notificationsAll: + return try await loadNotifications(withScope: .everything, olderThan: maxID) + case .notificationsMentionsOnly: + return try await loadNotifications(withScope: .mentions, olderThan: maxID) + case .notificationsWithAccount(let accountID): + return try await loadNotifications(withAccountID: accountID, olderThan: maxID) + } + } + + private func loadNotifications(withScope scope: APIService.MastodonNotificationScope, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] { + let useGroupedNotifications = false + if useGroupedNotifications { + return try await _getGroupedNotifications(withScope: scope, olderThan: maxID) + } else { + return try await _getUngroupedNotifications(withScope: scope, olderThan: maxID) + } + } + + private func loadNotifications(withAccountID accountID: String, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] { + let useGroupedNotifications = false + if useGroupedNotifications { + return try await _getGroupedNotifications(accountID: accountID, olderThan: maxID) + } else { + return try await _getUngroupedNotifications(accountID: accountID, olderThan: maxID) + } + } + + private func _getUngroupedNotifications(withScope scope: APIService.MastodonNotificationScope? = nil, accountID: String? = nil, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] { + + assert(scope != nil || accountID != nil, "need a scope or an accountID") + + let notifications = try await APIService.shared.notifications(olderThan: maxID, fromAccount: accountID, scope: scope, authenticationBox: authenticationBox).value + + let accounts = notifications.map { $0.account } + let relationships = try await APIService.shared.relationship(forAccounts: accounts, authenticationBox: authenticationBox).value + for relationship in relationships { + MastodonFeedItemCacheManager.shared.addToCache(relationship) + } + + return notifications.map { + MastodonFeedItemIdentifier.notification(id: $0.id) + } + } + + private func _getGroupedNotifications(withScope scope: APIService.MastodonNotificationScope? = nil, accountID: String? = nil, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] { + + assert(scope != nil || accountID != nil, "need a scope or an accountID") + + let results = try await APIService.shared.groupedNotifications(olderThan: maxID, fromAccount: accountID, scope: scope, authenticationBox: authenticationBox).value + + for account in results.accounts { + MastodonFeedItemCacheManager.shared.addToCache(account) + } + if let partials = results.partialAccounts { + for partialAccount in partials { + MastodonFeedItemCacheManager.shared.addToCache(partialAccount) + } + } + for status in results.statuses { + MastodonFeedItemCacheManager.shared.addToCache(status) + } + + return results.notificationGroups.map { + MastodonFeedItemCacheManager.shared.addToCache($0) + return MastodonFeedItemIdentifier.notificationGroup(id: $0.id) + } + } +} + +extension MastodonFeedKind { + var filterContext: Mastodon.Entity.FilterContext { + switch self { + case .notificationsAll, .notificationsMentionsOnly, .notificationsWithAccount: + return .notifications + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Model/FilterBox.swift b/MastodonSDK/Sources/MastodonCore/Model/FilterBox.swift index 2a921f01a4..4c3cf428a3 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/FilterBox.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/FilterBox.swift @@ -62,6 +62,13 @@ public extension Mastodon.Entity { hideWholeWordMatch = _hideWholeWordMatch } + public func apply(to status: Mastodon.Entity.Status, in context: FilterContext) -> Mastodon.Entity.FilterResult { + let status = status.reblog ?? status + let defaultFilterResult = Mastodon.Entity.FilterResult.notFiltered + guard let content = status.content?.lowercased() else { return defaultFilterResult } + return apply(to: content, in: context) + } + public func apply(to status: MastodonStatus, in context: FilterContext) -> Mastodon.Entity.FilterResult { let status = status.reblog ?? status let defaultFilterResult = Mastodon.Entity.FilterResult.notFiltered diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift index 74cd25a723..83d12470b5 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift @@ -19,8 +19,8 @@ extension APIService { } public func notifications( - maxID: Mastodon.Entity.Status.ID?, - accountID: String? = nil, + olderThan maxID: Mastodon.Entity.Status.ID?, + fromAccount accountID: String? = nil, scope: MastodonNotificationScope?, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> { @@ -57,6 +57,46 @@ extension APIService { return response } + + public func groupedNotifications( + olderThan maxID: Mastodon.Entity.Status.ID?, + fromAccount accountID: String? = nil, + scope: MastodonNotificationScope?, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content { + let authorization = authenticationBox.userAuthorization + + let types: [Mastodon.Entity.NotificationType]? + let excludedTypes: [Mastodon.Entity.NotificationType]? + + switch scope { + case .everything: + types = [.follow, .followRequest, .mention, .reblog, .favourite, .poll, .status, .moderationWarning] + excludedTypes = nil + case .mentions: + types = [.mention] + excludedTypes = [.follow, .followRequest, .reblog, .favourite, .poll] + case nil: + types = nil + excludedTypes = nil + } + + let query = Mastodon.API.Notifications.GroupedQuery( + maxID: maxID, + types: types, + excludeTypes: excludedTypes, + accountID: accountID + ) + + let response = try await Mastodon.API.Notifications.getGroupedNotifications( + session: session, + domain: authenticationBox.domain, + query: query, + authorization: authorization + ).singleOutput() + + return response + } } extension APIService { diff --git a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift index 52eaf7f973..7d3becbbd6 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift @@ -172,7 +172,7 @@ extension NotificationService { guard let authenticationBox = try await authenticationBox(for: pushNotification) else { return } _ = try await APIService.shared.notifications( - maxID: nil, + olderThan: nil, scope: .everything, authenticationBox: authenticationBox ) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index 03e591d2c3..30e7204305 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -9,14 +9,51 @@ import Combine import Foundation extension Mastodon.API.Notifications { - internal static func notificationsEndpointURL(domain: String) -> URL { - Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") + internal static func notificationsEndpointURL(domain: String, grouped: Bool = false) -> URL { + if grouped { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") + } else { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") + } } internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) } + /// Get all grouped notifications + /// + /// - Since: 4.3.0 + /// - Version: 4.3.0 + /// # Last Update + /// 2025/01/8 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/grouped_notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `GroupedNotificationsQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getGroupedNotifications( + session: URLSession, + domain: String, + query: Mastodon.API.Notifications.GroupedQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: notificationsEndpointURL(domain: domain, grouped: true), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.GroupedNotificationsResults.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + /// Get all notifications /// /// - Since: 0.0.0 @@ -38,7 +75,7 @@ extension Mastodon.API.Notifications { authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: notificationsEndpointURL(domain: domain), + url: notificationsEndpointURL(domain: domain, grouped: false), query: query, authorization: authorization ) @@ -133,6 +170,66 @@ extension Mastodon.API.Notifications { return items } } + + public struct GroupedQuery: PagedQueryType, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let types: [Mastodon.Entity.NotificationType]? + public let excludeTypes: [Mastodon.Entity.NotificationType]? + public let accountID: String? + public let groupedTypes: [String]? + public let expandAccounts: Bool + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + types: [Mastodon.Entity.NotificationType]? = nil, + excludeTypes: [Mastodon.Entity.NotificationType]? = nil, + accountID: String? = nil, + groupedTypes: [String]? = ["favourite", "follow", "reblog"], + expandAccounts: Bool = false + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.types = types + self.excludeTypes = excludeTypes + self.accountID = accountID + self.groupedTypes = groupedTypes + self.expandAccounts = expandAccounts + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let types = types { + types.forEach { + items.append(URLQueryItem(name: "types[]", value: $0.rawValue)) + } + } + if let excludeTypes = excludeTypes { + excludeTypes.forEach { + items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue)) + } + } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + // TODO: implement groupedTypes +// if let groupedTypes { +// items.append(URLQueryItem(name: "grouped_types", value: groupedTypes)) +// } + items.append(URLQueryItem(name: "expand_accounts", value: expandAccounts ? "full" : "partial_avatars")) + guard !items.isEmpty else { return nil } + return items + } + } } //MARK: - Notification Policy diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 953b1fd8d0..9936a4f600 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -26,7 +26,7 @@ extension Mastodon.Entity { public let account: Account public let status: Status? public let report: Report? -// public let relationshipSeverenceEvent: RelationshipSeveranceEvent? + public let relationshipSeveranceEvent: RelationshipSeveranceEvent? public let accountWarning: AccountWarning? enum CodingKeys: String, CodingKey { @@ -38,6 +38,7 @@ extension Mastodon.Entity { case status case report case accountWarning = "moderation_warning" + case relationshipSeveranceEvent = "event" } } @@ -62,7 +63,7 @@ extension Mastodon.Entity { public let sampleAccountIDs: [String] // IDs of some of the accounts who most recently triggered notifications in this group. public let statusID: ID? public let report: Report? - // public let relationshipSeverenceEvent: RelationshipSeveranceEvent? + public let relationshipSeveranceEvent: RelationshipSeveranceEvent? public let accountWarning: AccountWarning? enum CodingKeys: String, CodingKey { @@ -77,6 +78,93 @@ extension Mastodon.Entity { case statusID = "status_id" case report = "report" case accountWarning = "moderation_warning" + case relationshipSeveranceEvent = "event" + } + } + + public struct GroupedNotificationsResults: Codable, Sendable { + public let accounts: [Mastodon.Entity.Account] + public let partialAccounts: [Mastodon.Entity.PartialAccountWithAvatar]? + public let statuses: [Mastodon.Entity.Status] + public let notificationGroups: [Mastodon.Entity.NotificationGroup] + + enum CodingKeys: String, CodingKey { + case accounts + case partialAccounts = "partial_accounts" + case statuses + case notificationGroups = "notification_groups" + } + } + + public struct PartialAccountWithAvatar: Codable, Sendable + { + public typealias ID = String + + public let id: ID + public let acct: String // The Webfinger account URI. Equal to username for local users, or username@domain for remote users. + public let url: String // location of this account's profile page + public let avatar: String // url + public let avatarStatic: String // url, non-animated + public let locked: Bool // account manually approves follow requests + public let bot: Bool // is this a bot account + + enum CodingKeys: String, CodingKey { + case id + case acct + case url + case avatar + case avatarStatic = "avatar_static" + case locked + case bot + } + } + + public enum RelationshipSeveranceEventType: RawRepresentable, Codable, Sendable { + case domainBlock + case userDomainBlock + case accountSuspension + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "domain_block": self = .domainBlock + case "user_domain_block": self = .userDomainBlock + case "account_suspension": self = .accountSuspension + default: self = ._other(rawValue) + } + } + public var rawValue: String { + switch self { + case .domainBlock: return "domain_block" + case .userDomainBlock: + return "user_domain_block" + case .accountSuspension: + return "account_suspension" + case ._other(let rawValue): + return rawValue + } + } + } + + public struct RelationshipSeveranceEvent: Codable, Sendable { + public typealias ID = String + + public let id: ID + public let type: RelationshipSeveranceEventType + public let purged: Bool // Whether the list of severed relationships is unavailable because the underlying issue has been purged. + public let targetName: String // Name of the target of the moderation/block event. This is either a domain name or a user handle, depending on the event type. + public let followersCount: Int // Number of followers that were removed as result of the event. + public let followingCount: Int // Number of accounts the user stopped following as result of the event. + public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case type + case purged + case targetName = "target_name" + case followersCount = "followers_count" + case followingCount = "following_count" + case createdAt = "created_at" } } } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index d6bda9f393..4898a3842d 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -3,6 +3,7 @@ import Foundation import CoreDataStack +//@available(*, deprecated, message: "migrate to MastodonFeedLoader and MastodonFeedItemIdentifier") public final class MastodonFeed { public enum Kind { @@ -110,3 +111,108 @@ extension MastodonFeed: Hashable { } } + + +public enum MastodonFeedItemIdentifier: Hashable, Identifiable, Equatable { + case status(id: String) + case notification(id: String) + case notificationGroup(id: String) + + public var id: String { + switch self { + case .status(let id): + return id + case .notification(let id): + return id + case .notificationGroup(let id): + return id + } + } +} + +public enum MastodonFeedKind { + case notificationsAll + case notificationsMentionsOnly + case notificationsWithAccount(String) +} + +public class MastodonFeedItemCacheManager { + private var statusCache = [ String : Mastodon.Entity.Status ]() + private var notificationsCache = [ String : Mastodon.Entity.Notification ]() + private var groupedNotificationsCache = [ String : Mastodon.Entity.NotificationGroup ]() + private var relationshipsCache = [ String : Mastodon.Entity.Relationship ]() + private var fullAccountsCache = [ String : Mastodon.Entity.Account ]() + private var partialAccountsCache = [ String : Mastodon.Entity.PartialAccountWithAvatar ]() + + private init(){} + public static let shared = MastodonFeedItemCacheManager() + + public func clear() { // TODO: call this when switching accounts + statusCache.removeAll() + notificationsCache.removeAll() + groupedNotificationsCache.removeAll() + relationshipsCache.removeAll() + } + + public func addToCache(_ item: Any) { + if let status = item as? Mastodon.Entity.Status { + statusCache[status.id] = status + } else if let notification = item as? Mastodon.Entity.Notification { + notificationsCache[notification.id] = notification + } else if let notificationGroup = item as? Mastodon.Entity.NotificationGroup { + groupedNotificationsCache[notificationGroup.id] = notificationGroup + } else if let relationship = item as? Mastodon.Entity.Relationship { + relationshipsCache[relationship.id] = relationship + } else if let fullAccount = item as? Mastodon.Entity.Account { + partialAccountsCache.removeValue(forKey: fullAccount.id) + fullAccountsCache[fullAccount.id] = fullAccount + } else if let partialAccount = item as? Mastodon.Entity.PartialAccountWithAvatar { + partialAccountsCache[partialAccount.id] = partialAccount + } else { + assertionFailure("cannot cache \(item)") + } + } + + public func cachedItem(_ identifier: MastodonFeedItemIdentifier) -> Any? { + switch identifier { + case .status(let id): + return statusCache[id] + case .notification(let id): + return notificationsCache[id] + case .notificationGroup(let id): + return groupedNotificationsCache[id] + } + } + + public func filterableStatus(associatedWith identifier: MastodonFeedItemIdentifier) -> Mastodon.Entity.Status? { + guard let cachedItem = cachedItem(identifier) else { return nil } + if let status = cachedItem as? Mastodon.Entity.Status { + return status.reblog ?? status + } else if let notification = cachedItem as? Mastodon.Entity.Notification { + return notification.status?.reblog ?? notification.status + } else if let notificationGroup = cachedItem as? Mastodon.Entity.NotificationGroup { + guard let statusID = notificationGroup.statusID else { return nil } + let status = statusCache[statusID] + return status?.reblog ?? status + } else if let relationship = cachedItem as? Mastodon.Entity.Relationship { + return nil + } else { + return nil + } + } + + public func relationship(associatedWith accountID: MastodonFeedItemIdentifier) -> Mastodon.Entity.Relationship? { + assertionFailure("not implemented") + return nil + } + + public func partialAccount(_ id: String) -> Mastodon.Entity.PartialAccountWithAvatar? { + assertionFailure("not implemented") + return nil + } + + public func fullAccount(_ id: String) -> Mastodon.Entity.Account? { + assertionFailure("not implemented") + return nil + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift index fc13709f36..78c5a92b9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -45,6 +45,7 @@ public enum ContentWarning { } +//@available(*, deprecated, message: "migrate to Mastodon.Entity.Status") public final class MastodonStatus: ObservableObject { public typealias ID = Mastodon.Entity.Status.ID