feat(notifications): Buzz DM push notifications + repost mute parity

Buzz DM messages (NIP-29 relay-group StreamMessageV2Event kind 40002 / ChatEvent
kind 9 in a `t=dm` channel) carry no `p` tag — participation is the relevance
signal — so main added them to the in-app feed only. This wires them into push:

- BuzzDmNotification renderer: MessagingStyle on the Private Messages channel,
  channel name as the conversation title, "sender: text" body, sender avatar,
  deep-links to the relay-group chatroom via the channel's kind-39000 naddr
  (reusing the existing naddr → Route.RelayGroup path). Honors the
  "show messages in notifications" toggle and enriches the sender observably.
- Dispatcher: adds kinds 40002/9 to NOTIFICATION_KINDS and gates them
  EXCLUSIVELY on Buzz-DM membership (buzzDmChannelForMe) so ordinary kind-9
  chats (Concord / NIP-C7) don't leak into the tray.
- NotificationRoutes.relayGroupUri for the channel deep-link.

Also closes a mute-parity gap the audit surfaced: the push muted-thread check
covered Reaction/LnZap but not Repost/GenericRepost (the feed mutes all four) —
so a bare Buzz repost of your note on a muted thread now stays muted in push too.

Buzz reactions/reposts (bare no-`p`-tag likes) already route to the existing
Reaction/Repost renderers, which are correct for Buzz (plain kind-7/6/16, same
target resolution and emoji semantics) — verified, no change needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
Claude
2026-07-23 21:02:54 +00:00
parent c55f46cb2b
commit 77dd1a5585
4 changed files with 152 additions and 2 deletions
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.BuzzDmNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.ChessNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.CodeNotification
import com.vitorpamplona.amethyst.service.notifications.renderers.DirectMessageNotification
@@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -216,8 +218,9 @@ class EventNotificationConsumer(
// Don't push-notify events this account authored.
if (event.pubKey == account.signer.pubKey) return
// Drop reactions/zaps whose target note lives on a muted thread.
if (event is ReactionEvent || event is LnZapEvent) {
// Drop reactions/zaps/reposts whose target note lives on a muted thread
// (matches the in-app feed, which mutes all four).
if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) {
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
}
@@ -227,6 +230,11 @@ class EventNotificationConsumer(
is ChatMessageEvent -> DirectMessageNotification.notify(applicationContext, account, event)
is ChatMessageEncryptedFileHeaderEvent -> DirectMessageNotification.notify(applicationContext, account, event)
// Buzz DM messages (participant-routed; the predicate already scoped
// these kinds to Buzz DMs addressed to me).
is StreamMessageV2Event -> BuzzDmNotification.notify(applicationContext, account, event)
is ChatEvent -> BuzzDmNotification.notify(applicationContext, account, event)
is LnZapEvent -> ZapNotification.notify(applicationContext, account, event)
is NutzapEvent -> ZapNotification.notify(applicationContext, account, event)
is OnchainZapEvent -> ZapNotification.notify(applicationContext, account, event)
@@ -24,7 +24,9 @@ import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.renderers.BuzzDmNotification
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -133,6 +135,11 @@ class NotificationDispatcher(
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
WakeUpEvent.KIND,
// Buzz DM messages in a `t=dm` relay-group channel (participant-
// routed; gated to Buzz DMs only in the predicate so ordinary
// kind-9 chats — Concord/NIP-C7 — don't leak into the tray).
StreamMessageV2Event.KIND,
ChatEvent.KIND,
// Unwrapped from GiftWrap → Seal
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
@@ -216,6 +223,18 @@ class NotificationDispatcher(
// tagsAnEventByUser's replyTo check would otherwise
// always miss for replies into addressable posts.
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
// Buzz DM messages (kind 40002 / kind 9 in a `t=dm`
// relay-group channel) are participant-routed, not
// p-tagged. Gate them EXCLUSIVELY on Buzz-DM
// membership so ordinary kind-9 chats (Concord /
// NIP-C7) don't fall through the generic gate below.
if (event is StreamMessageV2Event || event is ChatEvent) {
return@predicate pubkeys.any { pubkey ->
BuzzDmNotification.buzzDmChannelForMe(note, pubkey) != null
}
}
// Reactions/reposts are routed by tagsAnEventByUser
// (the reacted note's author == me), not by a `p`
// tag — so a Buzz-style bare `["e", id]` like, which
@@ -55,6 +55,16 @@ object NotificationRoutes {
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$nostrGroupId$ACCOUNT$accountNpub"
/**
* Opens a NIP-29 relay-group / Buzz chatroom. [channelNAddr] is the channel's
* kind-39000 naddr, which `MainActivity.uriToRoute` already routes to
* `Route.RelayGroup` via the standard nostr-entity path.
*/
fun relayGroupUri(
channelNAddr: String,
accountNpub: String,
): String = "$channelNAddr$ACCOUNT$accountNpub"
}
internal fun Context.notificationManager(): NotificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Buzz DM notifications — a message (NIP-29 relay-group `StreamMessageV2Event`
* kind 40002 or `ChatEvent` kind 9) posted into a Buzz `t=dm` channel whose 39000
* participant list includes me. These carry no `p` tag, so — like Marmot/Concord
* — being a participant of the DM channel is the relevance signal, resolved from
* the channel's already-loaded metadata.
*
* Rendered with MessagingStyle on the Private Messages channel; the channel name
* is the conversation title, the message shows "sender: text", and tapping opens
* the relay-group chatroom (via the channel's kind-39000 naddr). The sender's
* name + avatar are enriched observably.
*/
object BuzzDmNotification {
/**
* The Buzz DM channel [note] belongs to when it's a DM addressed to [me] —
* else null. Shared with the dispatcher predicate so push and the in-app feed
* agree on "this Buzz DM is for me". Returns null (no notification) when the
* channel's 39000 metadata isn't loaded yet, mirroring the feed.
*/
fun buzzDmChannelForMe(
note: Note,
me: HexKey,
): RelayGroupChannel? {
val channel = LocalCache.getRelayGroupChannelForContent(note) ?: return null
val metadata = channel.event ?: return null
return if (metadata.isBuzzDm() && metadata.buzzParticipants().contains(me)) channel else null
}
suspend fun notify(
context: Context,
account: Account,
event: Event,
) {
// Honor the "show messages in notifications" toggle, like the in-app feed.
if (!account.settings.showMessagesInNotifications.value) return
val note = LocalCache.getNoteIfExists(event.id) ?: return
val channel = buzzDmChannelForMe(note, account.signer.pubKey) ?: return
val sender = LocalCache.getOrCreateUser(event.pubKey)
val body = event.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message)
val accountNpub = NotificationRoutes.accountNpub(account)
// The channel's kind-39000 naddr routes straight to the chatroom via the
// existing naddr → Route.RelayGroup path (no message load needed on tap).
val uri =
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
?: NotificationRoutes.noteUri(note, accountNpub)
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(sender),
notes = listOf(note),
isComplete = { sender.metadataOrNull()?.bestName() != null },
) {
nm.postConversation(
category = NotificationCategory.DIRECT_MESSAGE,
id = event.id,
senderName = channel.toBestDisplayName(),
pictureUrl = sender.profilePicture() ?: channel.profilePicture(),
messageBody = "${sender.toBestDisplayName()}: $body",
time = event.createdAt,
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
)
}
}
}