From c73739b4b2f6c79b65c59faa41a6f77e22205be7 Mon Sep 17 00:00:00 2001 From: Joe Barbere Date: Fri, 4 Sep 2026 21:06:48 -0400 Subject: [PATCH 1/2] Add Collections notification support (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mastodon 4.6 added two notification types for Collections: `added_to_collection` (someone added you to their collection) and `collection_update` (a collection you're featured in changed). Both carry the Collection entity on the notification, and both the v1 and the v2 grouped notification endpoints serialize it. Until now Ice Cubes dropped these on the floor: an unknown `type` maps to a nil `supportedType`, and both consolidation paths skip those. This adds the two cases, decodes the attached `collection` on Notification and NotificationGroup, carries it through consolidation, and renders it in the notification row as the same collection pill used on profiles, tappable through to the existing collection detail view. Push notification taps route there too. The two new types are also offered in the notifications filter menu, but only on 4.6+ instances — older servers return nothing for them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ab8HLH6dDqz5QtrW2yWyeE --- IceCubesApp/App/Tabs/NotificationTab.swift | 4 + .../Localization/Localizable.xcstrings | 66 ++++++++ .../Env/Sources/Env/CurrentInstance.swift | 4 + .../Models/ConsolidatedNotification.swift | 4 + .../Models/Sources/Models/Notification.swift | 7 +- .../Sources/Models/NotificationGroup.swift | 2 + .../CollectionNotificationTests.swift | 143 ++++++++++++++++++ .../List/NotificationsListDataSource.swift | 3 + .../List/NotificationsListView.swift | 13 +- .../Models/Notification+Consolidated.swift | 3 +- .../Models/NotificationTypeExt.swift | 18 +++ .../Row/NotificationRowCollectionView.swift | 32 ++++ .../Row/NotificationRowContentView.swift | 12 ++ 13 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 Packages/Models/Tests/ModelsTests/CollectionNotificationTests.swift create mode 100644 Packages/Notifications/Sources/Notifications/Row/NotificationRowCollectionView.swift 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..430e04d68 100644 --- a/Packages/Env/Sources/Env/CurrentInstance.swift +++ b/Packages/Env/Sources/Env/CurrentInstance.swift @@ -47,6 +47,10 @@ import Observation version >= 4.3 } + public var isCollectionsSupported: Bool { + version >= 4.6 + } + 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)") From 480aa6c26e91862445b016109e7e58d3bbef9c9d Mon Sep 17 00:00:00 2001 From: Joe Barbere Date: Fri, 4 Sep 2026 21:52:56 -0400 Subject: [PATCH 2/2] Gate collections on the Mastodon API version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CurrentInstance.version` parses `String.prefix(3)` into a Float, so a future "4.10" reads as 4.1 and would silently switch Collections back off. Collections shipped in 4.6.0, which bumped `api_versions.mastodon` to 10 (4.5.0 was 7, 4.7.1 is 11), so gate on that instead — the same way `isQuoteSupported` already does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ab8HLH6dDqz5QtrW2yWyeE --- Packages/Env/Sources/Env/CurrentInstance.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/Env/Sources/Env/CurrentInstance.swift b/Packages/Env/Sources/Env/CurrentInstance.swift index 430e04d68..dc46cdd68 100644 --- a/Packages/Env/Sources/Env/CurrentInstance.swift +++ b/Packages/Env/Sources/Env/CurrentInstance.swift @@ -47,8 +47,9 @@ import Observation version >= 4.3 } + /// Collections shipped in Mastodon 4.6, which bumped the Mastodon API version to 10. public var isCollectionsSupported: Bool { - version >= 4.6 + instance?.apiVersions?.mastodon ?? 0 >= 10 } public var isQuoteSupported: Bool {