mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
fix(buzz): thread kind-9 replies into the minichat instead of the channel
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, <channel>] reply kind 9 tags: [h, <channel>], [e, <parent>, "", "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", <id>, <relay>, <pubkey>]` — 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
537b75a451
commit
ec0c4a6c2a
@@ -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))
|
||||
|
||||
+23
-4
@@ -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", <id>, <relay>, <pubkey>]` — 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
|
||||
}
|
||||
|
||||
+99
@@ -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<String>) =
|
||||
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"))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user