From cfde4a5bf70e3f1664972f43ea1d13aa256b4d79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 15:36:35 +0000 Subject: [PATCH 1/2] fix(notifications): popup for Marmot group messages (kind:445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:?account= 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. --- .../EventNotificationConsumer.kt | 62 +++++++++++++++++++ .../notifications/NotificationDispatcher.kt | 20 ++++++ .../loggedIn/DecryptAndIndexProcessor.kt | 12 ++++ 3 files changed, 94 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 7470825af..4b7471612 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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:?account= — 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 8ca88478a..02edd0976 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -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) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index aebdf4308..91afd9784 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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, + ) } } From 30d8d1bb6f8800e4536bca3fa3c788926d664a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 15:57:36 +0000 Subject: [PATCH 2/2] refactor(notifications): type notifyGroupMessage as ChatEvent The previous signature took Event and runtime-checked kind == 9 inside the notifier. That left the contract implicit: any caller could pass a reaction or control message and the call would silently no-op. Type the parameter as ChatEvent so the kind:9 restriction is structural, and move the narrowing (`is ChatEvent`) to the GroupEventHandler call site where the inner event is parsed. Reactions, deletions, and other inner kinds now can't reach the notifier in the first place. Drops the runtime kind check and the now-stale comment about reactions. --- .../notifications/EventNotificationConsumer.kt | 14 +++++++------- .../notifications/NotificationDispatcher.kt | 3 ++- .../loggedIn/DecryptAndIndexProcessor.kt | 18 +++++++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 4b7471612..2d78756a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -505,18 +505,17 @@ class EventNotificationConsumer( * 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. + * Typed to [ChatEvent] so the caller has to narrow first — reactions, + * control messages, and deletions stay silent at the type level, + * mirroring how NIP-17 (kind:14) is the only DM kind we notify. */ suspend fun notifyGroupMessage( - innerEvent: Event, + innerEvent: ChatEvent, 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 @@ -530,8 +529,9 @@ class EventNotificationConsumer( 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. + // Defensive fallback for the rare empty-content ChatEvent so the + // popup is still actionable. Non-chat inner kinds were filtered + // out at the call site by the ChatEvent type narrowing. val body = innerEvent.content.takeIf { it.isNotBlank() } ?: "New message" val accountNpub = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 02edd0976..df17bfe01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -51,6 +51,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.CancellationException @@ -231,7 +232,7 @@ class NotificationDispatcher( * once the MLS-decrypted inner event has been parsed and indexed. */ suspend fun notifyGroupMessage( - innerEvent: Event, + innerEvent: ChatEvent, nostrGroupId: String, account: Account, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 91afd9784..b9538f08c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex @@ -665,13 +666,16 @@ class GroupEventHandler( // 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, - ) + // double-notify. Restrict to ChatEvent (kind:9) so + // reactions, deletions, and control messages stay + // silent. + if (innerEvent is ChatEvent) { + Amethyst.instance.notificationDispatcher.notifyGroupMessage( + innerEvent, + result.groupId, + account, + ) + } } }