fix(chats): light the Messages badge for every row type, notify Buzz thread replies

Two halves of the same gap: a row could show its blue dot while nothing above it
agreed.

1. Bottom-bar envelope counted only DMs
------------------------------------------------------------------------------
`messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`,
which opens with `if (newestMessage !is ChatroomKeyable) return null`. Only
NIP-17/NIP-04 DM events implement that interface, so eight of the nine row types
were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot
groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped"
rows. Each of those rows already computed its own dot from its own last-read
route; the badge just never asked.

`rowHasUnreadFlow` now answers, per row, the same question the row composable
answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord
channel can both carry a kind-9, so event kind alone can't tell them apart). It
returns a Flow rather than a (route, createdAt) pair because the two collapsed
rows fan in over every child channel — approximating those by their newest child
would miss an older channel that is still unread.

2. Buzz thread replies notified nothing
------------------------------------------------------------------------------
Buzz's clients thread with `["e", <id>, "", "reply"]` and only ever `p`-tag
@mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep
ly` — the rule that lets a reply notify without a `p` tag — bails unless the
event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified
under nothing. Combined with thread replies now being kept out of the channel
timeline and its unread dot, a reply to my message in a Buzz channel had become
invisible on every surface.

`isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions
(`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author
of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately
only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a
thread. It is OR'd into the same three gates the reaction case uses, so a reply
from a channel member I don't follow still notifies.

Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but
legacy Buzz thread replies exist and were being dropped at the kind gate before
any relevance check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-26 16:22:22 -04:00
co-authored by Claude Opus 5
parent 4f6e16a74e
commit 07e3f21509
3 changed files with 158 additions and 31 deletions
@@ -76,7 +76,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientB
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
@@ -105,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
@@ -440,6 +440,15 @@ class AccountViewModel(
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(30000), false)
/**
* The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot.
*
* Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself.
* This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not
* `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash
* cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot
* while the envelope stayed clean.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val messagesHasNewItems =
feedStates.dmKnown.feedContent
@@ -450,14 +459,7 @@ class AccountViewModel(
MutableStateFlow(null)
}
}.flatMapLatest { loadedFeedState ->
val flows =
loadedFeedState?.list?.mapNotNull { chat ->
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
account.settings.getLastReadFlow(route).map { lastReadAt ->
createdAt > lastReadAt
}
}
}
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) }
if (!flows.isNullOrEmpty()) {
combine(flows) { newItems ->
@@ -466,20 +468,6 @@ class AccountViewModel(
} else {
MutableStateFlow(false)
}
}.onStart {
val feed = feedStates.dmKnown.feedContent.value
if (feed is FeedState.Loaded) {
val newItems =
feed.feed.value.list.any { chat ->
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
val lastReadAt =
account.settings.lastReadPerRoute.value[route]
?.value ?: 0L
createdAt > lastReadAt
} == true
}
emit(newItems)
}
}
val messagesHasNewItemsFlow =
@@ -2203,8 +2191,6 @@ class AccountViewModel(
}
}
private fun unreadPrivateChatRoute(chat: Note): Pair<String, Long>? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden)
private fun markHiddenChatroomsAsRead() {
account.chatroomList.rooms.forEach { roomKey, chatroom ->
if (account.isAllHidden(roomKey.users)) {
@@ -0,0 +1,100 @@
/*
* 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.ui.screen.loggedIn.chats.rooms
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Whether one Messages row is showing a blue dot — the SAME question each row composable answers for
* itself in [ChatroomHeaderCompose], hoisted so the bottom-bar badge can ask it about every row.
*
* The badge used to run on `unreadPrivateChatRoute` alone, which opens with
* `if (newestMessage !is ChatroomKeyable) return null`. Only NIP-17/NIP-04 DM events implement that
* interface, so eight of the nine row types — public chats, ephemeral rooms, geohash cells, Marmot
* groups, NIP-29/Buzz channels and Concord channels, plus both collapsed "grouped" rows — were
* silently skipped: their row could show a dot while the envelope stayed clean.
*
* Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone
* hidden), so callers can skip it rather than subscribe to a flow that is always false.
*
* The two collapsed rows are why this returns a `Flow<Boolean>` rather than a `(route, createdAt)`
* pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and
* approximating them by the newest child would miss an older channel that is still unread.
*/
fun rowHasUnreadFlow(
row: Note,
account: Account,
): Flow<Boolean>? {
// Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim.
if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay)
if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId)
val route = rowLastReadRoute(row, account) ?: return null
val createdAt = row.createdAt() ?: return null
return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt }
}
/**
* The last-read marker route behind a row's dot, mirroring what each row composable loads. Channel-type
* rows are identified by their gatherer (the channel the note was filed into) rather than by event kind,
* because a Buzz channel and a Concord channel can both carry a kind-9 message.
*/
private fun rowLastReadRoute(
row: Note,
account: Account,
): String? {
row.inGatherers?.forEach { gatherer ->
when (gatherer) {
is RelayGroupChannel -> return relayGroupChannelLastReadRoute(gatherer.groupId)
is ConcordChannel -> return concordChannelLastReadRoute(gatherer.channelId.communityId, gatherer.channelId.channelId)
is MarmotGroupChatroom -> return marmotGroupLastReadRoute(gatherer.nostrGroupId)
is GeohashChatChannel -> return "Geohash/${gatherer.geohash}"
else -> Unit
}
}
return when (val event = row.event) {
// Same route strings the row composables use — see ChatroomHeaderCompose.
is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" }
is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" }
is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" }
// DMs keep their own rule: a room whose newest message is mine counts as read.
else -> unreadPrivateChatRoute(row.event, account.signer.pubKey, account::isAllHidden)?.first
}
}
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
@@ -148,10 +150,16 @@ class NotificationFeedFilter(
// filterGroupNotificationsToPubkey.
// • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention
// p-tags me; a minichat reply is a kind-1111 CommentEvent below.
// Either way it notifies only when it p-tags me — a plain channel message tags no one
// and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these
// replies before the p-tag check, so they'd never render on the Notifications tab.
// • Buzz channels: a THREAD reply carries no `p` tag at all (Buzz only p-tags
// @mentions), so it qualifies through [isBuzzThreadReplyToMyEvent] instead, which
// resolves the author of its `root`/`reply` marked `e` tags.
// A plain channel message tags no one and matches none of those, so it never reaches
// here. Without kind 9 the acceptableEvent kind gate would drop these replies before
// any of the checks, so they'd never render on the Notifications tab.
ChatEvent.KIND,
// Legacy Buzz thread replies (nothing writes 40002 any more, but they exist in the
// wild). Same no-`p`-tag shape as kind 9 above.
StreamMessageV2Event.KIND,
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
CommentEvent.KIND,
@@ -414,6 +422,36 @@ class NotificationFeedFilter(
?.pubkeyHex == me
}
/**
* A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag.
*
* Buzz's clients thread with `["e", <id>, "", "reply"]` (nested: `root` + `reply`) and only ever
* `p`-tag @mentions, so a reply to my message names me nowhere. That is the same shape as a Buzz
* reaction, which [isReactionToMyEvent] already rescues by resolving the target's author instead of
* trusting a tag — this does the same for replies, looking at the author of the `root`/`reply`
* targets specifically (never a bare `e`, which is WhiteNoise/Marmot's in-chat reply, not a thread).
*
* Without it a reply to my message in a Buzz channel notifies nothing — and since a thread reply is
* deliberately kept out of the channel timeline and its unread dot
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage]),
* it would be invisible on every surface.
*
* Only consulted when there is no `p` tag: a reply that does name me already passes the p-tag gate.
*/
private fun isBuzzThreadReplyToMyEvent(
note: Note,
me: HexKey,
): Boolean {
val event = note.event
if (event !is ChatEvent && event !is StreamMessageV2Event) return false
if (event.tags.any { it.getOrNull(0) == "p" }) return false
val threadTargets = setOfNotNull(event.tags.buzzThreadReply(), event.tags.buzzThreadRoot())
if (threadTargets.isEmpty()) return false
return note.replyTo?.any { it.idHex in threadTargets && it.author?.pubkeyHex == me } == true
}
fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
@@ -534,6 +572,9 @@ class NotificationFeedFilter(
// exactly like a Concord reaction, since being the author of the liked post is the only signal.
val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex)
// Same no-`p`-tag rescue for Buzz thread replies into my messages.
val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(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).
@@ -552,14 +593,14 @@ class NotificationFeedFilter(
// to genuine replies, so unrelated channel chatter never leaks through.
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) &&
(isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
(isChessEvent || isConcord || isReactionToMe || isThreadReplyToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe || isThreadReplyToMe) &&
(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/
// mention targets me directly), so skip the per-kind heuristic — which for a reaction would
// otherwise need my target message already loaded to resolve replyTo.
(isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex))
(isRawGlobal || isConcord || isThreadReplyToMe || tagsAnEventByUser(it, loggedInUserHex))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()