diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index f29e023151..5ffd2f8b9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -43,6 +43,7 @@ fun RenderReaction( it, modifier = Modifier, isBoostedNote = true, + makeItShort = true, unPackReply = ReplyRenderType.NONE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt index 2499397031..8bc9b50955 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -79,6 +79,7 @@ fun RenderZappedPost( it, modifier = Modifier, isBoostedNote = true, + makeItShort = true, unPackReply = ReplyRenderType.NONE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, 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 8036eb2e4a..08d7d01ae6 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 @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt new file mode 100644 index 0000000000..786c4eda28 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt @@ -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)) + } +}