From ec0c4a6c2a95d32d23f7325cff36bfd3671347cb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 15:13:05 -0400 Subject: [PATCH 1/3] fix(buzz): thread kind-9 replies into the minichat instead of the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply written by any current Buzz client is a kind-9 carrying a NIP-10 `reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel with the parent quoted above it, and it never appeared in the thread on its parent — the exact inverse of where Buzz puts it. Observed on the wire: parent kind 9 tags: [h, ] reply kind 9 tags: [h, ], [e, , "", "reply"] `isMinichatReply` is the single definition three consumers share — the channel timeline filter drops these, the reply-count chip counts them, and the minichat feed shows them — but it was type-gated to CommentEvent (1111) and StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`. Every downstream behaviour followed from that one gap: the reply stayed in the timeline, the parent showed no "N replies" chip, and the thread was empty. Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one reply mechanism for kind 9 — `["q", , , ]` — and never mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote a thread reply. Matching on the MARKER (never the bare tag) is what keeps WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`, which is an in-chat reply and must keep rendering as a quote bubble. Tests pin all four cases: marked direct, marked nested (root+reply), unmarked, and `q`. Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes 40002 any more: every send path in their mobile, desktop and CLI clients emits kind 9, the ~50 remaining references are all reads (filter kind lists, feed query sets, archive constants), and their NOSTR.md grades kind:9 as supported against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was the last active writer — so our replies threaded nowhere but our own client. We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags` (direct -> one `reply` marker; nested -> `root` + `reply`), which is what `buzzThread` already produced. Reading 40002 stays supported for events already in the wild, including the ones we wrote. Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every `e` tag to the parent. That link is what populates `note.replies`, which the thread and the chip read — discriminating there would unlink Buzz replies from their parents. The marker distinction belongs in rendering, not in linkage. Verified on device against a real thread: the reply now sits in the thread on "howd you get bumble working?" with a "3 replies" chip on the parent, and is gone from the channel timeline. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 21 +++- .../ui/screen/loggedIn/chats/MinichatReply.kt | 27 ++++- .../loggedIn/chats/MinichatReplyTest.kt | 99 +++++++++++++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c0a35d5f47..606a095faf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -165,7 +165,6 @@ import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent -import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot @@ -2438,12 +2437,24 @@ class Account( val hostRelay = group.groupId.relayUrl val signed = if (BuzzRelayDialect.isBuzz(hostRelay)) { - // Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's - // root (never `broadcast` — a minichat reply always lives in the thread). Attached - // media is carried as URLs appended to the content (no `imeta` on the stream event). + // Buzz rejects kind-1111, so its minichat threads with a NIP-10 `reply`-marked `e` + // on a plain kind-9 chat — byte-identical to `_buildReplyTags` in Buzz's own client + // (direct reply -> one `reply` marker; nested -> `root` + `reply`), which is what + // [buzzThread] emits. + // + // This used to write kind-40002. Nothing in Buzz writes 40002 any more — every send + // path in their mobile, desktop and CLI clients emits kind 9, and their NOSTR.md + // grades 40002 "Buzz-only — no standard NIP-29 client renders these" against kind 9's + // blessed status. 40002 survives only as a read-compat tail from the + // 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind + // their clients no longer thread on. Reading 40002 stays supported (see + // [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]). + // + // Attached media rides as URLs appended to the content. val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id signer.sign( - StreamMessageV2Event.build(group.groupId.id, finalText) { + ChatEvent.build(finalText) { + hTag(group.groupId.id) buzzThread(root, rootEvent.id) rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } previous(group.previousEventRefs(pubKey)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt index 2434a67e12..c6b8d8d4f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt @@ -24,15 +24,33 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** * Whether [event] is a **minichat thread reply** — a reply that lives inside the thread opened from - * its parent message, NOT as a flat sibling in the main timeline. Two dialects express the same idea: + * its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea: * * - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent]. - * - **Buzz workspaces**: a kind-40002 [StreamMessageV2Event] carrying a NIP-10 `reply`-marked `e` tag - * and NOT flagged `broadcast` (Buzz rejects kind-1111, so it threads chat with 40002 markers; a - * `broadcast=1` reply is an inline timeline sibling, matching block/buzz's `isThreadReply`). + * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This + * is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits + * `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + + * `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9. + * - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT + * flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it + * "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the + * 10002 -> 40001 -> 40002 migration, and Amethyst itself wrote some, so it stays readable. + * + * ### Why a marked `e` and not `q` + * + * NIP-C7 gives kind 9 exactly one reply mechanism — `["q", , , ]` — and never + * mentions `e` at all. So a marked `e` carries no C7 meaning and is free to denote a *thread* reply, + * which is precisely how Buzz uses it. The marker is what separates the cases: WhiteNoise/Marmot + * thread kind-9 chat with a **plain, unmarked** `e`, which is an in-chat reply and must keep rendering + * as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves + * that dialect untouched. + * + * A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's + * `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only. * * The timeline filter drops these (they belong in the minichat), the minichat count counts them, and * the minichat feed shows them — so all three agree on one definition. @@ -40,6 +58,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent fun isMinichatReply(event: Event?): Boolean = when (event) { is CommentEvent -> true + is ChatEvent -> event.tags.buzzThreadReply() != null is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null else -> false } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt new file mode 100644 index 0000000000..acb9de2a98 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt @@ -0,0 +1,99 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A kind-9 chat message is a thread reply only when its `e` tag carries a NIP-10 marker. + * + * Three conventions share kind 9 and must not be confused: + * - **NIP-C7** spends `q` on the in-chat reply and never mentions `e` at all, which is what leaves a + * marked `e` free to mean "thread reply". + * - **WhiteNoise / Marmot** thread chat with a **plain, unmarked** `e` — an *in-chat* reply that has to + * keep rendering as a quote bubble in the timeline. + * - **Buzz** threads with `["e", id, "", "reply"]` (nested: `root` + `reply`), which belongs in the + * minichat and must be dropped from the channel timeline. + * + * Getting this wrong is what put a Buzz thread reply in the main channel as a quote instead of in the + * thread on its parent. + */ +class MinichatReplyTest { + private val parentId = "1a05130cc86929f267747b17761d5873a95dbab66d5298c38a352bdfd0edc730" + private val rootId = "bf2e60b69fdf6bf3aa11223344556677889900aabbccddeeff00112233445566" + private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a" + + private fun chat(vararg tags: Array) = + ChatEvent( + id = "id", + pubKey = "pk", + createdAt = 1L, + tags = arrayOf(arrayOf("h", channel), *tags), + content = "hi", + sig = "sig", + ) + + /** The exact shape observed on the wire from Buzz's client for a direct reply. */ + @Test + fun `buzz direct reply - reply-marked e tag - is a thread reply`() { + assertTrue(isMinichatReply(chat(arrayOf("e", parentId, "", "reply")))) + } + + /** Nested reply: `root` + `reply`, matching Buzz's `_buildReplyTags`. */ + @Test + fun `buzz nested reply - root plus reply markers - is a thread reply`() { + assertTrue( + isMinichatReply( + chat(arrayOf("e", rootId, "", "root"), arrayOf("e", parentId, "", "reply")), + ), + ) + } + + /** + * Regression: WhiteNoise/Marmot use a bare `e`, which is an *in-chat* reply. Matching on the tag + * rather than the marker would swallow those into the minichat and empty the timeline. + */ + @Test + fun `whitenoise unmarked e tag stays an in-chat reply`() { + assertFalse(isMinichatReply(chat(arrayOf("e", parentId)))) + assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "")))) + } + + /** NIP-C7's own reply mechanism renders inline, not in a thread. */ + @Test + fun `nip-c7 q tag reply stays an in-chat reply`() { + assertFalse(isMinichatReply(chat(arrayOf("q", parentId, "", "pk")))) + } + + @Test + fun `a plain top-level chat message is not a thread reply`() { + assertFalse(isMinichatReply(chat())) + } + + /** A `root`-only marker (no `reply`) is a thread root reference, not a reply to that message. */ + @Test + fun `root marker alone is not a reply`() { + assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root")))) + } +} From 4f6e16a74e6e4d96081280fef166a9c6d2fe223c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 16:00:49 -0400 Subject: [PATCH 2/3] fix(nip29): line up preview, timeline and unread on one predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minichat thread reply was showing as a group's "last message" on Messages, and lighting its unread dot, even though the channel timeline correctly hides it. Three surfaces disagreed about what counts as a message: channel timeline ChannelFeedFilter !isMinichatReply(..) && isAcceptable Messages preview newestChatNote isGroupChatContent() && isAcceptable unread dot hasChatNewerThan isGroupChatContent() && isAcceptable So a reply that the channel deliberately routes to its thread still became the row summary, and still lit the dot — you would open the group, see nothing new, and watch the dot clear. The Concord side already solved exactly this by sharing one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this gives NIP-29 the same treatment: isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable Now used by: - `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview, in both INLINE and GROUPED view modes - both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot bump a row either - `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread Flow`, also used by the workspace channel list), the collapsed per-relay dot (`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row dot, which reads the createdAt of the note the preview picked The bottom-bar Messages badge follows automatically: it derives from the feed rows themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns null for anything that isn't ChatroomKeyable — which is a separate, pre-existing scope decision, untouched here.) Verified on device by creating the case rather than waiting for it: posted a reply into a thread so it became the newest event in the channel. The thread shows it (chip 4 -> 5 replies), the channel timeline does not, and the Messages row still previews the newest timeline event. Before this change that reply would have been the row summary. The earlier screenshot only looked right because a system message happened to be newer. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayGroup/RelayGroupUnread.kt | 37 ++++++++++++++++++- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 20 ++++------ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 75659f454e..a26ea3dc1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay 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.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -78,11 +81,41 @@ fun relayGroupServerHasUnreadFlow( } }.distinctUntilChanged() -/** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */ +/** + * Whether [note] is one of this group's **timeline** messages — what the channel feed renders, what + * the Messages row previews, and what the unread dot counts. + * + * Two exclusions, and both matter for the same reason: the row summary must not disagree with what + * opening the channel shows. + * - Non-content (`reaction`/deletion/label) carries the group's `h` tag too, so [isGroupChatContent] + * gates it out — a trailing 👍 must not become the "last message". + * - A **minichat thread reply** lives in the thread opened from its parent, not in the timeline + * ([isMinichatReply], the same predicate `ChannelFeedFilter` uses). Without this the Messages row + * previews a reply the channel never displays, and the unread dot lights for activity that leaves + * the timeline unchanged — you open the group, see nothing new, and the dot clears. + * + * The Concord side solves this identically with `isConcordTimelineMessage`. + */ +fun isRelayGroupTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event?.isGroupChatContent() == true && !isMinichatReply(note.event) && account.isAcceptable(note) + +/** + * The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none — + * the note the Messages row shows as the group's "last message". + */ +fun RelayGroupChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isRelayGroupTimelineMessage(note, account) } + .sortedByDefaultFeedOrder() + .firstOrNull() + +/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */ private fun RelayGroupChannel.hasChatNewerThan( account: Account, sinceSecs: Long, ): Boolean = notes.count { _, note -> - (note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true + (note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account) } > 0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 5ec4afb1a3..af44f5e0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -157,7 +159,7 @@ class ChatroomListKnownFeedFilter( // Newest loaded chat message, or a placeholder row so a just-joined group shows // up on Messages before its first kind-9 arrives (mirrors the Marmot-group path // above). Content kinds only — never a reaction/deletion as the "last message". - channel.newestChatNote(account) ?: channel.placeholderNote() + channel.newestTimelineNote(account) ?: channel.placeholderNote() } RelayGroupViewMode.GROUPED -> @@ -169,7 +171,7 @@ class ChatroomListKnownFeedFilter( val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null val newest = tags - .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) } + .mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestTimelineNote(account) } .maxByOrNull { it.createdAt() ?: 0L } RelayGroupServerRoomNote(relay, newest) } @@ -489,13 +491,6 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } - /** The newest actual chat message loaded in this group's channel, or null if none yet. */ - private fun RelayGroupChannel.newestChatNote(account: Account): Note? = - notes - .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } - .sortedByDefaultFeedOrder() - .firstOrNull() - /** * The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet. * Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author) @@ -540,8 +535,8 @@ class ChatroomListKnownFeedFilter( val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId } val result = mutableMapOf() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() - if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) { + val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() + if (gid != null && gid in joinedGroupIds) { val lastNote = result[gid] if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { result[gid] = newNote @@ -559,9 +554,8 @@ class ChatroomListKnownFeedFilter( // Newest new message per host relay, collapsed into one per-relay row. val newestPerRelay = HashMap() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach + val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() ?: return@forEach val relay = groupToRelay[gid] ?: return@forEach - if (!account.isAcceptable(newNote)) return@forEach val lastNote = newestPerRelay[relay] if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { newestPerRelay[relay] = newNote From 07e3f21509685aab356ca5f6d585a1ed4673fbb8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 16:22:22 -0400 Subject: [PATCH 3/3] fix(chats): light the Messages badge for every row type, notify Buzz thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", , "", "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) --- .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++----- .../loggedIn/chats/rooms/ChatroomRowUnread.kt | 100 ++++++++++++++++++ .../dal/NotificationFeedFilter.kt | 53 ++++++++-- 3 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2844127ec5..c3e4633ab2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) - private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> if (account.isAllHidden(roomKey.users)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt new file mode 100644 index 0000000000..f84fa24049 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt @@ -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` 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? { + // 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 + } +} 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 f4ac0d4f2c..4685312f59 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 @@ -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", , "", "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): List = items.sortedByDefaultFeedOrder()