mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(notifications): Buzz DM push notifications + repost mute parity
Buzz DM messages (NIP-29 relay-group StreamMessageV2Event kind 40002 / ChatEvent kind 9 in a `t=dm` channel) carry no `p` tag — participation is the relevance signal — so main added them to the in-app feed only. This wires them into push: - BuzzDmNotification renderer: MessagingStyle on the Private Messages channel, channel name as the conversation title, "sender: text" body, sender avatar, deep-links to the relay-group chatroom via the channel's kind-39000 naddr (reusing the existing naddr → Route.RelayGroup path). Honors the "show messages in notifications" toggle and enriches the sender observably. - Dispatcher: adds kinds 40002/9 to NOTIFICATION_KINDS and gates them EXCLUSIVELY on Buzz-DM membership (buzzDmChannelForMe) so ordinary kind-9 chats (Concord / NIP-C7) don't leak into the tray. - NotificationRoutes.relayGroupUri for the channel deep-link. Also closes a mute-parity gap the audit surfaced: the push muted-thread check covered Reaction/LnZap but not Repost/GenericRepost (the feed mutes all four) — so a bare Buzz repost of your note on a muted thread now stays muted in push too. Buzz reactions/reposts (bare no-`p`-tag likes) already route to the existing Reaction/Repost renderers, which are correct for Buzz (plain kind-7/6/16, same target resolution and emoji semantics) — verified, no change needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
+10
-2
@@ -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)
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+113
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user