From ccd4677f5d7afe2c9dd02b9ab879e61049c82e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:45:25 +0000 Subject: [PATCH 1/2] fix: render p-tagged NIP-29 group replies on the Notifications tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply to my message inside a joined NIP-29 relay group is a kind-9 ChatEvent that p-tags me (see ChannelNewMessageViewModel). It is already fetched at startup by filterGroupNotificationsToPubkey (scoped `#p`=me + `#h`=my groups on the group's host relay), but NotificationFeedFilter's `acceptableEvent` checks `kind in NOTIFICATION_KINDS` before the p-tag gate, and kind 9 (ChatEvent) was missing from that set — so those replies were dropped before ever reaching the p-tag check and never surfaced. Add ChatEvent.KIND to NOTIFICATION_KINDS. The existing gates handle the rest: my own messages are excluded (author != me), the reply p-tags me (isTaggedUser), and its `q`-tag to my message makes it pass tagsAnEventByUser in Selected mode too. Pin the behaviour with a tripwire test in NotificationKindsContractTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK --- .../dal/NotificationFeedFilter.kt | 7 +++++++ .../dal/NotificationKindsContractTest.kt | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 124e578ad0..8f4cdc9eb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -136,6 +137,12 @@ class NotificationFeedFilter( setOf( BadgeAwardEvent.KIND, ChannelMessageEvent.KIND, + // NIP-29 group chat (kind 9). A reply to my group message is a + // kind-9 that p-tags me (see ChannelNewMessageViewModel), fetched + // at startup by filterGroupNotificationsToPubkey. Without kind 9 + // here the acceptableEvent kind gate drops it before the p-tag + // check, so those replies never render on the Notifications tab. + ChatEvent.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt index 0e49022cf9..886bf711b9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import org.junit.Assert.assertTrue import org.junit.Test @@ -73,4 +74,23 @@ class NotificationKindsContractTest { unaccounted.isEmpty(), ) } + + /** + * A reply to my message inside a NIP-29 relay group is a kind-9 [ChatEvent] + * that p-tags me. It is fetched at startup by `filterGroupNotificationsToPubkey` + * (scoped `#p`=me + `#h`=my groups on the group's host relay), but the + * `acceptableEvent` gate first checks `kind in NOTIFICATION_KINDS`, so without + * kind 9 in the set the reply is dropped before the p-tag check and never + * surfaces on the Notifications tab. Pin its presence so it can't silently + * regress. + */ + @Test + fun `nip-29 group chat replies render on the Android notifications tab`() { + assertTrue( + "ChatEvent.KIND (9) is missing from NOTIFICATION_KINDS. NIP-29 group " + + "replies that p-tag the user would be dropped by the acceptableEvent " + + "kind gate before the p-tag check and never notify.", + ChatEvent.KIND in NotificationFeedFilter.NOTIFICATION_KINDS, + ) + } } From bf2b283cdc0b899c153b40894e1c359e46a9acfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:06:18 +0000 Subject: [PATCH 2/2] fix: keep NIP-29 group reactions in the group so likes notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK --- .../model/nip25Reactions/ReactionAction.kt | 64 +++++++++++-------- .../nip25Reactions/ReactionActionTest.kt | 37 +++++++++++ .../quartz/nip25Reactions/ReactionEvent.kt | 5 ++ 3 files changed, 79 insertions(+), 27 deletions(-) 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() } } }