mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+37
-27
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user