Merge pull request #2027 from nrobi144/fix/general-bugfixes

fix: desktop bugfixes — flaky test, repost rendering, reads feed
This commit is contained in:
Vitor Pamplona
2026-03-30 08:21:52 -04:00
committed by GitHub
14 changed files with 707 additions and 151 deletions
@@ -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),
)
}
@@ -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()
}
}
}
@@ -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.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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 com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -66,6 +69,7 @@ class FeedMetadataCoordinator(
// Track what we've already queued to avoid duplicates // Track what we've already queued to avoid duplicates
private val queuedPubkeys = mutableSetOf<HexKey>() private val queuedPubkeys = mutableSetOf<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>() private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
/** /**
* Start processing the subscription queue. * Start processing the subscription queue.
@@ -110,6 +114,37 @@ class FeedMetadataCoordinator(
fun loadMetadataForNotes(notes: List<Note>) { fun loadMetadataForNotes(notes: List<Note>) {
if (notes.isEmpty()) return 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 // Extract unique authors that we haven't already queued
val authors = val authors =
notes notes
@@ -36,8 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
@@ -148,6 +150,13 @@ class DesktopLocalCache : ICacheProvider {
val newUserMetadata = event.contactMetaData() val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) { if (newUserMetadata != null) {
user.updateUserInfo(newUserMetadata, event) 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()
}
}
} }
} }
} }
@@ -165,7 +174,7 @@ class DesktopLocalCache : ICacheProvider {
when (event) { when (event) {
is MetadataEvent -> { is MetadataEvent -> {
consumeMetadata(event) consumeMetadata(event)
true false // metadata updates User, not Note — skip event stream
} }
is TextNoteEvent -> { is TextNoteEvent -> {
@@ -188,6 +197,10 @@ class DesktopLocalCache : ICacheProvider {
consumeRepost(event, relay) consumeRepost(event, relay)
} }
is GenericRepostEvent -> {
consumeGenericRepost(event, relay)
}
is ContactListEvent -> { is ContactListEvent -> {
consumeContactList(event) consumeContactList(event)
} }
@@ -200,6 +213,10 @@ class DesktopLocalCache : ICacheProvider {
consumeBookmarkList(event) consumeBookmarkList(event)
} }
is CommentEvent -> {
consumeComment(event, relay)
}
else -> { else -> {
false false
} }
@@ -216,7 +233,25 @@ class DesktopLocalCache : ICacheProvider {
val note = getOrCreateNote(event.id) val note = getOrCreateNote(event.id)
if (note.event != null) return false if (note.event != null) return false
val author = getOrCreateUser(event.pubKey) 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) }
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) note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) } relay?.let { note.addRelay(it) }
repliesTo.forEach { it.addReply(note) } repliesTo.forEach { it.addReply(note) }
@@ -299,6 +334,8 @@ class DesktopLocalCache : ICacheProvider {
/** /**
* Consumes a kind 6 repost event. * Consumes a kind 6 repost event.
* Links repost to target note via e-tag. * 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( private fun consumeRepost(
event: RepostEvent, event: RepostEvent,
@@ -307,7 +344,27 @@ class DesktopLocalCache : ICacheProvider {
val note = getOrCreateNote(event.id) val note = getOrCreateNote(event.id)
if (note.event != null) return false if (note.event != null) return false
val author = getOrCreateUser(event.pubKey) 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) val repliesTo = listOfNotNull(boostedNote)
note.loadEvent(event, author, repliesTo) note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) } relay?.let { note.addRelay(it) }
@@ -28,12 +28,29 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent 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.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent 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<Note>.deduplicateReposts(): List<Note> =
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( class DesktopGlobalFeedFilter(
private val cache: DesktopLocalCache, private val cache: DesktopLocalCache,
@@ -42,19 +59,20 @@ class DesktopGlobalFeedFilter(
override fun feed(): List<Note> = override fun feed(): List<Note> =
cache.notes cache.notes
.filterIntoSet { _, note -> note.event is TextNoteEvent } .filterIntoSet { _, note -> isFeedNote(note.event) }
.sortedWith(DefaultFeedOrder) .sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit()) .take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isFeedNote(it.event) }
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder) override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 2500 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( class DesktopFollowingFeedFilter(
private val cache: DesktopLocalCache, private val cache: DesktopLocalCache,
@@ -66,19 +84,20 @@ class DesktopFollowingFeedFilter(
val follows = followedPubkeys() val follows = followedPubkeys()
return cache.notes return cache.notes
.filterIntoSet { _, note -> .filterIntoSet { _, note ->
note.event is TextNoteEvent && note.author?.pubkeyHex in follows isFeedNote(note.event) && note.author?.pubkeyHex in follows
}.sortedWith(DefaultFeedOrder) }.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit()) .take(limit())
} }
override fun applyFilter(newItems: Set<Note>): Set<Note> { override fun applyFilter(newItems: Set<Note>): Set<Note> {
val follows = followedPubkeys() val follows = followedPubkeys()
return newItems.filterTo(HashSet()) { 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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder) override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 2500 override fun limit(): Int = 2500
} }
@@ -116,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( class DesktopProfileFeedFilter(
private val pubkey: HexKey, private val pubkey: HexKey,
@@ -124,19 +143,21 @@ class DesktopProfileFeedFilter(
) : AdditiveFeedFilter<Note>() { ) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "profile-$pubkey" 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<Note> = override fun feed(): List<Note> =
cache.notes cache.notes
.filterIntoSet { _, note -> .filterIntoSet { _, note -> isProfileNote(note) }
note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey .sortedWith(DefaultFeedOrder)
}.sortedWith(DefaultFeedOrder) .deduplicateReposts()
.take(limit()) .take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isProfileNote(it) }
newItems.filterTo(HashSet()) {
it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder) override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 1000 override fun limit(): Int = 1000
} }
@@ -91,11 +91,9 @@ class DesktopRelaySubscriptionsCoordinator(
scope = scope, scope = scope,
indexRelays = indexRelays, indexRelays = indexRelays,
preloader = preloader, preloader = preloader,
onEvent = { event, _ -> onEvent = { event, relay ->
// Consume metadata events into local cache // Route all fetched events (metadata, boosted notes, etc.) into cache
if (event is MetadataEvent) { localCache.consume(event, relay)
localCache.consumeMetadata(event)
}
}, },
) )
@@ -37,7 +37,7 @@ enum class FeedMode {
*/ */
fun createGlobalFeedSubscription( fun createGlobalFeedSubscription(
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
limit: Int = 50, limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit, onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> }, onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig = ): SubscriptionConfig =
@@ -55,7 +55,7 @@ fun createGlobalFeedSubscription(
fun createFollowingFeedSubscription( fun createFollowingFeedSubscription(
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>, followedUsers: List<String>,
limit: Int = 50, limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit, onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> }, onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? { ): SubscriptionConfig? {
@@ -121,9 +121,21 @@ fun createThreadRepliesSubscription(
subId = generateSubId("thread-${noteId.take(8)}"), subId = generateSubId("thread-${noteId.take(8)}"),
filters = filters =
listOf( listOf(
// Kind 1 replies via lowercase e-tag (NIP-10)
FilterBuilders.byETags( FilterBuilders.byETags(
eventIds = listOf(noteId), 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, limit = limit,
), ),
), ),
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
* Provides convenience functions for creating relay subscription filters. * Provides convenience functions for creating relay subscription filters.
*/ */
object FilterBuilders { object FilterBuilders {
private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent
/** /**
* Creates a filter for text notes (kind 1) from all authors. * Creates a filter for text notes (kind 1) from all authors.
* *
@@ -41,7 +43,7 @@ object FilterBuilders {
until: Long? = null, until: Long? = null,
): Filter = ): Filter =
Filter( Filter(
kinds = listOf(1), // TextNoteEvent.KIND kinds = FEED_KINDS,
limit = limit, limit = limit,
since = since, since = since,
until = until, until = until,
@@ -63,7 +65,7 @@ object FilterBuilders {
until: Long? = null, until: Long? = null,
): Filter = ): Filter =
Filter( Filter(
kinds = listOf(1), // TextNoteEvent.KIND kinds = FEED_KINDS,
authors = authors, authors = authors,
limit = limit, limit = limit,
since = since, since = since,
@@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.model.Note
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState 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.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState 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.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode 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.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription 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.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event 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( data class LightboxState(
val urls: List<String>, val urls: List<String>,
@@ -86,6 +98,8 @@ data class LightboxState(
/** /**
* Note card that reads counts from the Note model (cache-backed). * Note card that reads counts from the Note model (cache-backed).
* Event is extracted from Note for signing operations in NoteActionsRow. * 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 @Composable
fun FeedNoteCard( fun FeedNoteCard(
@@ -102,53 +116,141 @@ fun FeedNoteCard(
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null, onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) { ) {
val event = note.event ?: return val event = note.event ?: return
val isRepost = event is RepostEvent || event is GenericRepostEvent
// Observe Note.flowSet for live count updates if (isRepost) {
val flowSet = remember(note) { note.flow() } val originalNote = note.replyTo?.lastOrNull()
val reactionsState by flowSet.reactions.stateFlow.collectAsState() if (originalNote == null) {
val repliesState by flowSet.replies.stateFlow.collectAsState() return
val zapsState by flowSet.zaps.stateFlow.collectAsState() }
// Read counts from Note model (re-read on each stateFlow emission) // Observe original note's flowSet — MUST happen before reading .event
val reactionCount = note.countReactions() // so we recompose when the async fetch fills in the event
val replyCount = note.replies.size val flowSet = remember(originalNote) { originalNote.flow() }
val repostCount = note.boosts.size val metadataState by flowSet.metadata.stateFlow.collectAsState()
val zapAmount = note.zapsAmount 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(originalNote) {
DisposableEffect(note) { onDispose { originalNote.clearFlow() }
onDispose { note.clearFlow() } }
}
Column { // Now read event — recomposition will re-read this when metadata invalidates
NoteCard( val originalEvent = originalNote.event
note = event.toNoteDisplayData(localCache), if (originalEvent == null) {
localCache = localCache, return
onClick = { onNavigateToThread(event.id) }, }
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
)
// Action buttons (only if logged in) val reactionCount = originalNote.countReactions()
if (account != null) { val replyCount = originalNote.replies.size
NoteActionsRow( val repostCount = originalNote.boosts.size
event = event, val zapAmount = originalNote.zapsAmount
relayManager = relayManager,
localCache = localCache, val reposterUser = localCache.getUserIfExists(event.pubKey)
account = account, val originalUser = localCache.getUserIfExists(originalEvent.pubKey)
nwcConnection = nwcConnection,
onReplyClick = onReply, Column {
onZapFeedback = onZapFeedback, // Repost header: overlapping avatars + "Boosted" label
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = note.zaps.size, ) {
zapAmountSats = zapAmount.toLong(), GenericRepostLayout(
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps baseAuthorPicture = {
reactionCount = reactionCount, UserAvatar(
replyCount = replyCount, userHex = originalEvent.pubKey,
repostCount = repostCount, 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() 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) { LaunchedEffect(feedState, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
val notes = viewModel.feedState.visibleNotes() val notes = viewModel.feedState.visibleNotes()
if (notes.isNotEmpty()) { if (notes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(notes) 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<String>()
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<String>()
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) // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms)
DisposableEffect(feedMode, subscriptionsCoordinator) { DisposableEffect(feedMode, subscriptionsCoordinator) {
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
@@ -204,7 +204,8 @@ fun ReadsScreen(
val events by eventState.items.collectAsState() val events by eventState.items.collectAsState()
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(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) } var eoseReceivedCount by remember { mutableStateOf(0) }
val initialLoadComplete = eoseReceivedCount > 0 val initialLoadComplete = eoseReceivedCount > 0
@@ -272,7 +273,8 @@ fun ReadsScreen(
createFollowingLongFormFeedSubscription( createFollowingLongFormFeedSubscription(
relays = connectedRelays, relays = connectedRelays,
followedUsers = followedUsers.toList(), followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ -> onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
if (event is LongTextNoteEvent) { if (event is LongTextNoteEvent) {
eventState.addItem(event) eventState.addItem(event)
} }
@@ -121,6 +121,7 @@ fun UserProfileScreen(
onBack: () -> Unit, onBack: () -> Unit,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
@@ -796,6 +797,7 @@ fun UserProfileScreen(
onReply = onCompose, onReply = onCompose,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index) lightboxState = LightboxState(urls, index)
}, },
@@ -296,6 +296,7 @@ internal fun RootContent(
onBack = {}, onBack = {},
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToArticle = onNavigateToArticle, onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
@@ -325,6 +326,7 @@ internal fun RootContent(
onBack = {}, onBack = {},
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
} }
@@ -426,6 +428,7 @@ internal fun OverlayContent(
onBack = onBack, onBack = onBack,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToArticle = onNavigateToArticle, onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
@@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl 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.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
@@ -324,6 +328,7 @@ fun NoteCard(
private data class ResolvedMention( private data class ResolvedMention(
val displayText: String, val displayText: String,
val pubKeyHex: String? = null, val pubKeyHex: String? = null,
val noteIdHex: String? = null,
) )
/** /**
@@ -354,11 +359,11 @@ private fun resolveBech32(
} }
is NNote -> { is NNote -> {
ResolvedMention("note:${entity.hex.take(8)}...") ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
} }
is NEvent -> { is NEvent -> {
ResolvedMention("note:${entity.hex.take(8)}...") ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
} }
else -> { else -> {
@@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
* and navigate to profile on click. * 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<String>,
bech32s: Collection<String>,
): List<ContentSegment> {
val segments = mutableListOf<ContentSegment>()
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 @Composable
fun RichTextContent( fun RichTextContent(
content: String, content: String,
urls: Urls, urls: Urls,
localCache: DesktopLocalCache? = null, localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null, onMentionClick: ((String) -> Unit)? = null,
onNavigateToThread: ((String) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val defaultColor = MaterialTheme.colorScheme.onSurface val defaultColor = MaterialTheme.colorScheme.onSurface
@@ -404,80 +434,159 @@ fun RichTextContent(
color = defaultColor, color = defaultColor,
modifier = modifier, 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 { } else {
data class Segment( // Has quoted notes — render text + embedded note cards
val start: Int, Column(modifier = modifier) {
val raw: String, // Strip quoted note bech32 references from text
val isUrl: Boolean, val strippedText =
) remember(content, quotedBech32s) {
var text = content
val segments = mutableListOf<Segment>() for (bech32 in quotedBech32s) {
for (url in urls.withScheme) { text = text.replace(bech32, "").trim()
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))
} }
text
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
} }
// Add remaining text if (strippedText.isNotBlank()) {
if (lastIndex < content.length) { val inlineBech32s = urls.bech32s - quotedBech32s
append(content.substring(lastIndex)) val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) }
} RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick)
} }
Text( // Render quoted notes as embedded cards
text = annotatedText, for (noteId in quotedNoteIds) {
style = MaterialTheme.typography.bodyMedium, Spacer(Modifier.height(8.dp))
color = defaultColor, QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread)
modifier = modifier, }
) }
} }
} }
/**
* 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<ContentSegment>,
resolvedBech32s: Map<String, ResolvedMention>,
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,
)
}
@@ -37,7 +37,7 @@ class FilterBuildersTest {
fun testTextNotesGlobal() { fun testTextNotesGlobal() {
val filter = FilterBuilders.textNotesGlobal(limit = 50) val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertEquals(listOf(1), filter.kinds) assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(50, filter.limit) assertEquals(50, filter.limit)
assertNull(filter.authors) assertNull(filter.authors)
assertNull(filter.tags) assertNull(filter.tags)
@@ -51,7 +51,7 @@ class FilterBuildersTest {
val until = 1640995200L // 2022-01-01 val until = 1640995200L // 2022-01-01
val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until) 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(100, filter.limit)
assertEquals(since, filter.since) assertEquals(since, filter.since)
assertEquals(until, filter.until) assertEquals(until, filter.until)
@@ -62,7 +62,7 @@ class FilterBuildersTest {
val authors = listOf(testPubKey, testPubKey2) val authors = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25) val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
assertEquals(listOf(1), filter.kinds) assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(authors, filter.authors) assertEquals(authors, filter.authors)
assertEquals(25, filter.limit) assertEquals(25, filter.limit)
assertNull(filter.tags) assertNull(filter.tags)
@@ -74,7 +74,7 @@ class FilterBuildersTest {
val since = 1609459200L val since = 1609459200L
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since) 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(authors, filter.authors)
assertEquals(10, filter.limit) assertEquals(10, filter.limit)
assertEquals(since, filter.since) assertEquals(since, filter.since)
@@ -421,7 +421,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesGlobal(limit = 50) val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertTrue(!filter.isEmpty()) assertTrue(!filter.isEmpty())
assertEquals(listOf(1), filter.kinds) assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(50, filter.limit) assertEquals(50, filter.limit)
} }
@@ -431,7 +431,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50) val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
assertTrue(!filter.isEmpty()) assertTrue(!filter.isEmpty())
assertEquals(listOf(1), filter.kinds) assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(followedUsers, filter.authors) assertEquals(followedUsers, filter.authors)
assertEquals(50, filter.limit) assertEquals(50, filter.limit)
} }
@@ -447,7 +447,7 @@ class FilterBuildersTest {
assertTrue(!contactListFilter.isEmpty()) assertTrue(!contactListFilter.isEmpty())
assertEquals(listOf(0), metadataFilter.kinds) assertEquals(listOf(0), metadataFilter.kinds)
assertEquals(listOf(1), postsFilter.kinds) assertEquals(listOf(1, 6, 16), postsFilter.kinds)
assertEquals(listOf(3), contactListFilter.kinds) assertEquals(listOf(3), contactListFilter.kinds)
} }