Merge pull request #2737 from vitorpamplona/claude/fix-group-message-notifications-UNKZq

Add notifications for Marmot group messages (kind:445)
This commit is contained in:
Vitor Pamplona
2026-05-05 12:10:54 -04:00
committed by GitHub
3 changed files with 99 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.
*
* 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:<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,
@@ -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)
}
}
}
@@ -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,
)
}
}
}