From 8065b045e45e226e0a71b7cf3e6b13d6e774c87a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:06:20 +0000 Subject: [PATCH] fix(concord): build thread replies as kind-1111 NIP-22 comments, not kind-9 quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which clients deliberately keep OUT of threads — a real thread reply is a kind-1111 NIP-22 comment. So our replies rendered (and were sent to Armada) as inline quotes, never grouping into a message's thread. ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags point at the immediate parent (root inherited when the parent is itself a comment, so the root is stable at any depth), plus the same channel/epoch binding every Chat Plane rumor carries. This is byte-compatible with Armada's buildV2CommentTags, so replies thread correctly in both directions. The read path already accepts these (they carry the binding, and consumeConcordRumor handles CommentEvent), so incoming Armada thread replies now land bound to their channel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 4 +-- .../concord/ConcordCommunitySessionTest.kt | 11 +++++-- .../concord/cord03Channels/ChannelChat.kt | 32 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index a724496c75..6ee5ed6dd7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -135,7 +135,7 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } - /** Builds an encrypted-seal reply wrap (kind 9 quoting [parent]) on the [channel] plane. */ + /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ suspend fun buildChannelReply( authorSigner: NostrSigner, channel: GroupKey, @@ -145,7 +145,7 @@ object ConcordActions { text: String, createdAt: Long, ): Event { - val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt) + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 62b3dfba2c..f6090a8207 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -80,12 +81,16 @@ class ConcordCommunitySessionTest { assertEquals("🤙", reaction.content) assertEquals(message.id, reaction.tags.first { it[0] == "e" }[1]) - // A reply decrypts as a kind-9 quoting the parent via a `q` tag. + // A reply decrypts as a kind-1111 NIP-22 thread comment: uppercase `E` at the thread + // root and lowercase `e` at the immediate parent (both the message here), still bound + // to the channel so it groups into the message's thread — the shape Armada threads. val replyWrap = ConcordActions.buildChannelReply(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "gm back", 4L) assertTrue(session.ingest(replyWrap)) val reply = captured.map { it.third }.first { it.content == "gm back" } - assertEquals(9, reply.kind) - assertEquals(message.id, reply.tags.first { it[0] == "q" }[1]) + assertEquals(1111, reply.kind) + assertEquals(message.id, reply.tags.first { it[0] == "E" }[1]) + assertEquals(message.id, reply.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(reply, community.generalChannelIdHex, community.rootEpoch)) // A stray wrap from a different community is ignored. val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index dc337250d1..acc65d34f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -63,26 +65,32 @@ object ChannelChat { ) /** - * Builds an unsigned kind-9 reply rumor bound to [channelId]/[epoch], quoting - * [parentId] (a `q` tag, NIP-C7 style) and crediting its author with a `p` tag. - * Reuses [message], so it is a normal channel message that also threads. + * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to + * [parent], bound to [channelId]/[epoch]. + * + * A thread reply is a NIP-22 comment — NOT a kind-9 message with a `q` tag + * (which NIP-C7 reserves for *inline quotes* that clients deliberately keep out + * of threads). [CommentEvent.replyBuilder] emits the uppercase `K`/`E`/`P` + * pointers at the immutable thread root and the lowercase `k`/`e`/`p` pointers + * at the immediate [parent] (inheriting the root when [parent] is itself a + * comment, so the root is stable at any depth). We add the same + * `["channel", …]` + `["epoch", …]` binding every Chat Plane rumor carries, so + * the reply is verifiable against the plane it arrives on. This is exactly the + * shape Soapbox Armada builds and groups into a message's thread. */ fun reply( authorPubKey: HexKey, channelId: HexKey, epoch: Long, text: String, - parentId: HexKey, - parentAuthor: HexKey, + parent: Event, createdAt: Long, ): Event = - message( - authorPubKey = authorPubKey, - channelId = channelId, - epoch = epoch, - text = text, - createdAt = createdAt, - extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)), + RumorAssembler.assembleRumor( + authorPubKey, + CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { + channelBinding(channelId, epoch) + }, ) /**