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 e82e26027a..ef5267dd59 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 @@ -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) 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 8c081bf51f..08850f8da4 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 @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt index e605fbaccf..acf98d2cd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt new file mode 100644 index 0000000000..cdcd3ced65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BuzzDmNotification.kt @@ -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, + ) + } + } +}