From ff6ab96e42b1fc64bfc1236104478a84e11b5abd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 18:50:08 -0800 Subject: [PATCH] fix(mobile): drop the # from Solo chat names in push notifications (#8955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Push notifications for Solo chats were titled like channels (`#Trip planning`). The iOS notification extension added `#` to every stream and forum conversation name, but the app presents Solo chats as personal chats, not channels. - `BuzzPushCachedChannel` gains optional `isSolo` and `creatorPubkey`, derived from relay kind 39000 metadata. `isSolo` uses the same rule as the app's `Channel.isSolo`: a private stream whose NIP-CL labels (every `t` after the leading type) include `buzz.solo`. `creatorPubkey` is the relay-authored `P` tag. - The resolver uses the bare channel name only for the viewer's own Solo chats (matching `Channel.isPersonalChat`, which requires `createdBy == viewer`). Someone else's Solo chat, and every other channel, keeps `#name`. - `isSolo` is optional, so existing App Group caches still decode. Entries cached before this change have no flag and keep the `#` until their metadata is refreshed. ## Validation - `swift test --package-path mobile/ios/BuzzPushKit`: 104 XCTest and 66 Swift Testing tests pass, including two new ones: - `testOnlyOwnSoloChatConversationNameDropsChannelHash`: a fresh Solo metadata fetch yields `Trip planning` when the viewer created the chat and `#Trip planning` when someone else did. Removing the creator check makes it fail. - `soloClassification`: only private `stream` channels labelled `buzz.solo` are marked Solo (open, forum, label-only and unlabelled private streams are not), and the `P` creator is cached lowercased. - Live relay check: a local relay with synthetic identities, a Solo chat ("Weekend plans") and a private channel ("design-review"), each with a second member who posts. The production `BuzzPushNotificationResolver` (the code the notification extension calls), run against relay-built kind 39000 metadata, gives: | | Solo chat | Channel | |---|---|---| | main | `#Weekend plans` | `#design-review` | | this PR | `Weekend plans` | `#design-review` | - Not covered: a banner rendered by the notification extension. `simctl push` delivers to the simulator as a local notification and never launches the extension, and a real APNs push needs credentials this machine doesn't have. ## Follow-up after merge Merging on the live relay check above. Still to do once a dogfood build includes this change: - [ ] On-device push check on a BLIMP: a Solo chat push shows the bare name and a channel push keeps `#name`. - [ ] Before/after notification screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../BuzzPushNotificationResolver.swift | 12 +++++ .../BuzzPushPresentationCache.swift | 31 +++++++++++ .../BuzzPushNotificationResolverTests.swift | 52 +++++++++++++++++++ .../BuzzPushPresentationCacheTests.swift | 38 ++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 989693c010..3def120389 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -462,6 +462,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { channelType: metadataEvent.map { BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0)) } ?? cached?.channelType, + isSolo: metadataEvent.map { + BuzzPushPresentationCacheStore.isSoloChannel($0) + } ?? cached?.isSolo, + creatorPubkey: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedCreator($0) + } ?? cached?.creatorPubkey, memberCount: membership?.count ?? cached?.memberCount, memberDigests: membership?.digests ?? cached?.memberDigests, membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID, @@ -508,6 +514,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ["stream", "forum"].contains(channelType), let displayName = channel.displayName else { return nil } + // Your own Solo chats are personal conversations, not channels, so they + // get no `#`. Like the app (`Channel.isPersonalChat`), someone else's + // Solo chat you belong to stays an ordinary channel. + if channel.isSolo == true, channel.creatorPubkey == currentUser { + return (displayName, recipientCount) + } return (displayName.hasPrefix("#") ? displayName : "#\(displayName)", recipientCount) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index 8619dee4da..d6f0b7cc31 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -45,6 +45,10 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { public let displayName: String? /// Relay-verified Buzz channel type, when recognized. public let channelType: String? + /// Whether relay metadata marks this as a private Solo stream (`buzz.solo` label). + public let isSolo: Bool? + /// Relay-authored creator (`P`), lowercased, when it is a valid pubkey. + public let creatorPubkey: String? /// Exact unique-member count when bounded, or a value above the bound when oversized. public let memberCount: Int? /// Complete community-and-channel-scoped member digests, when within bounds. @@ -66,6 +70,8 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { relayMetadataPubkey: String, displayName: String?, channelType: String? = nil, + isSolo: Bool? = nil, + creatorPubkey: String? = nil, memberCount: Int? = nil, memberDigests: [String]? = nil, membershipEventID: String? = nil, @@ -81,6 +87,8 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { self.relayMetadataPubkey = relayMetadataPubkey self.displayName = displayName self.channelType = channelType + self.isSolo = isSolo + self.creatorPubkey = creatorPubkey self.memberCount = memberCount self.memberDigests = memberDigests self.membershipEventID = membershipEventID @@ -327,6 +335,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { relayMetadataPubkey: normalizedRelayPubkey, displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)), channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)), + isSolo: Self.isSoloChannel(event), + creatorPubkey: Self.normalizedCreator(event), memberCount: hasCurrentAuthority ? existing?.memberCount : nil, memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil, membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil, @@ -378,6 +388,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { relayMetadataPubkey: existing.relayMetadataPubkey, displayName: existing.displayName, channelType: existing.channelType, + isSolo: existing.isSolo, + creatorPubkey: existing.creatorPubkey, memberCount: membership.count, memberDigests: membership.digests, membershipEventID: event.id, @@ -532,6 +544,23 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { return bounded.isEmpty ? nil : bounded } + /// Mirrors the app's Solo rule (`Channel.isSolo`): a private stream whose + /// NIP-CL labels (every `t` after the leading type) include `buzz.solo`. + static func isSoloChannel(_ event: VerifiedNostrEvent) -> Bool { + let types = event.tags.filter { $0.count >= 2 && $0[0] == "t" }.map { $0[1] } + let isPrivate = + !event.tags.contains { $0.first == "public" } + && event.tags.contains { $0.first == "private" } + return types.first == "stream" && isPrivate && types.dropFirst().contains("buzz.solo") + } + + /// The relay-authored creator `P` tag, lowercased, when it is a valid pubkey. + static func normalizedCreator(_ event: VerifiedNostrEvent) -> String? { + guard let creator = tagValue("P", in: event)?.lowercased(), isHexPubkey(creator) + else { return nil } + return creator + } + static func normalizedChannelType(_ value: String?) -> String? { guard let value, ["stream", "forum", "dm"].contains(value) else { return nil } return value @@ -714,6 +743,8 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { relayMetadataPubkey: channel.relayMetadataPubkey, displayName: channel.displayName, channelType: channel.channelType, + isSolo: channel.isSolo, + creatorPubkey: channel.creatorPubkey, memberCount: channel.memberCount, memberDigests: nil, membershipEventID: channel.membershipEventID, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index f0df68a479..ad0c6cdc78 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -753,6 +753,58 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 4) } + func testOnlyOwnSoloChatConversationNameDropsChannelHash() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Agent reply" + ) + // Like the app's Channel.isPersonalChat: only the viewer's own Solo chat + // is personal; someone else's Solo chat stays an ordinary channel. + for (creator, expected) in [ + (Self.ownPubkey, "Trip planning"), (message.pubkey, "#Trip planning"), + ] { + URLProtocolStub.requests = [] + let channel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_000, + tags: [ + ["d", Self.channelID], ["name", "Trip planning"], ["t", "stream"], + ["t", "buzz.solo"], ["P", creator], ["private"], + ] + ) + let membership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [["d", Self.channelID], ["p", Self.ownPubkey], ["p", message.pubkey]] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, status: 200, data: try JSONEncoder().encode([channel, membership])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: channel.pubkey) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, expected) + } + } + func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 8dc6e46892..5619f4bd99 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -253,6 +253,44 @@ struct BuzzPushPresentationCacheTests { #expect(cached.relayMetadataPubkey == relayPubkey) } + @Test("Channel metadata marks only private buzz.solo streams as Solo") + func soloClassification() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let creator = try pubkey(for: profileKey) + let cases: [(String, [[String]], Bool)] = [ + ("solo", [["t", "stream"], ["t", "buzz.solo"], ["private"], ["P", creator.uppercased()]], true), + ("open-solo", [["t", "stream"], ["t", "buzz.solo"], ["public"]], false), + ("forum-solo", [["t", "forum"], ["t", "buzz.solo"], ["private"]], false), + ("type-only", [["t", "buzz.solo"], ["private"]], false), + ("private", [["t", "stream"], ["private"]], false), + ] + let events = try cases.map { id, tags, _ in + try signedEvent( + privateKey: relayKey, createdAt: 100, kind: 39_000, + tags: [["d", id], ["name", id]] + tags) + } + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "wss://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: events, + membershipEvents: [] + ) + + let snapshot = try loadSnapshot(directory) + for (id, _, expected) in cases { + let cached = try #require( + snapshot.channel( + communityID: "community-a", relayOrigin: "https://relay.example", channelID: id)) + #expect(cached.isSolo == expected, "\(id)") + #expect(cached.creatorPubkey == (id == "solo" ? creator : nil), "\(id)") + } + } + @Test("Channel membership requires the same relay authority and keeps exact scoped digests") func channelMembershipAuthorityAndOrdering() throws { let directory = try temporaryDirectory()