From a23dbb2a16f82ceb362139a07b17375ff72a6c42 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 6 Jun 2026 15:26:22 +0300 Subject: [PATCH] fix(desktop): tighten profile Replies tab to marked replies only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Replies tab predicate used `!note.isNewThread()`, which returns true whenever Note.replyTo is non-empty. The cache populates replyTo from event.tagsWithoutCitations(), and that includes unmarked positional NIP-10 e-tags — which modern clients use for QUOTES and MENTIONS, not replies. Posts that merely quoted another note were therefore appearing in the Replies tab. Tighten the signal: a reply is now either a NIP-22 CommentEvent, or a NIP-10 TextNoteEvent carrying an explicit `reply`/`root` marker tag (`markedReply()` / `markedRoot()`). Unmarked e-tags no longer qualify. Adds 6 regression tests including the unmarked-e-tag false-positive case the user reported. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop/feeds/DesktopFeedFilters.kt | 19 ++- .../filters/DesktopProfileFeedFilterTest.kt | 141 ++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopProfileFeedFilterTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index c569982529..13fb4ff300 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -194,8 +195,11 @@ class DesktopThreadFilter( * Profile feed: text notes + reposts by a specific pubkey. * * When [repliesOnly] is true, the filter switches to "Replies" mode: - * only the pubkey's NIP-10 reply posts (kind 1 with a parent tag) — no - * reposts, no top-level notes, no channel/live messages. + * only the pubkey's reply posts — NIP-22 [CommentEvent]s and NIP-10 + * [TextNoteEvent]s carrying an explicit `reply`/`root` marker. Plain + * unmarked e-tags don't count: modern clients use those for quotes + * and mentions, and `Note.isNewThread()` (which is what Android's + * conversations feed checks) would let those through as "replies". */ class DesktopProfileFeedFilter( private val pubkey: HexKey, @@ -204,13 +208,18 @@ class DesktopProfileFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = if (repliesOnly) "profile-$pubkey-replies" else "profile-$pubkey" + private fun isReply(event: Event): Boolean = + when (event) { + is CommentEvent -> true + is TextNoteEvent -> event.markedReply() != null || event.markedRoot() != null + else -> false + } + private fun isProfileNote(note: Note): Boolean { val event = note.event ?: return false if (note.author?.pubkeyHex != pubkey) return false return if (repliesOnly) { - // event is TextNoteEvent excludes reposts AND chat-message types - // that !isNewThread() would otherwise let through. - event is TextNoteEvent && !note.isNewThread() + isReply(event) } else { isFeedNote(event) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopProfileFeedFilterTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopProfileFeedFilterTest.kt new file mode 100644 index 0000000000..02312f1d45 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopProfileFeedFilterTest.kt @@ -0,0 +1,141 @@ +/* + * 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.desktop.filters + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DesktopProfileFeedFilterTest { + private val author = "0000000000000000000000000000000000000000000000000000000000000001" + private val parentId = "1111111111111111111111111111111111111111111111111111111111111111" + private val cache = DesktopLocalCache() + + private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) } + + private fun textNote( + id: String, + pubkey: String, + tags: Array>, + content: String = "hi", + ): TextNoteEvent = TextNoteEvent(id, pubkey, 0L, tags, content, "") + + private fun replyNote(id: String): Note { + val u = user(author) + val event = + textNote( + id, + author, + arrayOf(arrayOf("e", parentId, "", "reply"), arrayOf("p", author)), + ) + val n = Note(id) + n.loadEvent(event, u, listOf(Note(parentId))) + return n + } + + private fun rootNote(id: String): Note { + val u = user(author) + val event = textNote(id, author, emptyArray()) + val n = Note(id) + n.loadEvent(event, u, emptyList()) + return n + } + + @Test + fun repliesOnly_includesReply() { + val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true) + val reply = replyNote("aa") + val result = filter.applyFilter(setOf(reply)) + assertEquals(setOf(reply), result) + } + + @Test + fun repliesOnly_excludesRoot() { + val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true) + val root = rootNote("aa") + val result = filter.applyFilter(setOf(root)) + assertTrue("Root post must NOT be in Replies tab", result.isEmpty()) + } + + @Test + fun repliesOnly_excludesOtherAuthor() { + val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true) + val otherAuthor = "0000000000000000000000000000000000000000000000000000000000000002" + val u = user(otherAuthor) + val event = + textNote( + "bb", + otherAuthor, + arrayOf(arrayOf("e", parentId, "", "reply"), arrayOf("p", author)), + ) + val n = Note("bb") + n.loadEvent(event, u, listOf(Note(parentId))) + val result = filter.applyFilter(setOf(n)) + assertTrue("Reply by another author must be excluded", result.isEmpty()) + } + + @Test + fun notesMode_includesBoth() { + val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = false) + val root = rootNote("aa") + val reply = replyNote("bb") + val result = filter.applyFilter(setOf(root, reply)) + assertEquals(setOf(root, reply), result) + } + + /** + * Regression: an unmarked e-tag is the legacy positional NIP-10 form, + * but modern clients use it for QUOTES/MENTIONS, not replies. The Replies + * tab must NOT include such posts. The looser `!isNewThread()` check + * (which Android's conversations filter uses) would falsely include them. + */ + @Test + fun repliesOnly_excludesQuoteWithUnmarkedETag() { + val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true) + val u = user(author) + val event = + textNote( + "cc", + author, + // Unmarked e-tag — typical of an inline `nostr:note1...` quote. + arrayOf(arrayOf("e", parentId)), + ) + val n = Note("cc") + // Cache would populate replyTo from tagsWithoutCitations(), which + // includes unmarked tags. Mirror that here. + n.loadEvent(event, u, listOf(Note(parentId))) + val result = filter.applyFilter(setOf(n)) + assertTrue("Quote (unmarked e-tag) must NOT appear in Replies tab", result.isEmpty()) + } + + @Test + fun feedKeysDifferByMode() { + val notes = DesktopProfileFeedFilter(author, cache, repliesOnly = false) + val replies = DesktopProfileFeedFilter(author, cache, repliesOnly = true) + assertFalse(notes.feedKey() == replies.feedKey()) + } +}