Merge pull request #3563 from vitorpamplona/claude/nip29-preload-startup-92ila9

Surface NIP-29 group replies and likes in notifications
This commit is contained in:
Vitor Pamplona
2026-07-14 19:28:48 -04:00
committed by GitHub
5 changed files with 106 additions and 27 deletions
@@ -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,
@@ -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,
)
}
}
@@ -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()
}
}
}