mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user