diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index ffeeaff9d3..6f07b1b5ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag /** @@ -64,21 +68,39 @@ object ReactionAction { throw IllegalStateException("Cannot react publicly to a private rumor") } - // Handle custom emoji reactions (format: ":emoji_name:") - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - // Fallback to text if emoji decode fails - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } + return signer.sign(buildPublicReaction(eventHint, reaction)) + } - return signer.sign(template) + /** + * Builds a public reaction template for [eventHint], decoding a custom-emoji + * reaction when present and falling back to plain text otherwise. + * + * When the target is a NIP-29 group event (it carries an `h` tag), the + * reaction copies that `h` tag so the like stays scoped to the group and + * lands on the group's host relay — where the recipient's group-notification + * subscription (`#p`=them + `#h`=their groups, kind 7 included) can match it. + * Without the `h` tag the like is a plain kind-7 that the host-relay query + * never sees, so a reaction to someone's group message would only reach them + * on the off chance NIP-65 routing delivered it to one of their inbox relays + * — never for a host-relay-only group. This mirrors how kind-9 replies carry + * the `h` tag to be notifiable. + */ + private fun buildPublicReaction( + eventHint: EventHintBundle, + reaction: String, + ): EventTemplate { + val groupScope: TagArrayBuilder.() -> Unit = { + eventHint.event.groupId()?.let { hTag(it) } + } + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + return ReactionEvent.build(emojiUrl, eventHint, initializer = groupScope) + } + // Fallback to text if emoji decode fails + } + return ReactionEvent.build(reaction, eventHint, initializer = groupScope) } /** @@ -159,19 +181,7 @@ object ReactionAction { ) } else { // Public reaction - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } - - onPublic(signer.sign(template)) + onPublic(signer.sign(buildPublicReaction(eventHint, reaction))) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt index c17e2aed36..bcf477442a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt @@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail @@ -57,12 +60,46 @@ class ReactionActionTest { publicCalls++ assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed") assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id }) + assertFalse( + reaction.tags.any { it.isNotEmpty() && it[0] == "h" }, + "a reaction to a non-group note must not carry an `h` tag", + ) }, onPrivate = { fail("reaction to a public note must not be gift-wrapped") }, ) assertEquals(1, publicCalls) } + @Test + fun reactionToRelayGroupMessage_carriesTheGroupHTag() = + runTest { + // A NIP-29 group chat message: a kind-9 ChatEvent scoped by `h`. + val groupId = "abcd1234" + val groupMessage = aliceSigner.sign(ChatEvent.build("gm") { hTag(groupId) }) + + var publicCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(groupMessage, null), + reaction = "+", + signer = bobSigner, + onPublic = { reaction -> + publicCalls++ + // Standard NIP-25 targeting … + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == groupMessage.id }) + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "p" && it[1] == aliceSigner.pubKey }) + // … plus the group `h` tag copied from the target, so the like + // stays in the group and the recipient's `#p`+`#h` host-relay + // notification query can match it. + assertTrue( + reaction.tags.any { it.size >= 2 && it[0] == "h" && it[1] == groupId }, + "a reaction to a NIP-29 group message must copy the group's `h` tag", + ) + }, + onPrivate = { fail("a public group message reaction must not be gift-wrapped") }, + ) + assertEquals(1, publicCalls) + } + @Test fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() = runTest { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index 69901c3965..e8b7e9c4a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -88,6 +89,7 @@ class ReactionEvent( reaction: String, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction, createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -95,12 +97,14 @@ class ReactionEvent( } pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) + initializer() } fun build( reaction: EmojiUrlTag, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction.toContentEncode(), createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -109,6 +113,7 @@ class ReactionEvent( pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) emoji(reaction) + initializer() } } }