fix: don't render Marmot reactions/deletions as chat bubbles

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
This commit is contained in:
Claude
2026-04-22 22:07:15 +00:00
parent 6c214b8c13
commit c4e7e0d9b4
3 changed files with 44 additions and 1 deletions
@@ -914,6 +914,20 @@ object LocalCache : ILocalCache, ICacheProvider {
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } 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 -> { else -> {
emptyList() emptyList()
} }
@@ -47,6 +47,7 @@ class MarmotGroupList(
nostrGroupId: HexKey, nostrGroupId: HexKey,
msg: Note, msg: Note,
) { ) {
if (!isDisplayableFeedMessage(msg)) return
val chatroom = getOrCreateGroup(nostrGroupId) val chatroom = getOrCreateGroup(nostrGroupId)
val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey
// Use the quiet path for our own messages so the relay round-trip // Use the quiet path for our own messages so the relay round-trip
@@ -74,6 +75,7 @@ class MarmotGroupList(
nostrGroupId: HexKey, nostrGroupId: HexKey,
msg: Note, msg: Note,
) { ) {
if (!isDisplayableFeedMessage(msg)) return
val chatroom = getOrCreateGroup(nostrGroupId) val chatroom = getOrCreateGroup(nostrGroupId)
if (chatroom.restoreMessageSync(msg)) { if (chatroom.restoreMessageSync(msg)) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
@@ -135,4 +137,27 @@ class MarmotGroupList(
rooms.forEach { key, _ -> result.add(key) } rooms.forEach { key, _ -> result.add(key) }
return result 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
}
} }
+5 -1
View File
@@ -776,7 +776,11 @@ test_09_reply_react_unreact() {
fi fi
step "B replies to the anchor" 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<String>` 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 sleep 3
if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then
record_result "09 reply/react" pass record_result "09 reply/react" pass