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:
+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
|
||||
|
||||
Reference in New Issue
Block a user