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")))) + } +}