From c4e7e0d9b46f517c1b672293d10a666fce872a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:07:15 +0000 Subject: [PATCH] fix: don't render Marmot reactions/deletions as chat bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhiteNoise threads its kind:445 payloads across three inner kinds: kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The Amethyst ingest pipeline was routing every inner event into the group chatroom feed, so a kind:7 reaction rendered as a chat bubble whose only content was the emoji, quoting the liked message via the target `e` tag. That looked identical to a threaded reply. The actual kind:9 reply from `wn messages send --reply-to` never arrived, which made the two look swapped in the UI. Three changes to untangle this: - MarmotGroupList.addMessage/restoreMessage now skip inner events with kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`. The reaction is still consumed by LocalCache so it attaches to the target note's reaction row, and the deletion still revokes that reaction — they just don't appear as standalone bubbles. - LocalCache.computeReplyTo learned to derive thread parents for `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies; without this the reply bubble had no quote context in the feed. - tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes its `reply_to` field through clap v4, which renames snake_case to kebab-case by default. The script was passing `--reply_to`, which clap rejected; the `|| true` + redirected stderr hid the error and no reply was ever published. Use `--reply-to`. https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32 --- .../amethyst/model/LocalCache.kt | 14 +++++++++++ .../model/marmotGroups/MarmotGroupList.kt | 25 +++++++++++++++++++ tools/marmot-interop/marmot-interop.sh | 6 ++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ac8d998f3..a6af8229c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -914,6 +914,20 @@ object LocalCache : ILocalCache, ICacheProvider { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } + is ChatEvent -> { + // Amethyst's own kind:9 replies carry the parent as a NIP-18 + // `q` tag (see ChatEvent.replyingTo), but the broader Marmot + // ecosystem — WhiteNoise in particular — threads kind:9 chats + // with a plain NIP-10 `e` tag. Accept both so inbound replies + // from either client show their quote bubble in the feed. + val eTagTargets = + event.tags + .filter { it.size > 1 && it[0] == "e" } + .map { it[1] } + val qTagTargets = event.quotedEvents().map { it.eventId } + (eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) } + } + else -> { emptyList() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index db34fd791..5e622ead4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -47,6 +47,7 @@ class MarmotGroupList( nostrGroupId: HexKey, msg: Note, ) { + if (!isDisplayableFeedMessage(msg)) return val chatroom = getOrCreateGroup(nostrGroupId) val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey // Use the quiet path for our own messages so the relay round-trip @@ -74,6 +75,7 @@ class MarmotGroupList( nostrGroupId: HexKey, msg: Note, ) { + if (!isDisplayableFeedMessage(msg)) return val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.restoreMessageSync(msg)) { noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } @@ -135,4 +137,27 @@ class MarmotGroupList( rooms.forEach { key, _ -> result.add(key) } return result } + + /** + * True if this inner event should appear as its own bubble in the group + * chat feed. Side-channel kinds (reactions, deletions) must still be + * consumed into LocalCache — they drive the reaction row on the target + * note and, for kind:5, revoke a prior reaction — but they must NOT show + * up as standalone messages. + * + * Needed because WhiteNoise emits plain kind:7 reactions (emoji content + + * `e` tag) and kind:5 unreacts inside kind:445, and the Marmot pipeline + * blindly routed every inner event into the chatroom. The reaction then + * rendered as a chat bubble containing just the emoji, with a quoted + * citation of the target message — which reads exactly like a reply. + */ + private fun isDisplayableFeedMessage(msg: Note): Boolean { + val kind = msg.event?.kind ?: return true + return kind != MARMOT_INNER_KIND_REACTION && kind != MARMOT_INNER_KIND_DELETION + } + + companion object { + private const val MARMOT_INNER_KIND_DELETION = 5 + private const val MARMOT_INNER_KIND_REACTION = 7 + } } diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index 876572cd6..4674fbe0c 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -776,7 +776,11 @@ test_09_reply_react_unreact() { fi step "B replies to the anchor" - wn_b messages send "$gid" "replying via wn" --reply_to "$msg_id" >/dev/null 2>&1 || true + # clap v4 converts snake_case fields to kebab-case flags by default, so the + # `reply_to: Option` field on `wn messages send` exposes as + # `--reply-to`. Passing `--reply_to` is silently rejected (the script's + # `|| true` hides the error) and the reply is never actually published. + wn_b messages send "$gid" "replying via wn" --reply-to "$msg_id" >/dev/null 2>&1 || true sleep 3 if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then record_result "09 reply/react" pass