Merge pull request #2027 from nrobi144/fix/general-bugfixes
fix: desktop bugfixes — flaky test, repost rendering, reads feed
This commit is contained in:
+40
@@ -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),
|
||||
)
|
||||
}
|
||||
+74
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
|
||||
Vendored
+60
-3
@@ -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) }
|
||||
|
||||
+38
-17
@@ -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
|
||||
}
|
||||
|
||||
+3
-5
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+15
-3
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+242
-41
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
},
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
|
||||
+180
-71
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user