feat(desktop): render reposts and quoted notes in feed

- Extract GenericRepostLayout + BoostedMark to commons for cross-platform use
- Feed filters accept kind 6/16 reposts with deduplication by original note
- Relay subscriptions request kinds 1, 6, 16
- Cache consumes GenericRepostEvent (kind 16) and uses getOrCreateNote for
  repost originals to handle out-of-order arrival
- FeedNoteCard renders reposts with overlapping avatars + "Boosted" label
- QuotedNoteEmbed renders nostr:nevent/note references as embedded NoteCards
  with reactive metadata observation
- Direct relay subscriptions fetch missing referenced notes and author metadata
- FeedMetadataCoordinator routes all events (not just metadata) back to cache
- consumeMetadata invalidates note flows when author metadata arrives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-30 11:29:02 +03:00
parent 0e8c6fa434
commit 2b3005797e
10 changed files with 644 additions and 135 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