fix(mobile): show the channel name as the notification subtitle (#9173)

On Apple Watch, a Buzz notification showed the community name ("buzz")
under the sender, taking a line of the small display without saying
where the message came from. iPhone never showed it, because iOS draws
the banner from the communication intent and drops `content.subtitle`.
The Watch renders the plain fields.

The subtitle now carries the conversation name, the same one the intent
uses for its group name:

- Team channels and forums: `#channel`
- Your own Solo chats: the bare name, no `#`
- DMs, and channels not yet in the presentation cache: no subtitle

The name logic moves into one helper so the subtitle and the iPhone
group name can't drift apart. A channel that's known but isn't eligible
for the communication style (for example, an unverified roster) now gets
`#channel` as its plain-banner subtitle in place of the community name.
Losing the community name means the Watch no longer tells communities
apart. That seems like the right trade for most people, who are in one
community.

### Validation

- BuzzPushKit `swift test`: all 110 XCTest and 69 Swift Testing cases
pass. Updated the resolver assertions: `#General` for a cached stream,
both with and without communication styling, and no subtitle for DMs and
uncached channels.
- Not yet exercised on a device or Watch.

🤖 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-09 06:59:13 -08:00
committed by GitHub
parent e1e1cd50ab
commit 58696ff577
3 changed files with 23 additions and 8 deletions

View File

@@ -533,7 +533,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
return BuzzPushResolution(
title: profile?.displayName ?? "",
body: body,
subtitle: community.name,
subtitle: channel.flatMap {
channelDisplayName(channel: $0, currentUser: community.pubkey?.lowercased())
},
threadIdentifier: conversationIdentifier ?? community.id,
navigationTarget: channelID.map {
BuzzPushNavigationTarget(
@@ -635,6 +637,18 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
guard memberCount == 2, senderIsMember else { return nil }
return (nil, recipientCount)
}
guard let displayName = channelDisplayName(channel: channel, currentUser: currentUser)
else { return nil }
return (displayName, recipientCount)
}
/// The conversation name shown with a channel message: `#name` for team
/// channels and forums, the bare name for your own Solo chats, and `nil`
/// for DMs and unrecognized channel types.
private static func channelDisplayName(
channel: BuzzPushCachedChannel,
currentUser: String?
) -> String? {
guard let channelType = channel.channelType,
["stream", "forum"].contains(channelType),
let displayName = channel.displayName
@@ -643,9 +657,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
// 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
}
return (displayName.hasPrefix("#") ? displayName : "#\(displayName)", recipientCount)
return displayName.hasPrefix("#") ? displayName : "#\(displayName)"
}
private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? {

View File

@@ -48,6 +48,7 @@ extension BuzzPushNotificationResolverTests {
)
XCTAssertEqual(result.conversationDisplayName, "#General")
XCTAssertEqual(result.subtitle, "#General")
XCTAssertEqual(result.conversationRecipientCount, 2)
}
@@ -96,7 +97,7 @@ extension BuzzPushNotificationResolverTests {
XCTAssertNil(result.conversationDisplayName)
XCTAssertNil(result.conversationRecipientCount)
XCTAssertEqual(result.subtitle, "Community")
XCTAssertEqual(result.subtitle, "#General")
}
func testPartialMembershipRefreshDoesNotReuseCachedChannel() throws {
@@ -262,6 +263,6 @@ extension BuzzPushNotificationResolverTests {
XCTAssertNil(result.conversationDisplayName)
XCTAssertNil(result.conversationRecipientCount)
XCTAssertEqual(result.subtitle, "Community")
XCTAssertNil(result.subtitle)
}
}

View File

@@ -311,7 +311,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result?.0.title, sender.title, "pubkey: \(sender.pubkey)")
XCTAssertEqual(result?.0.body, "Preview", "pubkey: \(sender.pubkey)")
XCTAssertEqual(result?.0.subtitle, "Community", "pubkey: \(sender.pubkey)")
XCTAssertNil(result?.0.subtitle, "pubkey: \(sender.pubkey)")
XCTAssertEqual(result?.0.senderPubkey, sender.pubkey, "pubkey: \(sender.pubkey)")
XCTAssertEqual(result?.0.threadIdentifier, "community-id", "pubkey: \(sender.pubkey)")
}
@@ -367,7 +367,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertNotEqual(result.body, Self.gatewayBody)
XCTAssertEqual(result.title, "")
XCTAssertEqual(result.body, "Hello Buzz")
XCTAssertEqual(result.subtitle, "Community")
XCTAssertNil(result.subtitle)
XCTAssertEqual(
result.threadIdentifier,
BuzzPushPresentationIdentity.conversation(
@@ -1024,7 +1024,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result.title, "")
XCTAssertNil(result.conversationDisplayName)
XCTAssertEqual(result.subtitle, "Community")
XCTAssertNil(result.subtitle)
XCTAssertEqual(result.body, "Fallback content")
}