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.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<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
/**
* Start processing the subscription queue.
@@ -110,6 +114,37 @@ class FeedMetadataCoordinator(
fun loadMetadataForNotes(notes: List<Note>) {
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
@@ -36,8 +36,10 @@ 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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
@@ -148,6 +150,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()
}
}
}
}
}
@@ -165,7 +174,7 @@ class DesktopLocalCache : ICacheProvider {
when (event) {
is MetadataEvent -> {
consumeMetadata(event)
true
false // metadata updates User, not Note — skip event stream
}
is TextNoteEvent -> {
@@ -188,6 +197,10 @@ class DesktopLocalCache : ICacheProvider {
consumeRepost(event, relay)
}
is GenericRepostEvent -> {
consumeGenericRepost(event, relay)
}
is ContactListEvent -> {
consumeContactList(event)
}
@@ -200,6 +213,10 @@ class DesktopLocalCache : ICacheProvider {
consumeBookmarkList(event)
}
is CommentEvent -> {
consumeComment(event, relay)
}
else -> {
false
}
@@ -216,7 +233,25 @@ 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) }
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) }
@@ -299,6 +334,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 +344,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) }
@@ -28,12 +28,29 @@ 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<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(
private val cache: DesktopLocalCache,
@@ -42,19 +59,20 @@ class DesktopGlobalFeedFilter(
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> note.event is TextNoteEvent }
.filterIntoSet { _, note -> isFeedNote(note.event) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.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
}
/**
* 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 +84,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<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
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(
private val pubkey: HexKey,
@@ -124,19 +143,21 @@ class DesktopProfileFeedFilter(
) : AdditiveFeedFilter<Note>() {
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> =
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<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isProfileNote(it) }
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
}
@@ -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)
},
)
@@ -37,7 +37,7 @@ enum class FeedMode {
*/
fun createGlobalFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 50,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
@@ -55,7 +55,7 @@ fun createGlobalFeedSubscription(
fun createFollowingFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 50,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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,
),
),
@@ -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,
@@ -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<String>,
@@ -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<String>, 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<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)
DisposableEffect(feedMode, subscriptionsCoordinator) {
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
@@ -204,7 +204,8 @@ fun ReadsScreen(
val events by eventState.items.collectAsState()
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) }
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)
}
@@ -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)
},
@@ -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,
)
@@ -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<String>): List<String> =
* 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<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
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<Segment>()
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<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() {
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)
}