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..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 @@ -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. + * + * 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: ChatEvent, + nostrGroupId: String, + account: Account, + ) = withWakeLock { + Log.d(TAG, "New Marmot Group Message to Notify") + + 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() + // 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 = + 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..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 @@ -222,4 +223,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: ChatEvent, + 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..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 @@ -660,6 +661,21 @@ 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. 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, + ) + } } }