fix(notifications): popup for Marmot group messages (kind:445)

Welcomes (kind:444) already had a direct-dispatch path because they have
no `p` tag and the cache-observer route can't match them to an account.
Group messages (kind:445) have the same problem — recipients are routed
by the `h` tag carrying the nostr_group_id — but were silently missed,
so the user only saw a popup when first added to a group, never for any
chat that followed.

Mirror the notifyWelcome path: after MarmotInboundProcessor decrypts
and verifies an ApplicationMessage and we've persisted the inner event
for the first time, fire notifyGroupMessage on NotificationDispatcher.
The notifier filters to ChatEvent (kind:9) so reactions, deletions and
control messages stay silent (matching how NIP-17 only notifies on
kind:14), applies the same 15-min freshness and self-author gates as
the other DM paths, and uses the marmot:<groupHex>?account=<npub>
deep-link scheme so taps land in the right chatroom.

Only fires on first-time decryption (isNew) so a relay re-broadcast or
on-disk persist replay can't double-notify.
This commit is contained in:
Claude
2026-05-05 15:36:35 +00:00
parent f2c8e154cf
commit cfde4a5bf7
3 changed files with 94 additions and 0 deletions
@@ -83,6 +83,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.TimeoutCancellationException
@@ -495,6 +496,67 @@ class EventNotificationConsumer(
)
}
/**
* Marmot kind:445 group messages have no `p` tag (recipients are routed
* by the `h` tag carrying the nostr_group_id), so the cache-observer path
* in [NotificationDispatcher] can't match them to an account. They're
* dispatched here directly from [com.vitorpamplona.amethyst.ui.screen.loggedIn.GroupEventHandler]
* once [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor] has
* decrypted the outer ChaCha20-Poly1305 layer and verified the inner
* MLS-signed payload.
*
* Only kind:9 chat messages produce a notification — reactions, control
* messages, and deletions stay silent, mirroring how NIP-17 (kind:14)
* is the only DM kind we notify.
*/
suspend fun notifyGroupMessage(
innerEvent: Event,
nostrGroupId: String,
account: Account,
) = withWakeLock {
Log.d(TAG, "New Marmot Group Message to Notify")
if (innerEvent.kind != ChatEvent.KIND) return@withWakeLock
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
if (MainActivity.isResumed) return@withWakeLock
// old event being re-broadcast
if (innerEvent.createdAt < TimeUtils.fifteenMinutesAgo()) return@withWakeLock
// a message we ourselves sent
if (innerEvent.pubKey == account.signer.pubKey) return@withWakeLock
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "Private group"
val sender = LocalCache.getOrCreateUser(innerEvent.pubKey)
val senderName = sender.toBestDisplayName()
val senderPicture = sender.profilePicture()
// Show the message body when present; reactions/empty payloads fall
// back to a generic prompt so the popup is still actionable.
val body = innerEvent.content.takeIf { it.isNotBlank() } ?: "New message"
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
// marmot:<groupHex>?account=<npub> — same scheme as notifyWelcome,
// taps deep-link straight to the group's chatroom.
val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub"
notificationManager()
.sendDMNotification(
id = innerEvent.id,
messageBody = "$senderName: $body",
senderName = groupName,
time = innerEvent.createdAt,
pictureUrl = senderPicture,
uri = noteUri,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
)
}
suspend fun decryptZapContentAuthor(
event: LnZapRequestEvent,
signer: NostrSigner,
@@ -222,4 +222,24 @@ class NotificationDispatcher(
Log.e(TAG, "Failed to dispatch Welcome notification ${event.id}", e)
}
}
/**
* Direct-invocation entry point for Marmot kind:445 group messages.
* Bypasses the cache-observer path because GroupEvents are routed by
* the `h` tag (nostr_group_id), not by `p` tag. Called from
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.GroupEventHandler]
* once the MLS-decrypted inner event has been parsed and indexed.
*/
suspend fun notifyGroupMessage(
innerEvent: Event,
nostrGroupId: String,
account: Account,
) {
try {
consumer.notifyGroupMessage(innerEvent, nostrGroupId, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "Failed to dispatch Group Message notification ${innerEvent.id}", e)
}
}
}
@@ -660,6 +660,18 @@ class GroupEventHandler(
// re-persist would silently grow the on-disk log).
if (isNew) {
manager.persistDecryptedMessage(result.groupId, result.innerEventJson)
// GroupEvents have no `p` tag, so the cache-observer
// notification path can't route them. Fire the popup
// directly here — only on first-time decryption, so
// a relay re-broadcast or persist-replay doesn't
// double-notify. The notifier itself filters by
// inner kind (chat only) and freshness.
Amethyst.instance.notificationDispatcher.notifyGroupMessage(
innerEvent,
result.groupId,
account,
)
}
}