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