diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index 077f6adb7..a805cbb67 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -66,6 +66,10 @@ struct NotificationsTab: View { case .follow, .follow_request: routerPath.navigate( to: .accountDetailWithAccount(account: newValue.notification.account)) + case .added_to_collection, .collection_update: + if let collection = newValue.notification.collection { + routerPath.navigate(to: .collectionDetail(collection: collection)) + } default: if let status = newValue.notification.status { routerPath.navigate(to: .statusDetailWithStatus(status: status)) diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index a1825458b..94cfe806d 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -40214,6 +40214,50 @@ } } }, + "notifications.label.added-to-collection" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "added you to a collection" + } + } + } + }, + "notifications.label.added-to-collection.push" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 Added you to a collection: " + } + } + } + }, + "notifications.label.collection-update" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "updated a collection you're in" + } + } + } + }, + "notifications.label.collection-update.push" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 Updated a collection: " + } + } + } + }, "notifications.label.favorite %lld" : { "extractionState" : "manual", "localizations" : { @@ -42335,6 +42379,28 @@ } } }, + "notifications.menu-title.added-to-collection" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Added to collection" + } + } + } + }, + "notifications.menu-title.collection-update" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collection updated" + } + } + } + }, "notifications.menu-title.favorite" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/Env/Sources/Env/CurrentInstance.swift b/Packages/Env/Sources/Env/CurrentInstance.swift index 6a22ba7f1..dc46cdd68 100644 --- a/Packages/Env/Sources/Env/CurrentInstance.swift +++ b/Packages/Env/Sources/Env/CurrentInstance.swift @@ -47,6 +47,11 @@ import Observation version >= 4.3 } + /// Collections shipped in Mastodon 4.6, which bumped the Mastodon API version to 10. + public var isCollectionsSupported: Bool { + instance?.apiVersions?.mastodon ?? 0 >= 10 + } + public var isQuoteSupported: Bool { instance?.apiVersions?.mastodon ?? 0 >= 7 } diff --git a/Packages/Models/Sources/Models/ConsolidatedNotification.swift b/Packages/Models/Sources/Models/ConsolidatedNotification.swift index 85131963c..c6a33d744 100644 --- a/Packages/Models/Sources/Models/ConsolidatedNotification.swift +++ b/Packages/Models/Sources/Models/ConsolidatedNotification.swift @@ -13,6 +13,8 @@ public struct ConsolidatedNotification: Identifiable { public let createdAt: ServerDate public let accounts: [Account] public let status: Status? + /// Set for `added_to_collection` and `collection_update` notifications (Mastodon 4.6+). + public let collection: AccountCollection? public let mostRecentNotificationId: String public var id: String { groupKey ?? mostRecentNotificationId } @@ -27,6 +29,7 @@ public struct ConsolidatedNotification: Identifiable { createdAt: ServerDate, accounts: [Account], status: Status?, + collection: AccountCollection? = nil, groupKey: String? = nil ) { self.notifications = notifications @@ -34,6 +37,7 @@ public struct ConsolidatedNotification: Identifiable { self.createdAt = createdAt self.accounts = accounts self.status = status ?? nil + self.collection = collection self.groupKey = groupKey self.mostRecentNotificationId = mostRecentNotificationId } diff --git a/Packages/Models/Sources/Models/Notification.swift b/Packages/Models/Sources/Models/Notification.swift index 6948f9e1a..fea631d7b 100644 --- a/Packages/Models/Sources/Models/Notification.swift +++ b/Packages/Models/Sources/Models/Notification.swift @@ -3,7 +3,7 @@ import Foundation public struct Notification: Decodable, Identifiable, Equatable { public enum NotificationType: String, CaseIterable { case follow, follow_request, mention, reblog, status, favourite, poll, update, quote, - quoted_update + quoted_update, added_to_collection, collection_update } public let id: String @@ -12,6 +12,8 @@ public struct Notification: Decodable, Identifiable, Equatable { public let account: Account public let status: Status? public let groupKey: String? + /// Attached when `type` is `added_to_collection` or `collection_update` (Mastodon 4.6+). + public let collection: AccountCollection? public var supportedType: NotificationType? { .init(rawValue: type) @@ -24,7 +26,8 @@ public struct Notification: Decodable, Identifiable, Equatable { createdAt: ServerDate(), account: .placeholder(), status: .placeholder(), - groupKey: nil) + groupKey: nil, + collection: nil) } } diff --git a/Packages/Models/Sources/Models/NotificationGroup.swift b/Packages/Models/Sources/Models/NotificationGroup.swift index 57132103e..beb00b279 100644 --- a/Packages/Models/Sources/Models/NotificationGroup.swift +++ b/Packages/Models/Sources/Models/NotificationGroup.swift @@ -10,6 +10,8 @@ public struct NotificationGroup: Codable, Identifiable, Sendable { public let latestPageNotificationAt: ServerDate public let sampleAccountIds: [String] public let statusId: String? + /// Attached when `type` is `added_to_collection` or `collection_update` (Mastodon 4.6+). + public let collection: AccountCollection? public var id: String { groupKey } } diff --git a/Packages/Models/Tests/ModelsTests/CollectionNotificationTests.swift b/Packages/Models/Tests/ModelsTests/CollectionNotificationTests.swift new file mode 100644 index 000000000..d8d9de5e2 --- /dev/null +++ b/Packages/Models/Tests/ModelsTests/CollectionNotificationTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +@testable import Models + +private let collectionJSON = """ + { + "id": "116131056935959117", + "account_id": "113668893442515793", + "uri": "https://example.com/ap/113668893442515793/collections/116131056935959117", + "url": "https://example.com/collections/116131056935959117", + "name": "Excellent people", + "description": "Well worth following", + "language": "en", + "local": true, + "sensitive": false, + "discoverable": true, + "tag": null, + "item_count": 1, + "items": [ + { + "id": "116141056635954112", + "account_id": "112658193342215767", + "state": "accepted", + "created_at": "2026-02-25T11:35:01.394Z" + } + ], + "created_at": "2026-02-25T11:35:01.394Z", + "updated_at": "2026-02-25T11:37:38.182Z" + } + """ + +private let accountJSON = """ + { + "id": "113668893442515793", + "username": "curator", + "acct": "curator@example.com", + "display_name": "Curator", + "note": "", + "avatar": "https://example.com/avatar.png", + "header": "https://example.com/header.png", + "locked": false, + "emojis": [], + "fields": [], + "created_at": "2026-01-01T00:00:00.000Z", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "bot": false, + "discoverable": true + } + """ + +private func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder +} + +@Test +func testAddedToCollectionNotificationDecoding() throws { + let json = """ + { + "id": "34975861", + "type": "added_to_collection", + "created_at": "2026-06-10T09:42:00.000Z", + "group_key": "ungrouped-34975861", + "account": \(accountJSON), + "collection": \(collectionJSON) + } + """ + + let notification = try makeDecoder().decode( + Models.Notification.self, from: Data(json.utf8)) + + #expect(notification.supportedType == .added_to_collection) + #expect(notification.status == nil) + #expect(notification.collection?.id == "116131056935959117") + #expect(notification.collection?.name == "Excellent people") + #expect(notification.collection?.acceptedAccountIds == ["112658193342215767"]) +} + +@Test +func testCollectionUpdateNotificationDecoding() throws { + let json = """ + { + "id": "34975862", + "type": "collection_update", + "created_at": "2026-06-10T09:43:00.000Z", + "group_key": "ungrouped-34975862", + "account": \(accountJSON), + "collection": \(collectionJSON) + } + """ + + let notification = try makeDecoder().decode( + Models.Notification.self, from: Data(json.utf8)) + + #expect(notification.supportedType == .collection_update) + #expect(notification.collection?.itemCount == 1) +} + +@Test +func testNotificationWithoutCollectionStillDecodes() throws { + let json = """ + { + "id": "34975863", + "type": "follow", + "created_at": "2026-06-10T09:44:00.000Z", + "group_key": "ungrouped-34975863", + "account": \(accountJSON) + } + """ + + let notification = try makeDecoder().decode( + Models.Notification.self, from: Data(json.utf8)) + + #expect(notification.supportedType == .follow) + #expect(notification.collection == nil) +} + +@Test +func testNotificationGroupDecodesCollection() throws { + let json = """ + { + "group_key": "added_to_collection-116131056935959117", + "notifications_count": 1, + "type": "added_to_collection", + "most_recent_notification_id": 34975861, + "page_min_id": "34975861", + "page_max_id": "34975861", + "latest_page_notification_at": "2026-06-10T09:42:00.000Z", + "sample_account_ids": ["113668893442515793"], + "collection": \(collectionJSON) + } + """ + + let group = try makeDecoder().decode(NotificationGroup.self, from: Data(json.utf8)) + + #expect(group.type == "added_to_collection") + #expect(group.statusId == nil) + #expect(group.collection?.name == "Excellent people") +} diff --git a/Packages/Notifications/Sources/Notifications/List/NotificationsListDataSource.swift b/Packages/Notifications/Sources/Notifications/List/NotificationsListDataSource.swift index f1498dfc8..a7a112fa7 100644 --- a/Packages/Notifications/Sources/Notifications/List/NotificationsListDataSource.swift +++ b/Packages/Notifications/Sources/Notifications/List/NotificationsListDataSource.swift @@ -369,6 +369,7 @@ public final class NotificationsListDataSource { createdAt: event.notification.createdAt, accounts: [event.notification.account], status: event.notification.status, + collection: event.notification.collection, groupKey: groupKey ) @@ -419,6 +420,7 @@ public final class NotificationsListDataSource { createdAt: newGroup.createdAt, accounts: updatedAccounts, status: existingGroup.status, + collection: newGroup.collection ?? existingGroup.collection, groupKey: groupKey ) @@ -483,6 +485,7 @@ public final class NotificationsListDataSource { createdAt: group.latestPageNotificationAt, accounts: accounts, status: status, + collection: group.collection, groupKey: group.groupKey )) } diff --git a/Packages/Notifications/Sources/Notifications/List/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/List/NotificationsListView.swift index 7d50b13ac..9383c802a 100644 --- a/Packages/Notifications/Sources/Notifications/List/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/List/NotificationsListView.swift @@ -76,7 +76,7 @@ public struct NotificationsListView: View { .tint(theme.labelColor) } Divider() - ForEach(Notification.NotificationType.allCases, id: \.self) { type in + ForEach(filterableTypes, id: \.self) { type in Button { applyFilter(type: type) } label: { @@ -167,6 +167,17 @@ public struct NotificationsListView: View { } } + private var filterableTypes: [Models.Notification.NotificationType] { + Models.Notification.NotificationType.allCases.filter { type in + switch type { + case .added_to_collection, .collection_update: + currentInstance.isCollectionsSupported + default: + true + } + } + } + @ViewBuilder private var notificationsView: some View { switch viewState { diff --git a/Packages/Notifications/Sources/Notifications/Models/Notification+Consolidated.swift b/Packages/Notifications/Sources/Notifications/Models/Notification+Consolidated.swift index 36ffbacba..f6aaacd32 100644 --- a/Packages/Notifications/Sources/Notifications/Models/Notification+Consolidated.swift +++ b/Packages/Notifications/Sources/Notifications/Models/Notification+Consolidated.swift @@ -28,7 +28,8 @@ extension [Models.Notification] { type: supportedType, createdAt: notification.createdAt, accounts: notifications.map(\.account), - status: notification.status) + status: notification.status, + collection: notification.collection) } .sorted { $0.createdAt.asDate > $1.createdAt.asDate diff --git a/Packages/Notifications/Sources/Notifications/Models/NotificationTypeExt.swift b/Packages/Notifications/Sources/Notifications/Models/NotificationTypeExt.swift index e7af58285..36cca476f 100644 --- a/Packages/Notifications/Sources/Notifications/Models/NotificationTypeExt.swift +++ b/Packages/Notifications/Sources/Notifications/Models/NotificationTypeExt.swift @@ -25,6 +25,10 @@ extension Models.Notification.NotificationType { "quoted your post" case .quoted_update: "updated a quoted status" + case .added_to_collection: + "notifications.label.added-to-collection" + case .collection_update: + "notifications.label.collection-update" } } @@ -50,6 +54,10 @@ extension Models.Notification.NotificationType { "🔊 quoted" case .quoted_update: "🔊 updated a quoted status" + case .added_to_collection: + "notifications.label.added-to-collection.push" + case .collection_update: + "notifications.label.collection-update.push" } } @@ -76,6 +84,10 @@ extension Models.Notification.NotificationType { return Image(systemName: "quote.bubble.fill") case .quoted_update: return Image(systemName: "exclamationmark.bubble.fill") + case .added_to_collection: + return Image(systemName: "rectangle.stack.badge.person.crop.fill") + case .collection_update: + return Image(systemName: "person.2.crop.square.stack.fill") } } @@ -93,6 +105,8 @@ extension Models.Notification.NotificationType { return Color.cyan.opacity(0.80) case .favourite: return Color.yellow.opacity(0.80) + case .added_to_collection, .collection_update: + return Color.indigo.opacity(0.80) } } @@ -118,6 +132,10 @@ extension Models.Notification.NotificationType { "notifications.menu-title.quote" case .quoted_update: "notifications.menu-title.quote-updated" + case .added_to_collection: + "notifications.menu-title.added-to-collection" + case .collection_update: + "notifications.menu-title.collection-update" } } } diff --git a/Packages/Notifications/Sources/Notifications/Row/NotificationRowCollectionView.swift b/Packages/Notifications/Sources/Notifications/Row/NotificationRowCollectionView.swift new file mode 100644 index 000000000..b453ffac5 --- /dev/null +++ b/Packages/Notifications/Sources/Notifications/Row/NotificationRowCollectionView.swift @@ -0,0 +1,32 @@ +import DesignSystem +import Env +import Models +import SwiftUI + +/// Content shown for `added_to_collection` and `collection_update` notifications: a pill for the +/// collection that was the object of the notification, matching the one on account profiles. +@MainActor +struct NotificationRowCollectionView: View { + let collection: AccountCollection + let routerPath: RouterPath + + var body: some View { + HStack { + Button { + routerPath.navigate(to: .collectionDetail(collection: collection)) + } label: { + VStack(alignment: .leading, spacing: 0) { + Text(collection.name) + .font(.scaledCallout) + .lineLimit(1) + .truncationMode(.tail) + Text("account.detail.collections-n-accounts \(collection.itemCount)") + .font(.caption2) + } + } + .buttonStyle(.bordered) + .frame(maxWidth: 180) + Spacer() + } + } +} diff --git a/Packages/Notifications/Sources/Notifications/Row/NotificationRowContentView.swift b/Packages/Notifications/Sources/Notifications/Row/NotificationRowContentView.swift index f13dc0dc4..0bb681d3f 100644 --- a/Packages/Notifications/Sources/Notifications/Row/NotificationRowContentView.swift +++ b/Packages/Notifications/Sources/Notifications/Row/NotificationRowContentView.swift @@ -55,6 +55,18 @@ struct NotificationRowContentView: View { Spacer() } .environment(\.isCompact, true) + } else if let collection = notification.collection { + VStack(alignment: .leading, spacing: 6) { + Text("@\(notification.accounts[0].acct)") + .font(.scaledCallout) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + .onTapGesture { + routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0])) + } + NotificationRowCollectionView(collection: collection, routerPath: routerPath) + } + .frame(maxWidth: .infinity, alignment: .leading) } else { Group { Text("@\(notification.accounts[0].acct)")