Merge remote-tracking branch 'origin/claude/buzz-repo-analysis-7k54ga' into claude/buzz-repo-analysis-7k54ga

This commit is contained in:
Claude
2026-07-23 18:43:47 +00:00
3 changed files with 89 additions and 9 deletions
@@ -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<User, List<Job>>()
@@ -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()
@@ -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<PagingStatus> = 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<NormalizedRelayUrl, List<String>> =
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<NormalizedRelayUrl, List<String>> {
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<NormalizedRelayUrl> = 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))
@@ -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", <id>]` 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", <id>]` — 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/