mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+7
@@ -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,
|
||||
|
||||
+20
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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