From e21794e42a4ee9ea98eb9f062b0f2dca80b10f31 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 12 May 2026 21:58:03 +0200 Subject: [PATCH] =?UTF-8?q?Manual=20testing=20fixes:=20-=20quartz:=20Event?= =?UTF-8?q?.threadRootIdOrSelf()=20now=20falls=20back=20to=20markedReply()?= =?UTF-8?q?=3F.eventId=20when=20both=20markedRoot()=20and=20unmarkedRoot()?= =?UTF-8?q?=20are=20absent=20-=20amethyst:=20After=20upstream=20PR=20#2855?= =?UTF-8?q?=20split=20the=20notifications=20feed=20into=20notificationsFol?= =?UTF-8?q?lowing=20and=20notificationsEveryone,=20the=20hiddenUsers.flow?= =?UTF-8?q?=20collector=20still=20only=20invalidated=20the=20original=20no?= =?UTF-8?q?tifications=20feed.=20Extends=20it=20to=20invalidate=20all=20th?= =?UTF-8?q?ree.=20-=20amethyst:=20CardFeedContentState.refreshSuspended()?= =?UTF-8?q?=20takes=20an=20additive-only=20path=20when=20lastNotes=20is=20?= =?UTF-8?q?populated=20=E2=80=94=20it=20only=20adds=20cards=20for=20new=20?= =?UTF-8?q?admissions,=20never=20removing=20cards=20for=20notes=20that=20n?= =?UTF-8?q?o=20longer=20pass=20the=20filter.=20Re-muting=20mid-session=20c?= =?UTF-8?q?orrectly=20rejected=20muted-thread=20reactions=20in=20the=20fil?= =?UTF-8?q?ter,=20but=20the=20existing=20cards=20stayed=20in=20the=20UI=20?= =?UTF-8?q?as=20"Show=20Anyway"=20placeholders.=20Calls=20clear()=20before?= =?UTF-8?q?=20invalidateData()=20on=20each=20notification=20feed=20so=20th?= =?UTF-8?q?e=20refresh=20hits=20the=20full-rebuild=20branch=20and=20remova?= =?UTF-8?q?ls=20propagate.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loggedIn/AccountFeedContentStates.kt | 9 +++ .../quartz/nip10Notes/BaseThreadedEvent.kt | 9 ++- .../nip10Notes/ThreadRootIdOrSelfTest.kt | 75 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index e63e09fd9..c180e87fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -138,7 +138,16 @@ class AccountFeedContentStates( account.hiddenUsers.flow.collect { dmKnown.invalidateData() dmNew.invalidateData() + // Re-mute removes cards, not just adds them. CardFeedContentState's + // refreshSuspended() takes an additive-only path when lastNotes is + // populated, which keeps stale cards for notes that no longer pass + // the filter. Clear first so the refresh hits the full-rebuild branch. + notifications.clear() notifications.invalidateData() + notificationsFollowing.clear() + notificationsFollowing.invalidateData() + notificationsEveryone.clear() + notificationsEveryone.invalidateData() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index bec989c89..b8379fb62 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -31,7 +31,14 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull -fun Event.threadRootIdOrSelf(): HexKey = (this as? BaseThreadedEvent)?.root()?.eventId ?: id +fun Event.threadRootIdOrSelf(): HexKey { + val threaded = this as? BaseThreadedEvent ?: return id + threaded.root()?.eventId?.let { return it } + // NIP-10 legacy single-level form: when only a "reply"-marked e-tag is + // present (no "root" marker), the reply target IS the conversation root. + threaded.markedReply()?.eventId?.let { return it } + return id +} @Immutable open class BaseThreadedEvent( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt new file mode 100644 index 000000000..2ae2407d1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt @@ -0,0 +1,75 @@ +/* + * 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.quartz.nip10Notes + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ThreadRootIdOrSelfTest { + private val selfId = "3b1ef90115dc6f383e9588d29c86fe3ecda6ecc98415af556cd1399731a15e09" + private val rootId = "41ed6fdfbe827e7e87f3bfd852270b036c199b721777593d257029d742adcb46" + private val parentId = "54851588857363fa077fc756c1a64528f511d39476ca37e6a781bcb7aabb9e7d" + private val pubKey = "b62b357c1b939ae051a361d5befd409ddda06264a7588f1a986c24b1d13cc53f" + private val relay = "wss://relay.damus.io" + private val sig = "0".repeat(128) + + private fun textNote(tags: Array>): TextNoteEvent = TextNoteEvent(selfId, pubKey, 1778593701L, tags, "", sig) + + @Test fun topLevelNote_noETags_resolvesToOwnId() { + // A note with no e-tags is the root of its own thread. + val note = textNote(emptyArray()) + assertEquals(selfId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithRootMarker_resolvesToRoot() { + // Modern NIP-10: explicit "root" marker. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "root"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithBothRootAndReplyMarkers_resolvesToRoot() { + // Multi-level reply: "root" marker points to thread root, + // "reply" marker points to immediate parent. + val note = + textNote( + arrayOf( + arrayOf("e", rootId, relay, "root"), + arrayOf("e", parentId, relay, "reply"), + ), + ) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithOnlyReplyMarker_resolvesToReplyTarget() { + // NIP-10 legacy single-level form: a one-level reply marked only + // "reply" with no "root" marker. The "reply" target IS the + // conversation root. Regression case from issue #161 device QA. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "reply"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithUnmarkedETag_resolvesToTaggedEvent() { + // Positional NIP-10: e-tag with no marker. Treated as root by + // BaseThreadedEvent.unmarkedRoot(). + val note = textNote(arrayOf(arrayOf("e", rootId, relay))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } +}