From 0948c0d4674177e761546f78ca91df8eebb59008 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:44:09 +0000 Subject: [PATCH] feat: reactions and zaps anchor their own thread; drop chip long-press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the thread screen on a reaction, lightning zap, nutzap, or onchain zap now shows that event as the root with only its reply subtree below it — the post it targets stays visible through the card's embedded preview, but the target's conversation no longer loads around it. Replies and comments (kind 1 / 1111) keep the existing behavior: the full parent thread loads and the screen lands on the clicked note. Covered by ThreadAssemblerTest. The long-press reply shortcut on notification zap chips is removed: the thread view's reply button is now the single reply entry point (and still routes private zaps to the sender's DM room via routeReplyTo). https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa --- .../amethyst/ui/note/MultiSetCompose.kt | 12 +- .../amethyst/commons/model/ThreadAssembler.kt | 31 +++- .../commons/model/ThreadAssemblerTest.kt | 144 ++++++++++++++++++ 3 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 47f1c4300c..cc1e97ba06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -84,7 +84,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap @@ -523,7 +522,6 @@ fun click( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun RenderState( content: ZapAmountCommentNotification, @@ -532,15 +530,7 @@ private fun RenderState( nav: INav, ) { Row( - modifier = - Modifier.combinedClickable( - onClick = { click(content, nav) }, - onLongClick = { - content.zapNote?.let { zap -> - nav.nav { routeReplyTo(zap, accountViewModel.account) } - } - }, - ), + modifier = Modifier.clickable { click(content, nav) }, verticalAlignment = Alignment.CenterVertically, ) { DisplayAuthorCommentAndAmount( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index 657922e00b..4b0e944665 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet @@ -107,14 +111,23 @@ class ThreadAssembler( return if (note.event != null) { val thread = OnlyLatestVersionSet() - val threadRoot = searchRoot(note, thread) ?: note + if (anchorsItsOwnThread(note)) { + // Reactions and zaps anchor their own conversation: the event is + // the root and only its reply subtree is loaded — not the thread + // of the post it targeted (that post is embedded in the event's + // card). Replies and comments (kind 1 / 1111) keep loading the + // full parent thread below. + loadDown(note, thread) + } else { + val threadRoot = searchRoot(note, thread) ?: note - loadUp(note, thread) + loadUp(note, thread) - loadDown(threadRoot, thread) - // adds the replies of the note in case the search for Root - // did not added them. - note.replies.forEach { loadDown(it, thread) } + loadDown(threadRoot, thread) + // adds the replies of the note in case the search for Root + // did not added them. + note.replies.forEach { loadDown(it, thread) } + } ThreadInfo( root = note, @@ -128,6 +141,12 @@ class ThreadAssembler( } } + private fun anchorsItsOwnThread(note: Note): Boolean = + when (note.event) { + is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true + else -> false + } + fun loadUp( note: Note, thread: MutableSet, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt new file mode 100644 index 0000000000..040f05888d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -0,0 +1,144 @@ +/* + * 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.commons.model + +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Thread anchoring rules: replies and comments (kind 1 / 1111) load the full + * parent thread; reactions and zaps anchor their own thread — the event is the + * root and only its reply subtree is included, not the conversation of the + * post they target. + */ +class ThreadAssemblerTest { + private val originalId = "a".repeat(64) + private val zapId = "b".repeat(64) + private val reactionId = "c".repeat(64) + private val replyToZapId = "d".repeat(64) + private val authorKey = "e".repeat(64) + private val sig = "f".repeat(128) + + private val original = + Note(originalId).apply { + event = TextNoteEvent(originalId, authorKey, 1000, arrayOf(arrayOf("t", "test")), "the zapped post", sig) + replyTo = emptyList() + } + + private val zap = + Note(zapId).apply { + event = LnZapEvent(zapId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "", sig) + replyTo = listOf(original) + } + + private val reaction = + Note(reactionId).apply { + event = ReactionEvent(reactionId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "+", sig) + replyTo = listOf(original) + } + + private val replyToZap = + Note(replyToZapId).apply { + event = CommentEvent(replyToZapId, authorKey, 1002, arrayOf(arrayOf("E", zapId), arrayOf("e", zapId)), "nice zap!", sig) + replyTo = listOf(zap) + } + + private val cache = + StubCache( + mapOf( + originalId to original, + zapId to zap, + reactionId to reaction, + replyToZapId to replyToZap, + ), + ) + + init { + // Mirrors LocalCache: replies link bidirectionally, but zaps and + // reactions are credited via addZap/addReaction, not addReply, so the + // original note's `replies` does not contain them. + zap.addReply(replyToZap) + } + + @Test + fun zapAnchorsItsOwnThread() { + val info = ThreadAssembler(cache).findThreadFor(zapId)!! + + assertEquals(zap, info.root) + assertTrue(info.allNotes.contains(zap)) + assertTrue(info.allNotes.contains(replyToZap), "the zap's replies belong to its thread") + assertFalse(info.allNotes.contains(original), "the zapped post's thread must not be loaded") + } + + @Test + fun reactionAnchorsItsOwnThread() { + val info = ThreadAssembler(cache).findThreadFor(reactionId)!! + + assertEquals(reaction, info.root) + assertTrue(info.allNotes.contains(reaction)) + assertFalse(info.allNotes.contains(original), "the liked post's thread must not be loaded") + } + + @Test + fun commentReplyingToAZapStillLoadsTheFullParentThread() { + val info = ThreadAssembler(cache).findThreadFor(replyToZapId)!! + + assertEquals(replyToZap, info.root) + assertTrue(info.allNotes.contains(replyToZap)) + assertTrue(info.allNotes.contains(zap), "parents load for replies/comments") + assertTrue(info.allNotes.contains(original), "parents load all the way to the thread root") + } + + private class StubCache( + private val notesById: Map, + ) : ICacheProvider { + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = null + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey] + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey] + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ThreadAssembler in this test") + + override fun getEventStream(): ICacheEventStream = error("not used by ThreadAssembler in this test") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } +}