fix: keep group thread replies scoped to the group

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 13:36:10 +00:00
parent 2fb6e3cb2f
commit e43c752a07
2 changed files with 44 additions and 0 deletions
@@ -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 =
@@ -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")