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) + }, ) /**