fix(mobile): drop the # from Solo chat names in push notifications (#8955)

## 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-02 18:50:08 -08:00
committed by GitHub
parent 972e89ac89
commit ff6ab96e42
4 changed files with 133 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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,

View File

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