mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat: reactions and zaps anchor their own thread; drop chip long-press
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
This commit is contained in:
@@ -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(
|
||||
|
||||
+25
-6
@@ -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<Note>,
|
||||
|
||||
+144
@@ -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<HexKey, Note>,
|
||||
) : 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user