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")