From 0e8c6fa4343038cd80f3c9b420aeeb4cd351cc88 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 26 Mar 2026 09:05:37 +0200 Subject: [PATCH 1/4] fix(cache): route ReadsScreen following-mode events through cache Following-mode long-form feed was not calling consumeEvent(), so LongTextNoteEvents never reached the cache. Back-navigation showed empty reads because cache had nothing to seed from. --- .../com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 80438b7b0..42f3ad82a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -204,7 +204,8 @@ fun ReadsScreen( val events by eventState.items.collectAsState() var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } - var followedUsers by remember { mutableStateOf>(emptySet()) } + // Seed followedUsers from cache so Following mode works immediately on back-nav + var followedUsers by remember { mutableStateOf(localCache.followedUsers.value) } var eoseReceivedCount by remember { mutableStateOf(0) } val initialLoadComplete = eoseReceivedCount > 0 @@ -272,7 +273,8 @@ fun ReadsScreen( createFollowingLongFormFeedSubscription( relays = connectedRelays, followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event is LongTextNoteEvent) { eventState.addItem(event) } From 2b3005797e20d1fbcc25bf967202ab3dcfb31913 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 11:29:02 +0300 Subject: [PATCH 2/4] feat(desktop): render reposts and quoted notes in feed - Extract GenericRepostLayout + BoostedMark to commons for cross-platform use - Feed filters accept kind 6/16 reposts with deduplication by original note - Relay subscriptions request kinds 1, 6, 16 - Cache consumes GenericRepostEvent (kind 16) and uses getOrCreateNote for repost originals to handle out-of-order arrival - FeedNoteCard renders reposts with overlapping avatars + "Boosted" label - QuotedNoteEmbed renders nostr:nevent/note references as embedded NoteCards with reactive metadata observation - Direct relay subscriptions fetch missing referenced notes and author metadata - FeedMetadataCoordinator routes all events (not just metadata) back to cache - consumeMetadata invalidates note flows when author metadata arrives Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commons/compose/elements/BoostedMark.kt | 40 +++ .../compose/layouts/GenericRepostLayout.kt | 74 +++++ .../assemblers/FeedMetadataCoordinator.kt | 35 +++ .../desktop/cache/DesktopLocalCache.kt | 36 ++- .../desktop/feeds/DesktopFeedFilters.kt | 32 +- .../DesktopRelaySubscriptionsCoordinator.kt | 8 +- .../desktop/subscriptions/FilterBuilders.kt | 6 +- .../amethyst/desktop/ui/FeedScreen.kt | 283 +++++++++++++++--- .../amethyst/desktop/ui/note/NoteCard.kt | 251 +++++++++++----- .../desktop/filters/FilterBuildersTest.kt | 14 +- 10 files changed, 644 insertions(+), 135 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt new file mode 100644 index 000000000..173639ccd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt @@ -0,0 +1,40 @@ +/* + * 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.compose.elements + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun BoostedMark() { + Text( + "Boosted", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(start = 5.dp), + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt new file mode 100644 index 000000000..a77f979ef --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt @@ -0,0 +1,74 @@ +/* + * 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.compose.layouts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.Repost + +private val Size55Modifier = Modifier.size(55.dp) +private val Size35Modifier = Modifier.size(35.dp) +private val Size18Modifier = Modifier.size(18.dp) + +@Composable +fun RepostIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Repost, + contentDescription = "Boost or quote", + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun GenericRepostLayout( + baseAuthorPicture: @Composable () -> Unit, + repostAuthorPicture: @Composable () -> Unit, +) { + Box(modifier = Size55Modifier) { + Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } + + Box( + remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, + ) { + RepostIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.onSurfaceVariant) + } + + Box( + remember { Size35Modifier.align(Alignment.BottomEnd) }, + contentAlignment = Alignment.BottomEnd, + ) { + repostAuthorPicture() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index 4b62fc9ab..aa03e9230 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.CoroutineScope @@ -66,6 +69,7 @@ class FeedMetadataCoordinator( // Track what we've already queued to avoid duplicates private val queuedPubkeys = mutableSetOf() private val queuedNoteIds = mutableSetOf() + private val queuedBoostedIds = mutableSetOf() /** * Start processing the subscription queue. @@ -110,6 +114,37 @@ class FeedMetadataCoordinator( fun loadMetadataForNotes(notes: List) { if (notes.isEmpty()) return + // Fetch referenced note content: reposts (via replyTo) + quoted notes (via e-tags) + val repostBoostedIds = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + .filter { it.event == null } + .map { it.idHex } + + val quotedNoteIds = + notes + .mapNotNull { it.event } + .flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } } + + val allReferencedIds = + (repostBoostedIds + quotedNoteIds) + .filter { it !in queuedBoostedIds } + .distinct() + + if (allReferencedIds.isNotEmpty()) { + queuedBoostedIds.addAll(allReferencedIds) + val referencedFilter = + Filter( + ids = allReferencedIds, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + referencedFilter, + tag = "feed-referenced-notes", + ) + } + // Extract unique authors that we haven't already queued val authors = notes diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 319ec94db..140fecc46 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -148,6 +149,13 @@ class DesktopLocalCache : ICacheProvider { val newUserMetadata = event.contactMetaData() if (newUserMetadata != null) { user.updateUserInfo(newUserMetadata, event) + // Invalidate metadata flows on notes by this author that have observers + // so QuotedNoteEmbed/FeedNoteCard recompose with updated avatar/name + notes.forEach { _, note -> + if (note.author?.pubkeyHex == event.pubKey && note.flowSet?.metadata?.hasObservers() == true) { + note.flowSet?.metadata?.invalidateData() + } + } } } } @@ -188,6 +196,10 @@ class DesktopLocalCache : ICacheProvider { consumeRepost(event, relay) } + is GenericRepostEvent -> { + consumeGenericRepost(event, relay) + } + is ContactListEvent -> { consumeContactList(event) } @@ -299,6 +311,8 @@ class DesktopLocalCache : ICacheProvider { /** * Consumes a kind 6 repost event. * Links repost to target note via e-tag. + * Uses getOrCreateNote for the boosted note so the link exists even if + * the original note hasn't arrived yet (it will be filled in later). */ private fun consumeRepost( event: RepostEvent, @@ -307,7 +321,27 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) } + val boostedId = event.boostedEventId() + val boostedNote = boostedId?.let { getOrCreateNote(it) } + val repliesTo = listOfNotNull(boostedNote) + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + boostedNote?.addBoost(note) + return true + } + + /** + * Consumes a kind 16 generic repost event. + * Links repost to target note via e-tag. + */ + private fun consumeGenericRepost( + event: GenericRepostEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val boostedNote = event.boostedEventId()?.let { getOrCreateNote(it) } val repliesTo = listOfNotNull(boostedNote) note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } 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 8f87d901a..6aac787c9 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 @@ -28,12 +28,26 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = event is TextNoteEvent || event is RepostEvent || event is GenericRepostEvent + +private fun List.deduplicateReposts(): List = + distinctBy { note -> + val event = note.event + if (event is RepostEvent || event is GenericRepostEvent) { + note.replyTo?.lastOrNull()?.idHex ?: note.idHex + } else { + note.idHex + } + } + /** - * Global feed: all kind 1 text notes, sorted by createdAt desc. + * Global feed: kind 1 text notes + kind 6/16 reposts, sorted by createdAt desc. */ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, @@ -42,19 +56,20 @@ class DesktopGlobalFeedFilter( override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is TextNoteEvent } + .filterIntoSet { _, note -> isFeedNote(note.event) } .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } /** - * Following feed: kind 1 text notes from followed pubkeys. + * Following feed: kind 1 text notes + kind 6/16 reposts from followed pubkeys. */ class DesktopFollowingFeedFilter( private val cache: DesktopLocalCache, @@ -66,19 +81,20 @@ class DesktopFollowingFeedFilter( val follows = followedPubkeys() return cache.notes .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex in follows + isFeedNote(note.event) && note.author?.pubkeyHex in follows }.sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) } override fun applyFilter(newItems: Set): Set { val follows = followedPubkeys() return newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex in follows + isFeedNote(it.event) && it.author?.pubkeyHex in follows } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 474f3a163..9f1116cfe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -91,11 +91,9 @@ class DesktopRelaySubscriptionsCoordinator( scope = scope, indexRelays = indexRelays, preloader = preloader, - onEvent = { event, _ -> - // Consume metadata events into local cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } + onEvent = { event, relay -> + // Route all fetched events (metadata, boosted notes, etc.) into cache + localCache.consume(event, relay) }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index fee6d8c3b..ecdefb508 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter * Provides convenience functions for creating relay subscription filters. */ object FilterBuilders { + private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent + /** * Creates a filter for text notes (kind 1) from all authors. * @@ -41,7 +43,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, limit = limit, since = since, until = until, @@ -63,7 +65,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, authors = authors, limit = limit, since = since, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 654da5d7c..23dc88b35 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark +import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -67,14 +71,22 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote data class LightboxState( val urls: List, @@ -86,6 +98,8 @@ data class LightboxState( /** * Note card that reads counts from the Note model (cache-backed). * Event is extracted from Note for signing operations in NoteActionsRow. + * Handles reposts (kind 6/16) by showing overlapping avatars + "Boosted" label + * and rendering the original note content. */ @Composable fun FeedNoteCard( @@ -102,53 +116,141 @@ fun FeedNoteCard( onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val event = note.event ?: return + val isRepost = event is RepostEvent || event is GenericRepostEvent - // Observe Note.flowSet for live count updates - val flowSet = remember(note) { note.flow() } - val reactionsState by flowSet.reactions.stateFlow.collectAsState() - val repliesState by flowSet.replies.stateFlow.collectAsState() - val zapsState by flowSet.zaps.stateFlow.collectAsState() + if (isRepost) { + val originalNote = note.replyTo?.lastOrNull() + if (originalNote == null) { + return + } - // Read counts from Note model (re-read on each stateFlow emission) - val reactionCount = note.countReactions() - val replyCount = note.replies.size - val repostCount = note.boosts.size - val zapAmount = note.zapsAmount + // Observe original note's flowSet — MUST happen before reading .event + // so we recompose when the async fetch fills in the event + val flowSet = remember(originalNote) { originalNote.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() - // Clean up flowSet when card leaves composition - DisposableEffect(note) { - onDispose { note.clearFlow() } - } + DisposableEffect(originalNote) { + onDispose { originalNote.clearFlow() } + } - Column { - NoteCard( - note = event.toNoteDisplayData(localCache), - localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - onImageClick = onImageClick, - onMediaClick = onMediaClick, - ) + // Now read event — recomposition will re-read this when metadata invalidates + val originalEvent = originalNote.event + if (originalEvent == null) { + return + } - // Action buttons (only if logged in) - if (account != null) { - NoteActionsRow( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = onReply, - onZapFeedback = onZapFeedback, + val reactionCount = originalNote.countReactions() + val replyCount = originalNote.replies.size + val repostCount = originalNote.boosts.size + val zapAmount = originalNote.zapsAmount + + val reposterUser = localCache.getUserIfExists(event.pubKey) + val originalUser = localCache.getUserIfExists(originalEvent.pubKey) + + Column { + // Repost header: overlapping avatars + "Boosted" label + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = note.zaps.size, - zapAmountSats = zapAmount.toLong(), - zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps - reactionCount = reactionCount, - replyCount = replyCount, - repostCount = repostCount, + ) { + GenericRepostLayout( + baseAuthorPicture = { + UserAvatar( + userHex = originalEvent.pubKey, + pictureUrl = originalUser?.profilePicture(), + size = 35.dp, + ) + }, + repostAuthorPicture = { + UserAvatar( + userHex = event.pubKey, + pictureUrl = reposterUser?.profilePicture(), + size = 35.dp, + ) + }, + ) + BoostedMark() + } + + // Original note content + NoteCard( + note = originalEvent.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(originalEvent.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, ) + + // Action buttons for original note + if (account != null) { + NoteActionsRow( + event = originalEvent, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = originalNote.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } + } + } else { + // Regular note rendering (unchanged) + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + Column { + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + ) + + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } } } } @@ -248,16 +350,115 @@ fun FeedScreen( val feedState by viewModel.feedState.feedContent.collectAsState() - // Load metadata for visible notes via Coordinator (rate-limited) + // Load metadata for visible notes + repost/quoted note authors via Coordinator LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { val notes = viewModel.feedState.visibleNotes() if (notes.isNotEmpty()) { subscriptionsCoordinator.loadMetadataForNotes(notes) + + // Also load metadata for repost original + quoted note authors + val referencedAuthors = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { + it.replyTo + ?.lastOrNull() + ?.author + ?.pubkeyHex + } + subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors) } } } + // Fetch missing referenced notes (repost originals + quoted notes via e-tags) + // Uses a direct relay subscription — bypasses the coordinator pipeline + val missingNoteIds = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Repost originals where event is null + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + val repostIds = repostOriginals.filter { it.event == null }.map { it.idHex } + + // Quoted note IDs from content bech32s (nostr:nevent/nostr:note references) + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val contentQuotedIds = + allEvents + .flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> entity.hex + is NEvent -> entity.hex + else -> null + } + } + }.filter { localCache.getNoteIfExists(it)?.event == null } + + (repostIds + contentQuotedIds).distinct() + } + + rememberSubscription(allRelayUrls, missingNoteIds, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingNoteIds.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-referenced"), + filters = listOf(FilterBuilders.byIds(missingNoteIds)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + + // Fetch missing metadata (kind 0) for all note authors including referenced notes + val missingAuthorPubkeys = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Collect all referenced notes (repost originals + e-tag/content referenced) + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + + // All notes in cache that are referenced by visible notes + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val quotedNotes = + allEvents.flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> localCache.getNoteIfExists(entity.hex) + is NEvent -> localCache.getNoteIfExists(entity.hex) + else -> null + } + } + } + + // Authors from feed notes + repost originals + quoted notes + (notes.mapNotNull { it.author } + repostOriginals.mapNotNull { it.author } + quotedNotes.mapNotNull { it.author }) + .filter { it.profilePicture() == null } + .map { it.pubkeyHex } + .distinct() + } + + rememberSubscription(allRelayUrls, missingAuthorPubkeys, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingAuthorPubkeys.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-metadata"), + filters = listOf(FilterBuilders.userMetadataMultiple(missingAuthorPubkeys)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) DisposableEffect(feedMode, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 44417e19a..7cc2500c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -63,6 +66,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -324,6 +328,7 @@ fun NoteCard( private data class ResolvedMention( val displayText: String, val pubKeyHex: String? = null, + val noteIdHex: String? = null, ) /** @@ -354,11 +359,11 @@ private fun resolveBech32( } is NNote -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } is NEvent -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } else -> { @@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set): List = * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color * and navigate to profile on click. */ +private data class ContentSegment( + val start: Int, + val raw: String, + val isUrl: Boolean, +) + +private fun buildSegments( + content: String, + schemeUrls: Collection, + bech32s: Collection, +): List { + val segments = mutableListOf() + for (url in schemeUrls) { + val idx = content.indexOf(url) + if (idx != -1) segments.add(ContentSegment(idx, url, true)) + } + for (bech32 in bech32s) { + val idx = content.indexOf(bech32) + if (idx != -1) segments.add(ContentSegment(idx, bech32, false)) + } + segments.sortBy { it.start } + return segments +} + @Composable fun RichTextContent( content: String, urls: Urls, localCache: DesktopLocalCache? = null, onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, modifier: Modifier = Modifier, ) { val defaultColor = MaterialTheme.colorScheme.onSurface @@ -404,80 +434,159 @@ fun RichTextContent( color = defaultColor, modifier = modifier, ) + return + } + + // Resolve bech32s to find quoted notes vs inline mentions + val resolvedBech32s = + remember(urls.bech32s, localCache) { + urls.bech32s.associateWith { resolveBech32(it, localCache) } + } + + // Collect quoted note IDs (nevent/note references) + val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys } + val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() } + + if (quotedNoteIds.isEmpty()) { + // No quoted notes — render everything as annotated text + val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) } + RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier) } else { - data class Segment( - val start: Int, - val raw: String, - val isUrl: Boolean, - ) - - val segments = mutableListOf() - for (url in urls.withScheme) { - val idx = content.indexOf(url) - if (idx != -1) segments.add(Segment(idx, url, true)) - } - for (bech32 in urls.bech32s) { - val idx = content.indexOf(bech32) - if (idx != -1) segments.add(Segment(idx, bech32, false)) - } - segments.sortBy { it.start } - - val annotatedText = - buildAnnotatedString { - var lastIndex = 0 - - for (segment in segments) { - if (segment.start < lastIndex) continue - - // Add text before segment - if (segment.start > lastIndex) { - append(content.substring(lastIndex, segment.start)) + // Has quoted notes — render text + embedded note cards + Column(modifier = modifier) { + // Strip quoted note bech32 references from text + val strippedText = + remember(content, quotedBech32s) { + var text = content + for (bech32 in quotedBech32s) { + text = text.replace(bech32, "").trim() } - - if (segment.isUrl) { - withStyle( - SpanStyle( - color = primaryColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(segment.raw) - } - } else { - val resolved = resolveBech32(segment.raw, localCache) - if (resolved.pubKeyHex != null && onMentionClick != null) { - val pubKey = resolved.pubKeyHex - withLink( - LinkAnnotation.Clickable( - tag = "mention", - styles = TextLinkStyles(SpanStyle(color = primaryColor)), - ) { - onMentionClick(pubKey) - }, - ) { - append(resolved.displayText) - } - } else { - withStyle(SpanStyle(color = primaryColor)) { - append(resolved.displayText) - } - } - } - - lastIndex = segment.start + segment.raw.length + text } - // Add remaining text - if (lastIndex < content.length) { - append(content.substring(lastIndex)) - } + if (strippedText.isNotBlank()) { + val inlineBech32s = urls.bech32s - quotedBech32s + val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) } + RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick) } - Text( - text = annotatedText, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) + // Render quoted notes as embedded cards + for (noteId in quotedNoteIds) { + Spacer(Modifier.height(8.dp)) + QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread) + } + } } } + +/** + * Renders a quoted note by ID. Observes the note's metadata flow so it + * recomposes when the event arrives asynchronously from a relay fetch. + * Also observes the author's metadata for display name / avatar updates. + */ +@Composable +fun QuotedNoteEmbed( + noteId: String, + localCache: DesktopLocalCache?, + onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, +) { + if (localCache == null) return + + // getOrCreateNote ensures a placeholder exists so subscriptions can find it + val note = remember(noteId) { localCache.getOrCreateNote(noteId) } + + // Observe note metadata flow — recomposes when loadEvent() is called + val flowSet = remember(note) { note.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + val event = note.event + if (event != null) { + // Recompute on every recomposition — picks up user metadata changes + val displayData = event.toNoteDisplayData(localCache) + + NoteCard( + note = displayData, + localCache = localCache, + onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, + onAuthorClick = onMentionClick, + onMentionClick = onMentionClick, + ) + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Text( + "Loading quoted note...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun RichAnnotatedText( + content: String, + segments: List, + resolvedBech32s: Map, + defaultColor: androidx.compose.ui.graphics.Color, + primaryColor: androidx.compose.ui.graphics.Color, + onMentionClick: ((String) -> Unit)?, + modifier: Modifier = Modifier, +) { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + for (seg in segments) { + if (seg.start < lastIndex) continue + if (seg.start > lastIndex) { + append(content.substring(lastIndex, seg.start)) + } + if (seg.isUrl) { + withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) { + append(seg.raw) + } + } else { + val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw) + if (resolved.pubKeyHex != null && onMentionClick != null) { + val pubKey = resolved.pubKeyHex + withLink( + LinkAnnotation.Clickable( + tag = "mention", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + onMentionClick(pubKey) + }, + ) { + append(resolved.displayText) + } + } else { + withStyle(SpanStyle(color = primaryColor)) { + append(resolved.displayText) + } + } + } + lastIndex = seg.start + seg.raw.length + } + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = defaultColor, + modifier = modifier, + ) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt index 162b178b8..f1fdbacbf 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt @@ -37,7 +37,7 @@ class FilterBuildersTest { fun testTextNotesGlobal() { val filter = FilterBuilders.textNotesGlobal(limit = 50) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) assertNull(filter.authors) assertNull(filter.tags) @@ -51,7 +51,7 @@ class FilterBuildersTest { val until = 1640995200L // 2022-01-01 val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(100, filter.limit) assertEquals(since, filter.since) assertEquals(until, filter.until) @@ -62,7 +62,7 @@ class FilterBuildersTest { val authors = listOf(testPubKey, testPubKey2) val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(25, filter.limit) assertNull(filter.tags) @@ -74,7 +74,7 @@ class FilterBuildersTest { val since = 1609459200L val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(10, filter.limit) assertEquals(since, filter.since) @@ -421,7 +421,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesGlobal(limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) } @@ -431,7 +431,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(followedUsers, filter.authors) assertEquals(50, filter.limit) } @@ -447,7 +447,7 @@ class FilterBuildersTest { assertTrue(!contactListFilter.isEmpty()) assertEquals(listOf(0), metadataFilter.kinds) - assertEquals(listOf(1), postsFilter.kinds) + assertEquals(listOf(1, 6, 16), postsFilter.kinds) assertEquals(listOf(3), contactListFilter.kinds) } From 882c39fd0e81a643ca43e33666da80f994c2859c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 11:31:59 +0300 Subject: [PATCH 3/4] fix(cache): use getOrCreateNote for reply linking to fix flaky thread test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2001 — ThreadFilter test failed intermittently because consumeTextNote used getNoteIfExists for reply-to linking. If the reply event arrived before the root, the link was lost. Now uses getOrCreateNote to create placeholders, same pattern as reposts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 140fecc46..f0ad8611b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -228,7 +228,7 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) } + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } From 0a41806d7ed1afc7a58ec89320e3e7d41bd72019 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 30 Mar 2026 13:33:18 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(desktop):=20feed=20loading=20issues=20?= =?UTF-8?q?=E2=80=94=20missing=20events,=20broken=20profile=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile feed now includes reposts (kind 6/16), not just text notes - Metadata consume returns false to avoid wasteful null note lookups - Feed subscription limits raised from 50 to 200 for global/following - Thread replies subscription fetches NIP-22 comments (kind 1111) - Cache now handles CommentEvent for thread display - Wire onNavigateToThread through UserProfileScreen so tapping notes opens threads Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/DesktopLocalCache.kt | 25 ++++++++++++++++++- .../desktop/feeds/DesktopFeedFilters.kt | 25 +++++++++++-------- .../desktop/subscriptions/FeedSubscription.kt | 18 ++++++++++--- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 ++ .../desktop/ui/deck/DeckColumnContainer.kt | 3 +++ 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index f0ad8611b..6fbf9c266 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent @@ -173,7 +174,7 @@ class DesktopLocalCache : ICacheProvider { when (event) { is MetadataEvent -> { consumeMetadata(event) - true + false // metadata updates User, not Note — skip event stream } is TextNoteEvent -> { @@ -212,6 +213,10 @@ class DesktopLocalCache : ICacheProvider { consumeBookmarkList(event) } + is CommentEvent -> { + consumeComment(event, relay) + } + else -> { false } @@ -235,6 +240,24 @@ class DesktopLocalCache : ICacheProvider { return true } + /** + * Consumes a kind 1111 comment event (NIP-22). + * Like text notes but uses BaseThreadedEvent reply structure. + */ + private fun consumeComment( + event: CommentEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + repliesTo.forEach { it.addReply(note) } + return true + } + /** * Consumes a kind 7 reaction event. * Links reaction to target notes via e-tags and a-tags. 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 6aac787c9..a9d4cdead 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 @@ -34,7 +34,10 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = event is TextNoteEvent || event is RepostEvent || event is GenericRepostEvent +private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = + event is TextNoteEvent || + event is RepostEvent || + event is GenericRepostEvent private fun List.deduplicateReposts(): List = distinctBy { note -> @@ -132,7 +135,7 @@ class DesktopThreadFilter( } /** - * Profile feed: all kind 1 notes by a specific pubkey. + * Profile feed: text notes + reposts by a specific pubkey. */ class DesktopProfileFeedFilter( private val pubkey: HexKey, @@ -140,19 +143,21 @@ class DesktopProfileFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = "profile-$pubkey" + private fun isProfileNote(note: Note): Boolean { + val event = note.event ?: return false + return note.author?.pubkeyHex == pubkey && isFeedNote(event) + } + override fun feed(): List = cache.notes - .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey - }.sortedWith(DefaultFeedOrder) + .filterIntoSet { _, note -> isProfileNote(note) } + .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey - } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isProfileNote(it) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 1000 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index a7d4a633a..589e500cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -37,7 +37,7 @@ enum class FeedMode { */ fun createGlobalFeedSubscription( relays: Set, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig = @@ -55,7 +55,7 @@ fun createGlobalFeedSubscription( fun createFollowingFeedSubscription( relays: Set, followedUsers: List, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig? { @@ -121,9 +121,21 @@ fun createThreadRepliesSubscription( subId = generateSubId("thread-${noteId.take(8)}"), filters = listOf( + // Kind 1 replies via lowercase e-tag (NIP-10) FilterBuilders.byETags( eventIds = listOf(noteId), - kinds = listOf(1), // TextNoteEvent + kinds = listOf(1), + limit = limit, + ), + // Kind 1111 comments via lowercase e-tag (reply parent) or uppercase E-tag (root) + FilterBuilders.byTags( + tags = mapOf("e" to listOf(noteId)), + kinds = listOf(1111), + limit = limit, + ), + FilterBuilders.byTags( + tags = mapOf("E" to listOf(noteId)), + kinds = listOf(1111), limit = limit, ), ), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 0c9582151..914f6fcbd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -121,6 +121,7 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { @@ -796,6 +797,7 @@ fun UserProfileScreen( onReply = onCompose, onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 8d8f01805..a72032acd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -296,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) @@ -325,6 +326,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onZapFeedback = onZapFeedback, ) } @@ -426,6 +428,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, )