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

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
This commit is contained in:
shannon 2025-01-09 08:43:08 -05:00
parent 4dd856f106
commit 100937187c
19 changed files with 762 additions and 142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<NotificationSection, NotificationItem> = {
let newItems = records.map { record in
NotificationItem.feed(record: record)
NotificationItem.notification(record)
}
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Mastodon.Entity.GroupedNotificationsResults> {
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 {

View File

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

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.GroupedNotificationsResults>, 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<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, 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

View File

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

View File

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

View File

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