From e43c752a07682fdfca5b0aace7321ac912c3572b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:36:10 +0000 Subject: [PATCH] fix: keep group thread replies scoped to the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying to a NIP-29 group thread went through the shared NIP-22 comment composer, which built a plain kind-1111 comment with no group `h` tag and broadcast it to the author's outbox. Such a reply is not group content: the host relay rejects it and no other member — or other NIP-29 client like Flotilla — ever sees it, and for a private/closed group it leaks to unrelated relays. Both fixes are tightly guarded on the replied-to event being group-scoped, so ordinary comments are untouched: - CommentPostViewModel inherits the group's `h` tag from the event being replied to (covers replies to the kind-11 root and to nested 1111 comments — both route here). - The reply is published only to the group's host relay (the relay the thread was seen on) via signAndSendPrivatelyOrBroadcast, instead of the outbox-computing broadcast — so it reaches the group and never leaks. Verified: quartz test builds the reply as the composer does (CommentEvent.replyBuilder { hTag } over a kind-11 root) and asserts it is a 1111 carrying the group `h` tag and referencing the thread. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../nip22Comments/CommentPostViewModel.kt | 21 +++++++++++++++++ .../Nip29ArmadaInteropTest.kt | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3a9df00e63..77d54cbd59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -91,6 +91,9 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis @@ -520,8 +523,20 @@ open class CommentPostViewModel : val anonymous = wantsAnonymousPost cancel() + // A reply within a NIP-29 group is group content: pin it to the group's + // host relay (the relay the thread was seen on) instead of the author's + // outbox, so it reaches the group and — for a private/closed group — is + // never leaked to unrelated relays. + val groupHostRelays = + replyingTo + ?.takeIf { it.event?.isGroupScoped() == true } + ?.relays + ?.takeIf { it.isNotEmpty() } + if (anonymous) { accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) + } else if (groupHostRelays != null) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { groupHostRelays } } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } @@ -577,6 +592,12 @@ open class CommentPostViewModel : msg = tagger.message, replyingTo = eventHint, ) { + // A reply inside a NIP-29 group thread must carry the group's `h` + // tag, or the host relay won't accept it and no other member (or + // other NIP-29 client, e.g. Flotilla) will see it. Inherit it from + // the event being replied to; a no-op for non-group comments. + replyingToEvent?.groupId()?.let { hTag(it) } + val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList() val extraNotificationAuthors = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index fcb3374944..0d2bffb626 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.nip29RelayGroups import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent @@ -332,6 +334,27 @@ class Nip29ArmadaInteropTest { assertFalse(put.any { it[0] == "previous" }) } + @Test + fun buildsGroupThreadReplyWithHTagAndRootReference() { + // A reply to a kind-11 group thread must be a 1111 comment that BOTH roots + // at the thread AND carries the group `h` tag — the exact composition the + // Android composer builds (CommentEvent.replyBuilder { hTag(...) }). + val thread = + parse( + ThreadEvent.KIND, + arrayOf(arrayOf("h", gid), arrayOf("title", "T")), + content = "body", + pubKey = alice, + ) as ThreadEvent + + val reply = CommentEvent.replyBuilder("nice", EventHintBundle(thread)) { hTag(gid) } + + assertEquals(CommentEvent.KIND, reply.kind) + assertEquals(gid, reply.tags.hTag()) + // References the thread it replies to (NIP-22 root scope carries the id). + assertTrue(reply.tags.any { it.size > 1 && it[1] == thread.id }) + } + @Test fun buildsJoinLeaveAndGroupScopedChat() { val join = JoinRequestEvent.build(gid, reason = "hi", inviteCode = "inv9")