fix(concord): build thread replies as kind-1111 NIP-22 comments, not kind-9 quotes

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-12 20:06:20 +00:00
parent 0bac5c7aca
commit 8065b045e4
3 changed files with 30 additions and 17 deletions
@@ -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)
}
@@ -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"))
@@ -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)
},
)
/**