From 8982af5752b5f0120f97501a7f882cab6ce5df91 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 23 Jul 2026 12:32:35 -0400 Subject: [PATCH 1/2] feat(buzz): notify on reactions/zaps to my Buzz DM messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-notification query (reactions/zaps/reposts on my messages inside a NIP-29 group, kind-7 etc. scoped #p=me + #h on the host relay) sourced its #h set from the published kind-10009 group list — which deliberately excludes Buzz DM channels (membership is server-side, tracked in BuzzDmChannels). So a reaction on my DM message was only ever loaded as a chip inside the open conversation, never surfacing on the Notifications tab. Include DM channels (from BuzzDmChannels, minus hidden ones) alongside the joined groups in both the live-tail (AccountNotificationsEoseFromInboxRelays- Manager) and backward-history (AccountNotificationsHistoryEoseManager) queries, reusing the same filterGroupNotificationsToPubkey builder, and re-subscribe when a DM is discovered/hidden. Regular-channel reactions already worked via the joined-group path; this closes the DM gap the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...NotificationsEoseFromInboxRelaysManager.kt | 33 ++++++++++++++++- .../AccountNotificationsHistoryEoseManager.kt | 35 +++++++++++++++---- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 0008693396..06d29d6bbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -99,7 +101,28 @@ class AccountNotificationsEoseFromInboxRelaysManager( ) } - return inbox + groups + // Buzz DM channels are NOT in the published group list (membership is server-side, tracked in + // BuzzDmChannels), so the joined-group query above skips them. Poll each DM host relay the same + // way — `#p` = me + `#h` = my DM channels — so a reaction/zap/repost on my DM message surfaces + // in notifications, not only as a chip inside the open conversation. Hidden DMs are excluded. + val myPubkey = user(key).pubkeyHex + val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey) + val dmGroups = + BuzzDmChannels + .channelsFor(myPubkey) + .filterKeys { it !in hiddenDms } + .entries + .groupBy({ it.value }, { it.key }) + .flatMap { (relay, channelIds) -> + filterGroupNotificationsToPubkey( + relay = relay, + pubkey = myPubkey, + groupIds = channelIds.distinct(), + since = since?.get(relay)?.time ?: pagingBoundary, + ) + } + + return inbox + groups + dmGroups } val userJobMap = mutableMapOf>() @@ -122,6 +145,14 @@ class AccountNotificationsEoseFromInboxRelaysManager( invalidateFilters() } }, + // Re-subscribe when a Buzz DM is discovered/hidden so its host relay is polled for + // reactions/zaps on my DM messages. + key.account.scope.launch(Dispatchers.IO) { + BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() } + }, key.account.scope.launch(Dispatchers.IO) { key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt index 32dcb9d932..485f8fb9f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account @@ -82,12 +84,26 @@ class AccountNotificationsHistoryEoseManager( val status: StateFlow = pager.status // Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the - // group filter and (its keys) to add group host relays to the paged relay set. - private fun groupsByRelay(account: Account): Map> = - account.relayGroupList.liveRelayGroupList.value - .groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId }) - .mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } } - .toMap() + // group filter and (its keys) to add group host relays to the paged relay set. Buzz DM channels aren't + // in the published list (server-side membership, tracked in BuzzDmChannels), so include them here too — + // otherwise paging back never loads older reactions/zaps on my DM messages. Hidden DMs are excluded. + private fun groupsByRelay(account: Account): Map> { + val myPubkey = account.userProfile().pubkeyHex + val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey) + + val listGroups = + account.relayGroupList.liveRelayGroupList.value + .mapNotNull { tag -> RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { it to tag.groupId } } + val dmGroups = + BuzzDmChannels + .channelsFor(myPubkey) + .filterKeys { it !in hiddenDms } + .map { (channelId, relay) -> relay to channelId } + + return (listGroups + dmGroups) + .groupBy({ it.first }, { it.second }) + .mapValues { (_, ids) -> ids.distinct() } + } // The full relay set this account pages notifications back through: inbox relays + group host relays. private fun notificationRelaySet(account: Account): Set = account.notificationRelays.flow.value + groupsByRelay(account).keys @@ -159,6 +175,13 @@ class AccountNotificationsHistoryEoseManager( .sample(1000) .collectLatest { invalidateFilters() } }, + // A Buzz DM discovered/hidden adds or drops its host relay from the paged set. + key.account.scope.launch(Dispatchers.IO) { + BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() } + }, ) return requestNewSubscription(historyListener(key)) From 3a9d9a7b5d0efbd68acc6204e29a165b69036c4d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 23 Jul 2026 13:07:45 -0400 Subject: [PATCH 2/2] fix(buzz): show bare Buzz reactions (e-tag only) in Notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Buzz like is a bare kind-7 `["e", ]` — no `p` tag (unlike NIP-25, which p-tags the reacted author) and no `h` tag. The notifications filter's p-tag gate and follow filter therefore both dropped it, so a like on my Buzz message never surfaced even in Global mode, despite the reaction being loaded (it rendered as a chip in the open conversation). Recognize a reaction/repost that carries NO `p` tag whose reacted target — the last `e` tag, already loaded — is my own note, and let it bypass the follow filter and satisfy the p-tag gate (like a Concord reaction). Scoped to the no-`p`-tag case so well-formed NIP-25 reactions keep their normal routing, and to an already-loaded target so it can't accept blindly. The per-kind gate already resolves the same target author, so it needs no change. Verified on emulator: a 👍 + ❤️ on my Buzz DM message now appears on the Curated notifications tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dal/NotificationFeedFilter.kt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 952ade956e..7288424f8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -390,6 +390,27 @@ class NotificationFeedFilter( return md.isBuzzDm() && md.buzzParticipants().contains(me) } + /** + * A reaction/repost that carries NO `p` tag — so neither the follow filter nor the p-tag gate can + * route it — but whose reacted target (the LAST `e` tag, already loaded) is MY note. Buzz likes are a + * bare `["e", ]` with no author `p` tag (unlike NIP-25, which p-tags the reacted author), so being + * the author of the liked post is the only relevance signal. Restricted to the no-`p`-tag case so a + * well-formed reaction keeps its normal p-tag routing, and to an already-loaded target so it can't + * accept blindly. + */ + private fun isReactionToMyEvent( + note: Note, + me: HexKey, + ): Boolean { + val event = note.event + if (event !is ReactionEvent && event !is RepostEvent && event !is GenericRepostEvent) return false + if (event.tags.any { it.getOrNull(0) == "p" }) return false + return note.replyTo + ?.lastOrNull() + ?.author + ?.pubkeyHex == me + } + fun acceptableEvent( it: Note, filterParams: FilterByListParams, @@ -502,6 +523,11 @@ class NotificationFeedFilter( val isConcord = isConcordMessage || isConcordReaction + // A bare reaction/repost (no p-tag) to my own already-loaded note — e.g. a Buzz like, which is + // just `["e", ]` — is relevant to me: bypass the follow filter and satisfy the p-tag gate, + // exactly like a Concord reaction, since being the author of the liked post is the only signal. + val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex) + // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so // Concord reactions do too (only isConcordMessage is gated). @@ -520,8 +546,8 @@ class NotificationFeedFilter( // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && - (isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && - (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && + (isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && + (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && // For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/