fix: replies to likes and zaps now reach the Curated notification feed

The Curated (Selected) notification mode gates events through
tagsAnEventByUser, which only recognized a reply by its parent note's
author in the local cache. That made replies to the user's reactions
disappear whenever the reaction event wasn't cached (nothing re-fetches
old kind 7s after a restart), and replies to the user's zaps never
matched at all because zap receipts are authored by the lightning
provider. Detect both from the kind 1111 event itself: reaction parents
via the NIP-22 root/reply author tags, zap parents via the k tag plus
the reply's explicit p tag on the user. Covered by
NotificationTagsAnEventByUserTest.

Also renders the embedded target note inside zap and reaction cards with
makeItShort, keeping the inner preview compact.

https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
This commit is contained in:
Claude
2026-06-12 21:23:33 +00:00
parent 46b2147598
commit d271c3fe4b
4 changed files with 154 additions and 0 deletions
@@ -43,6 +43,7 @@ fun RenderReaction(
it,
modifier = Modifier,
isBoostedNote = true,
makeItShort = true,
unPackReply = ReplyRenderType.NONE,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
@@ -79,6 +79,7 @@ fun RenderZappedPost(
it,
modifier = Modifier,
isBoostedNote = true,
makeItShort = true,
unPackReply = ReplyRenderType.NONE,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
@@ -168,6 +168,29 @@ class NotificationFeedFilter(
return true
}
if (event is CommentEvent) {
// NIP-22 comments carry their root/parent authors as tags, so a
// reply to the user's reaction stays detectable even when the
// reaction event is not in the local cache (the replyTo-author
// check above needs it loaded).
if (event.rootAuthorKeys().contains(authorHex) || event.replyAuthorKeys().contains(authorHex)) {
return true
}
// Replies to the user's zaps: the receipt — and therefore the
// author tags above — is signed by the recipient's lightning
// provider, not the zapper. The `k` tag (or the cached parent)
// proves the comment targets a zap; the reply's explicit p tag
// on the user marks it as theirs.
val targetsZapReceipt =
event.hasScopeKind(LnZapEvent.KIND.toString()) ||
note.replyTo?.any { it.event is LnZapEvent } == true
if (targetsZapReceipt && event.isTaggedUser(authorHex)) {
return true
}
}
if ((event is TextNoteEvent || event is CommentEvent)) {
val community =
event
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The "Curated" (Selected) notification mode gates every event through
* [NotificationFeedFilter.tagsAnEventByUser]. Replies to the user's likes and
* zaps are kind:1111 comments whose relevance must be detectable without the
* parent event in the local cache: reaction parents via the NIP-22 root/reply
* author tags, zap parents via the `k` tag plus the explicit p tag (the
* receipt itself is signed by the lightning provider, not the zapper).
*/
class NotificationTagsAnEventByUserTest {
private val me = "1".repeat(64)
private val wallet = "2".repeat(64)
private val replier = "3".repeat(64)
private val reactionId = "a".repeat(64)
private val zapId = "b".repeat(64)
private val replyId = "c".repeat(64)
private val sig = "f".repeat(128)
@Test
fun `reply to my reaction is relevant via nip22 author tags without the parent in cache`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", reactionId, "", me),
arrayOf("K", "7"),
arrayOf("P", me),
arrayOf("e", reactionId, "", me),
arrayOf("k", "7"),
arrayOf("p", me),
),
"lol same",
sig,
)
// Parent reaction NOT in cache: replyTo resolves to an empty note (no author).
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(reactionId))
}
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
@Test
fun `reply to my zap is relevant via k tag and explicit p tag without the receipt in cache`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", zapId, "", wallet),
arrayOf("K", LnZapEvent.KIND.toString()),
arrayOf("P", wallet),
arrayOf("e", zapId, "", wallet),
arrayOf("k", LnZapEvent.KIND.toString()),
arrayOf("p", wallet),
arrayOf("p", me),
),
"thanks for the zap!",
sig,
)
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(zapId))
}
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
@Test
fun `reply to someone else's zap that does not tag me stays irrelevant`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", zapId, "", wallet),
arrayOf("K", LnZapEvent.KIND.toString()),
arrayOf("P", wallet),
arrayOf("e", zapId, "", wallet),
arrayOf("k", LnZapEvent.KIND.toString()),
arrayOf("p", wallet),
),
"nice zap",
sig,
)
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(zapId))
}
assertFalse(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
}