From 9075c4f1985e899e94c6b52d4d3a5df4dd0997a2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 13:10:49 -0800 Subject: [PATCH 1/2] fix(mobile): drop the # from Solo chat names in push notifications The notification extension titled every stream and forum conversation #name, including Solo chats, which the app presents as personal chats rather than channels. Cache whether relay metadata marks a channel as Solo (a private stream labelled buzz.solo, matching Channel.isSolo) and use the bare name for those conversations. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../BuzzPushNotificationResolver.swift | 5 +++ .../BuzzPushPresentationCache.swift | 17 +++++++ .../BuzzPushNotificationResolverTests.swift | 45 +++++++++++++++++++ .../BuzzPushPresentationCacheTests.swift | 36 +++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 989693c0104..2cc3f7022f6 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -462,6 +462,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { channelType: metadataEvent.map { BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0)) } ?? cached?.channelType, + isSolo: metadataEvent.map { + BuzzPushPresentationCacheStore.isSoloChannel($0) + } ?? cached?.isSolo, memberCount: membership?.count ?? cached?.memberCount, memberDigests: membership?.digests ?? cached?.memberDigests, membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID, @@ -508,6 +511,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ["stream", "forum"].contains(channelType), let displayName = channel.displayName else { return nil } + // Solo chats are personal conversations, not channels, so they get no `#`. + if channel.isSolo == true { 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 8619dee4daf..3043f35c86d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -45,6 +45,8 @@ 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? /// 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 +68,7 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { relayMetadataPubkey: String, displayName: String?, channelType: String? = nil, + isSolo: Bool? = nil, memberCount: Int? = nil, memberDigests: [String]? = nil, membershipEventID: String? = nil, @@ -81,6 +84,7 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { self.relayMetadataPubkey = relayMetadataPubkey self.displayName = displayName self.channelType = channelType + self.isSolo = isSolo self.memberCount = memberCount self.memberDigests = memberDigests self.membershipEventID = membershipEventID @@ -327,6 +331,7 @@ 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), memberCount: hasCurrentAuthority ? existing?.memberCount : nil, memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil, membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil, @@ -378,6 +383,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { relayMetadataPubkey: existing.relayMetadataPubkey, displayName: existing.displayName, channelType: existing.channelType, + isSolo: existing.isSolo, memberCount: membership.count, memberDigests: membership.digests, membershipEventID: event.id, @@ -532,6 +538,16 @@ 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") + } + static func normalizedChannelType(_ value: String?) -> String? { guard let value, ["stream", "forum", "dm"].contains(value) else { return nil } return value @@ -714,6 +730,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { relayMetadataPubkey: channel.relayMetadataPubkey, displayName: channel.displayName, channelType: channel.channelType, + isSolo: channel.isSolo, 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 f0df68a4790..998246b6d8a 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -753,6 +753,51 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 4) } + func testSoloChatConversationNameHasNoChannelHash() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Agent reply" + ) + 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", Self.ownPubkey], ["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, "Trip planning") + } + 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 8dc6e46892e..0f4ee1744a3 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -253,6 +253,42 @@ 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 cases: [(String, [[String]], Bool)] = [ + ("solo", [["t", "stream"], ["t", "buzz.solo"], ["private"]], 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)") + } + } + @Test("Channel membership requires the same relay authority and keeps exact scoped digests") func channelMembershipAuthorityAndOrdering() throws { let directory = try temporaryDirectory() From 12f9343f9fae458452ef1f5f2692ceaaa9288b78 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 13:28:27 -0800 Subject: [PATCH 2/2] fix(mobile): keep # for Solo chats created by someone else Match Channel.isPersonalChat: a buzz.solo stream is only a personal chat for its creator. Cache the relay-authored P creator and drop the # only when it is the current user. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../BuzzPushNotificationResolver.swift | 11 ++- .../BuzzPushPresentationCache.swift | 14 ++++ .../BuzzPushNotificationResolverTests.swift | 69 ++++++++++--------- .../BuzzPushPresentationCacheTests.swift | 4 +- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 2cc3f7022f6..3def120389d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -465,6 +465,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { 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, @@ -511,8 +514,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ["stream", "forum"].contains(channelType), let displayName = channel.displayName else { return nil } - // Solo chats are personal conversations, not channels, so they get no `#`. - if channel.isSolo == true { return (displayName, recipientCount) } + // 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 3043f35c86d..d6f0b7cc310 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -47,6 +47,8 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { 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. @@ -69,6 +71,7 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { displayName: String?, channelType: String? = nil, isSolo: Bool? = nil, + creatorPubkey: String? = nil, memberCount: Int? = nil, memberDigests: [String]? = nil, membershipEventID: String? = nil, @@ -85,6 +88,7 @@ public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { self.displayName = displayName self.channelType = channelType self.isSolo = isSolo + self.creatorPubkey = creatorPubkey self.memberCount = memberCount self.memberDigests = memberDigests self.membershipEventID = membershipEventID @@ -332,6 +336,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { 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, @@ -384,6 +389,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { displayName: existing.displayName, channelType: existing.channelType, isSolo: existing.isSolo, + creatorPubkey: existing.creatorPubkey, memberCount: membership.count, memberDigests: membership.digests, membershipEventID: event.id, @@ -548,6 +554,13 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { 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 @@ -731,6 +744,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { 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 998246b6d8a..ad0c6cdc783 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -753,7 +753,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 4) } - func testSoloChatConversationNameHasNoChannelHash() throws { + func testOnlyOwnSoloChatConversationNameDropsChannelHash() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, createdAt: Self.now, @@ -761,41 +761,48 @@ final class BuzzPushNotificationResolverTests: XCTestCase { tags: [["h", Self.channelID]], content: "Agent reply" ) - 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", Self.ownPubkey], ["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])) + // 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])) } - 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)) + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: channel.pubkey) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) ) ) - ) - XCTAssertEqual(result.conversationDisplayName, "Trip planning") + XCTAssertEqual(result.conversationDisplayName, expected) + } } func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 0f4ee1744a3..5619f4bd998 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -259,8 +259,9 @@ struct BuzzPushPresentationCacheTests { 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"]], true), + ("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), @@ -286,6 +287,7 @@ struct BuzzPushPresentationCacheTests { snapshot.channel( communityID: "community-a", relayOrigin: "https://relay.example", channelID: id)) #expect(cached.isSolo == expected, "\(id)") + #expect(cached.creatorPubkey == (id == "solo" ? creator : nil), "\(id)") } }