fix: keep NIP-29 group reactions in the group so likes notify

A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.

Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
This commit is contained in:
Claude
2026-07-14 23:06:18 +00:00
parent ccd4677f5d
commit bf2b283cdc
3 changed files with 79 additions and 27 deletions
@@ -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<Event>,
reaction: String,
): EventTemplate<ReactionEvent> {
val groupScope: TagArrayBuilder<ReactionEvent>.() -> 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)))
}
}
@@ -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 {
@@ -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<Event>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ReactionEvent>.() -> Unit = {},
) = eventTemplate<ReactionEvent>(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<Event>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ReactionEvent>.() -> Unit = {},
) = eventTemplate<ReactionEvent>(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()
}
}
}