From 27543ac304662c65a969835a390aedf232f020f0 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 22 May 2026 07:00:31 +0300 Subject: [PATCH 1/2] feat(desktop): long-press details popups + right-click customize for note actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Long-press zap icon → floating popup with zap receipts (sender, amount, message) - Long-press like icon → floating popup with reactions grouped by emoji - Right-click like icon → emoji picker (DropdownMenu with 6 common emojis) - Right-click repost icon → Repost/Quote options (DropdownMenu) - Right-click zap icon → custom zap dialog (preserved existing behavior) - Long-press reply → opens thread (same as click) - ActivePopup sealed class ensures only one popup open at a time - Popup + ElevatedCard for rich content, DropdownMenu for option lists - combinedClickable with explicit ripple preserves IconButton UX - PopupProperties(focusable = true) for desktop click-outside dismiss - @Immutable on ZapReceipt for Compose stability - Note param added to NoteActionsRow, passed from FeedScreen Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 2 + .../amethyst/desktop/ui/NoteActions.kt | 562 +++++++++++++++--- 2 files changed, 479 insertions(+), 85 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b8acdd51a3..9eecca9937 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -209,6 +209,7 @@ fun FeedNoteCard( onReplyClick = onReply, onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + note = originalNote, zapCount = originalNote.zaps.size, zapAmountSats = zapAmount.toLong(), zapReceipts = emptyList(), @@ -257,6 +258,7 @@ fun FeedNoteCard( onReplyClick = onReply, onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + note = note, zapCount = note.zaps.size, zapAmountSats = zapAmount.toLong(), zapReceipts = emptyList(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 490125e088..4be1dbb783 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,19 +31,28 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -49,8 +61,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.vitorpamplona.amethyst.commons.icons.Bookmark import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled import com.vitorpamplona.amethyst.commons.icons.Reply @@ -58,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction @@ -89,6 +111,22 @@ import kotlin.coroutines.resume private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) +/** + * Mutually exclusive popup state for note action bar. + * Only one popup can be open at a time. + */ +sealed class ActivePopup { + data object None : ActivePopup() + + data object ZapReceipts : ActivePopup() + + data object Reactions : ActivePopup() + + data object EmojiPicker : ActivePopup() + + data object RepostOptions : ActivePopup() +} + /** * Feedback from a zap operation for UI display. */ @@ -115,6 +153,7 @@ sealed class ZapFeedback { /** * Data class representing a zap receipt for display. */ +@Immutable data class ZapReceipt( val senderPubKey: String, val amountSats: Long, @@ -395,6 +434,214 @@ fun ZapReceiptsDialog( ) } +/** + * Floating popup showing zap receipts from a Note's zaps map. + * Uses Popup + ElevatedCard for rich scrollable content. + */ +@Composable +fun ZapReceiptsPopup( + note: Note, + localCache: DesktopLocalCache, + onDismiss: () -> Unit, +) { + val zapEntries = + remember(note.zaps) { + note.zaps + .mapNotNull { (request, receipt) -> + val sender = + request.author?.toBestDisplayName() + ?: request.event?.pubKey?.take(12) + ?: return@mapNotNull null + val amount = + (receipt?.event as? LnZapEvent)?.amount?.toLong() + ?: return@mapNotNull null + val message = request.event?.content?.ifBlank { null } + Triple(sender, amount, message) + }.sortedByDescending { it.second } + } + + val totalSats = remember(zapEntries) { zapEntries.sumOf { it.second } } + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -40), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard( + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 300.dp) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (zapEntries.isEmpty()) { + Text( + "No zaps yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + // Header: total sats + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Zap, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + "${formatSats(totalSats)} sats", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + HorizontalDivider() + + // Sorted receipts + zapEntries.take(10).forEach { (sender, amount, message) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = sender, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!message.isNullOrBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Text( + text = "${formatSats(amount)} sats", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + if (zapEntries.size > 10) { + Text( + text = "and ${zapEntries.size - 10} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** + * Floating popup showing reactions grouped by emoji from a Note's reactions map. + * Uses Popup + ElevatedCard for rich scrollable content. + */ +@Composable +fun ReactionsPopup( + note: Note, + localCache: DesktopLocalCache, + onDismiss: () -> Unit, +) { + val totalCount = remember(note.reactions) { note.countReactions() } + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -40), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard( + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 300.dp) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (note.reactions.isEmpty()) { + Text( + "No reactions yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + // Header: total count + Text( + "$totalCount reaction${if (totalCount != 1) "s" else ""}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + HorizontalDivider() + + // Group by emoji + note.reactions.forEach { (emoji, reactionNotes) -> + val displayEmoji = if (emoji == "+") "\u2764\ufe0f" else emoji + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + displayEmoji, + fontSize = 16.sp, + ) + Text( + "${reactionNotes.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Sender names + reactionNotes.take(5).forEach { reactionNote -> + val senderName = + reactionNote.author?.toBestDisplayName() + ?: reactionNote.event?.pubKey?.take(12) + ?: "Unknown" + Text( + text = senderName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp), + ) + } + if (reactionNotes.size > 5) { + Text( + text = "and ${reactionNotes.size - 5} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + } + } + } +} + /** * Fetches metadata for multiple users in a single subscription. */ @@ -470,9 +717,13 @@ private suspend fun fetchMetadataForUsers( } } +private val EMOJI_OPTIONS = listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02") + /** * Action buttons row for a note (react, reply, repost, zap, bookmark). + * Supports click (action), long-press (view details popup), and right-click (customize). */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun NoteActionsRow( event: Event, @@ -482,6 +733,7 @@ fun NoteActionsRow( onReplyClick: () -> Unit, onZapFeedback: (ZapFeedback) -> Unit, modifier: Modifier = Modifier, + note: Note? = null, zapCount: Int = 0, zapAmountSats: Long = 0, zapReceipts: List = emptyList(), @@ -502,16 +754,27 @@ fun NoteActionsRow( var showZapReceiptsDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + // Mutually exclusive popup state + var activePopup by remember { mutableStateOf(ActivePopup.None) } + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Reply button with count + // Reply button with count — long-press = same as click (open thread) Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = onReplyClick, - modifier = Modifier.size(32.dp), + Box( + modifier = + Modifier + .size(32.dp) + .combinedClickable( + onClick = onReplyClick, + onLongClick = onReplyClick, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ), + contentAlignment = Alignment.Center, ) { Icon( Reply, @@ -529,37 +792,91 @@ fun NoteActionsRow( } } - // Like button with count + // Like button with count — long-press = reactions popup, right-click = emoji picker Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { - if (!isLiked) { - scope.launch { - reactToNote( - // TODO: Bring a hint to where the event came from - event = EventHintBundle(event, null), - reaction = "+", - account = account, - relayManager = relayManager, - ) - isLiked = true - localReactionCount++ - } + Box { + Box( + modifier = + Modifier + .size(32.dp) + .combinedClickable( + onClick = { + if (!isLiked) { + scope.launch { + reactToNote( + event = EventHintBundle(event, null), + reaction = "+", + account = account, + relayManager = relayManager, + ) + isLiked = true + localReactionCount++ + } + } + }, + onLongClick = { + if (note != null) { + activePopup = ActivePopup.Reactions + } + }, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ).onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + activePopup = ActivePopup.EmojiPicker + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + if (isLiked) MaterialSymbols.Favorite else MaterialSymbols.FavoriteBorder, + contentDescription = if (isLiked) "Unlike" else "Like", + tint = + if (isLiked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Reactions popup (long-press) + if (activePopup is ActivePopup.Reactions && note != null) { + ReactionsPopup( + note = note, + localCache = localCache, + onDismiss = { activePopup = ActivePopup.None }, + ) + } + + // Emoji picker (right-click) + DropdownMenu( + expanded = activePopup is ActivePopup.EmojiPicker, + onDismissRequest = { activePopup = ActivePopup.None }, + ) { + EMOJI_OPTIONS.forEach { emoji -> + val displayEmoji = if (emoji == "+") "\u2764\ufe0f" else emoji + DropdownMenuItem( + text = { Text(displayEmoji, fontSize = 20.sp) }, + onClick = { + activePopup = ActivePopup.None + if (!isLiked) { + scope.launch { + reactToNote( + event = EventHintBundle(event, null), + reaction = emoji, + account = account, + relayManager = relayManager, + ) + isLiked = true + localReactionCount++ + } + } + }, + ) } - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - if (isLiked) MaterialSymbols.Favorite else MaterialSymbols.FavoriteBorder, - contentDescription = if (isLiked) "Unlike" else "Like", - tint = - if (isLiked) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) + } } if (localReactionCount > 0) { Text( @@ -570,36 +887,82 @@ fun NoteActionsRow( } } - // Repost button with count + // Repost button with count — right-click = repost options Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { - if (!isReposted) { - scope.launch { - repostNote( - // TODO: Bring a hint to where the event came from - event = EventHintBundle(event, null), - account = account, - relayManager = relayManager, - ) - isReposted = true - localRepostCount++ - } - } - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - Repost, - contentDescription = "Repost", - tint = - if (isReposted) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant + Box { + Box( + modifier = + Modifier + .size(32.dp) + .combinedClickable( + onClick = { + if (!isReposted) { + scope.launch { + repostNote( + event = EventHintBundle(event, null), + account = account, + relayManager = relayManager, + ) + isReposted = true + localRepostCount++ + } + } + }, + onLongClick = { /* no long-press action for repost */ }, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ).onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + activePopup = ActivePopup.RepostOptions + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Repost, + contentDescription = "Repost", + tint = + if (isReposted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Repost options (right-click) + DropdownMenu( + expanded = activePopup is ActivePopup.RepostOptions, + onDismissRequest = { activePopup = ActivePopup.None }, + ) { + DropdownMenuItem( + text = { Text("Repost") }, + onClick = { + activePopup = ActivePopup.None + if (!isReposted) { + scope.launch { + repostNote( + event = EventHintBundle(event, null), + account = account, + relayManager = relayManager, + ) + isReposted = true + localRepostCount++ + } + } }, - modifier = Modifier.size(18.dp), - ) + ) + DropdownMenuItem( + text = { Text("Quote") }, + onClick = { + activePopup = ActivePopup.None + // Copy note link to clipboard for quoting + val noteLink = "nostr:${NNote.create(event.id)}" + copyToClipboard(noteLink) + }, + ) + } } if (localRepostCount > 0) { Text( @@ -610,33 +973,62 @@ fun NoteActionsRow( } } - // Zap button with amount (clickable to show receipts) + // Zap button with amount — long-press = zap receipts popup, right-click = custom zap dialog Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { - if (isZapping) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - IconButton( - onClick = { showZapDialog = true }, - modifier = Modifier.size(32.dp), - ) { - Icon( - Zap, - contentDescription = "Zap", - tint = - if (zapAmountSats > 0) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), + Box { + Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { + if (isZapping) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, ) + } else { + Box( + modifier = + Modifier + .size(32.dp) + .combinedClickable( + onClick = { showZapDialog = true }, + onLongClick = { + if (note != null) { + activePopup = ActivePopup.ZapReceipts + } else { + showZapReceiptsDialog = true + } + }, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ).onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + showZapDialog = true + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Zap, + contentDescription = "Zap", + tint = + if (zapAmountSats > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } } } + + // Zap receipts popup (long-press) + if (activePopup is ActivePopup.ZapReceipts && note != null) { + ZapReceiptsPopup( + note = note, + localCache = localCache, + onDismiss = { activePopup = ActivePopup.None }, + ) + } } if (zapAmountSats > 0) { Text( @@ -792,7 +1184,7 @@ fun NoteActionsRow( ) } - // Zap receipts dialog + // Zap receipts dialog (from clicking the amount text) if (showZapReceiptsDialog) { ZapReceiptsDialog( receipts = zapReceipts, From 3185df21b6e0e7a79f3ca622a650615067cb1131 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 22 May 2026 07:37:53 +0300 Subject: [PATCH 2/2] fix(desktop): reactive counters, quote boost, and boost detail popup - Add kind 1 (replies) to interaction subscriptions - Key count reads on FlowSet state for reactive updates - Wire Quote menu item to ComposeNoteDialog with q-tag support - Add BoostsPopup on long-press repost icon (who boosted) - ComposeNoteDialog now accepts quoteOf param with nostr: URI pre-fill Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DesktopRelaySubscriptionsCoordinator.kt | 5 + .../amethyst/desktop/ui/ComposeNoteDialog.kt | 37 ++- .../amethyst/desktop/ui/FeedScreen.kt | 77 +++-- .../amethyst/desktop/ui/NoteActions.kt | 271 ++++++++++++++++-- .../amethyst/desktop/ui/ThreadScreen.kt | 39 +++ 5 files changed, 371 insertions(+), 58 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 0a195b25bf..afa616fb00 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -175,6 +175,11 @@ class DesktopRelaySubscriptionsCoordinator( kinds = listOf(com.vitorpamplona.quartz.nip18Reposts.RepostEvent.KIND), tags = mapOf("e" to noteIds), ), + // Replies (kind 1) targeting these notes + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND), + tags = mapOf("e" to noteIds), + ), ) val listener = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 50b1715581..ab5a6c3ef7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -74,6 +74,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.quote +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -96,8 +99,19 @@ fun ComposeNoteDialog( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, replyTo: Event? = null, + quoteOf: Event? = null, ) { - var content by remember { mutableStateOf("") } + val initialContent = + remember(quoteOf) { + if (quoteOf != null) { + val relays = relayManager.connectedRelays.value.take(3) + val nevent = NEvent.create(quoteOf.id, quoteOf.pubKey, quoteOf.kind, relays) + "\nnostr:$nevent" + } else { + "" + } + } + var content by remember { mutableStateOf(initialContent) } var isPosting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -165,7 +179,11 @@ fun ComposeNoteDialog( ) { Column(modifier = Modifier.padding(24.dp)) { Text( - if (replyTo != null) "Reply" else "New Note", + when { + replyTo != null -> "Reply" + quoteOf != null -> "Quote" + else -> "New Note" + }, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -179,6 +197,15 @@ fun ComposeNoteDialog( ) } + quoteOf?.let { quoted -> + Spacer(Modifier.height(8.dp)) + Text( + "Quoting: ${quoted.content.take(50)}${if (quoted.content.length > 50) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(16.dp)) OutlinedTextField( @@ -357,6 +384,7 @@ fun ComposeNoteDialog( account = account, relayManager = relayManager, replyTo = replyTo, + quoteOf = quoteOf, imetaTags = imetaTags, relays = selectedRelays, ) @@ -529,6 +557,7 @@ private suspend fun publishNote( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, replyTo: Event?, + quoteOf: Event? = null, imetaTags: List = emptyList(), relays: Set, ) { @@ -546,6 +575,10 @@ private suspend fun publishNote( eTag(etag) pTag(PTag(replyTo.pubKey, relayHint = null)) } + if (quoteOf != null) { + quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey)) + pTag(PTag(quoteOf.pubKey, relayHint = null)) + } hashtags(findHashtags(content)) references(findURLs(content)) for (imeta in imetaTags) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 9eecca9937..8c666e145c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -153,10 +154,10 @@ fun FeedNoteCard( return } - val reactionCount = originalNote.countReactions() - val replyCount = originalNote.replies.size - val repostCount = originalNote.boosts.size - val zapAmount = originalNote.zapsAmount + val reactionCount = remember(reactionsState) { originalNote.countReactions() } + val replyCount = remember(repliesState) { originalNote.replies.size } + val repostCount = remember(metadataState) { originalNote.boosts.size } + val zapAmount = remember(zapsState) { originalNote.zapsAmount } val reposterUser = localCache.getUserIfExists(event.pubKey) val originalUser = localCache.getUserIfExists(originalEvent.pubKey) @@ -170,15 +171,15 @@ fun FeedNoteCard( GenericRepostLayout( baseAuthorPicture = { UserAvatar( - userHex = originalEvent.pubKey, - pictureUrl = originalUser?.profilePicture(), + userHex = event.pubKey, + pictureUrl = reposterUser?.profilePicture(), size = 35.dp, ) }, repostAuthorPicture = { UserAvatar( - userHex = event.pubKey, - pictureUrl = reposterUser?.profilePicture(), + userHex = originalEvent.pubKey, + pictureUrl = originalUser?.profilePicture(), size = 35.dp, ) }, @@ -216,6 +217,8 @@ fun FeedNoteCard( reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, ) } } @@ -227,10 +230,10 @@ fun FeedNoteCard( 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 + val reactionCount = remember(reactionsState) { note.countReactions() } + val replyCount = remember(repliesState) { note.replies.size } + val repostCount = remember(metadataState) { note.boosts.size } + val zapAmount = remember(zapsState) { note.zapsAmount } DisposableEffect(note) { onDispose { note.clearFlow() } @@ -265,6 +268,8 @@ fun FeedNoteCard( reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, ) } } @@ -409,6 +414,15 @@ fun FeedScreen( val feedState by viewModel.feedState.feedContent.collectAsState() + // Force refresh when followedUsers arrives and feed is still empty + LaunchedEffect(followedUsers, feedState) { + if (followedUsers.isNotEmpty() && feedState is FeedState.Empty) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + viewModel.feedState.refreshSuspended() + } + } + } + // Viewport-aware metadata loading: only fetch for visible notes + buffer // Uses snapshotFlow to avoid per-frame recomposition from scroll observation LaunchedEffect(feedState, subscriptionsCoordinator) { @@ -511,19 +525,32 @@ fun FeedScreen( ) } - // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) - DisposableEffect(feedMode, subscriptionsCoordinator) { - val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} - val relays = relayManager.relayStatuses.value.keys - // Initial subscription with whatever notes are visible now - val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id } - val subId = - if (noteIds.isNotEmpty()) { - coordinator.requestInteractions(noteIds, relays) - } else { - null - } - onDispose { subId?.let { coordinator.releaseInteractions(it) } } + // Interaction subscriptions (reactions, zaps, reposts, replies) — same pattern as metadata + val interactionNoteIds = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + viewModel.feedState.visibleNotes().mapNotNull { it.event?.id } + } + + rememberSubscription(allRelayUrls, interactionNoteIds, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || interactionNoteIds.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-interactions"), + filters = + listOf( + FilterBuilders.reactionsForEvents(interactionNoteIds), + FilterBuilders.zapsForEvents(interactionNoteIds), + FilterBuilders.repostsForEvents(interactionNoteIds), + Filter( + kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND), + tags = mapOf("e" to interactionNoteIds), + ), + ), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) } Box(modifier = Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 4be1dbb783..8f90bfdf00 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient @@ -125,6 +126,8 @@ sealed class ActivePopup { data object EmojiPicker : ActivePopup() data object RepostOptions : ActivePopup() + + data object Boosts : ActivePopup() } /** @@ -442,25 +445,52 @@ fun ZapReceiptsDialog( fun ZapReceiptsPopup( note: Note, localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, onDismiss: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, ) { + var metadataVersion by remember { mutableIntStateOf(0) } + + // Fetch missing metadata for zap senders + LaunchedEffect(note.idHex) { + val pubKeys = + note.zaps.keys + .mapNotNull { it.event?.pubKey } + .distinct() + .filter { localCache.getUserIfExists(it)?.profilePicture() == null } + if (pubKeys.isNotEmpty()) { + fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ } + } + } + + @Suppress("UNUSED_EXPRESSION") + metadataVersion + + data class ZapEntry( + val pubKey: String, + val pictureUrl: String?, + val name: String, + val amount: Long, + val message: String?, + ) + val zapEntries = - remember(note.zaps) { + remember(note.zaps, metadataVersion) { note.zaps .mapNotNull { (request, receipt) -> - val sender = - request.author?.toBestDisplayName() - ?: request.event?.pubKey?.take(12) - ?: return@mapNotNull null + val pubKey = request.event?.pubKey ?: return@mapNotNull null + val user = request.author + val name = user?.toBestDisplayName() ?: pubKey.take(12) + val pictureUrl = user?.profilePicture() val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong() ?: return@mapNotNull null val message = request.event?.content?.ifBlank { null } - Triple(sender, amount, message) - }.sortedByDescending { it.second } + ZapEntry(pubKey, pictureUrl, name, amount, message) + }.sortedByDescending { it.amount } } - val totalSats = remember(zapEntries) { zapEntries.sumOf { it.second } } + val totalSats = remember(zapEntries) { zapEntries.sumOf { it.amount } } Popup( alignment = Alignment.TopCenter, @@ -508,21 +538,30 @@ fun ZapReceiptsPopup( HorizontalDivider() // Sorted receipts - zapEntries.take(10).forEach { (sender, amount, message) -> + zapEntries.take(10).forEach { entry -> Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier.fillMaxWidth().clickable { + onDismiss() + onNavigateToProfile(entry.pubKey) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + UserAvatar( + userHex = entry.pubKey, + pictureUrl = entry.pictureUrl, + size = 24.dp, + ) Column(modifier = Modifier.weight(1f)) { Text( - text = sender, + text = entry.name, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) - if (!message.isNullOrBlank()) { + if (!entry.message.isNullOrBlank()) { Text( - text = message, + text = entry.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -530,7 +569,7 @@ fun ZapReceiptsPopup( } } Text( - text = "${formatSats(amount)} sats", + text = "${formatSats(entry.amount)} sats", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -557,9 +596,28 @@ fun ZapReceiptsPopup( fun ReactionsPopup( note: Note, localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, onDismiss: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, ) { - val totalCount = remember(note.reactions) { note.countReactions() } + var metadataVersion by remember { mutableIntStateOf(0) } + + LaunchedEffect(note.idHex) { + val pubKeys = + note.reactions.values + .flatten() + .mapNotNull { it.event?.pubKey } + .distinct() + .filter { localCache.getUserIfExists(it)?.profilePicture() == null } + if (pubKeys.isNotEmpty()) { + fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ } + } + } + + @Suppress("UNUSED_EXPRESSION") + metadataVersion + + val totalCount = remember(note.reactions, metadataVersion) { note.countReactions() } Popup( alignment = Alignment.TopCenter, @@ -613,18 +671,31 @@ fun ReactionsPopup( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Sender names + // Sender avatars + names reactionNotes.take(5).forEach { reactionNote -> - val senderName = - reactionNote.author?.toBestDisplayName() - ?: reactionNote.event?.pubKey?.take(12) - ?: "Unknown" - Text( - text = senderName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp), - ) + val pubKey = reactionNote.event?.pubKey ?: return@forEach + val user = reactionNote.author + val senderName = user?.toBestDisplayName() ?: pubKey.take(12) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier.padding(start = 24.dp).clickable { + onDismiss() + onNavigateToProfile(pubKey) + }, + ) { + UserAvatar( + userHex = pubKey, + pictureUrl = user?.profilePicture(), + size = 20.dp, + ) + Text( + text = senderName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (reactionNotes.size > 5) { Text( @@ -642,6 +713,111 @@ fun ReactionsPopup( } } +/** + * Floating popup showing who boosted (reposted) a note. + * Shows kind 6/1621 reposts only (matching Android — quotes are not aggregated on Note). + * Uses Popup + ElevatedCard for rich scrollable content. + */ +@Composable +fun BoostsPopup( + note: Note, + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + onDismiss: () -> Unit, + onNavigateToThread: (String) -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, +) { + var metadataVersion by remember { mutableIntStateOf(0) } + + data class BoostEntry( + val pubKey: String, + val pictureUrl: String?, + val name: String, + ) + + LaunchedEffect(note.idHex) { + val pubKeys = + note.boosts + .mapNotNull { it.event?.pubKey } + .distinct() + .filter { localCache.getUserIfExists(it)?.profilePicture() == null } + if (pubKeys.isNotEmpty()) { + fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ } + } + } + + @Suppress("UNUSED_EXPRESSION") + metadataVersion + + val boostEntries = + remember(note.boosts, metadataVersion) { + note.boosts.mapNotNull { boostNote -> + val pubKey = boostNote.event?.pubKey ?: return@mapNotNull null + val user = boostNote.author + BoostEntry(pubKey, user?.profilePicture(), user?.toBestDisplayName() ?: pubKey.take(12)) + } + } + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -40), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard( + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 300.dp) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (boostEntries.isEmpty()) { + Text( + "No reposts yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + "${boostEntries.size} repost${if (boostEntries.size != 1) "s" else ""}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + HorizontalDivider() + + boostEntries.take(10).forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier.clickable { + onDismiss() + onNavigateToProfile(entry.pubKey) + }, + ) { + UserAvatar(userHex = entry.pubKey, pictureUrl = entry.pictureUrl, size = 24.dp) + Text(entry.name, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + if (boostEntries.size > 10) { + Text( + text = "and ${boostEntries.size - 10} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + /** * Fetches metadata for multiple users in a single subscription. */ @@ -744,6 +920,8 @@ fun NoteActionsRow( isBookmarked: Boolean = false, bookmarkList: BookmarkListEvent? = null, onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, ) { var isLiked by remember { mutableStateOf(false) } var isReposted by remember { mutableStateOf(false) } @@ -757,6 +935,9 @@ fun NoteActionsRow( // Mutually exclusive popup state var activePopup by remember { mutableStateOf(ActivePopup.None) } + // Quote compose state + var quoteEvent by remember { mutableStateOf(null) } + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -846,7 +1027,9 @@ fun NoteActionsRow( ReactionsPopup( note = note, localCache = localCache, + relayManager = relayManager, onDismiss = { activePopup = ActivePopup.None }, + onNavigateToProfile = onNavigateToProfile, ) } @@ -908,7 +1091,11 @@ fun NoteActionsRow( } } }, - onLongClick = { /* no long-press action for repost */ }, + onLongClick = { + if (note != null) { + activePopup = ActivePopup.Boosts + } + }, indication = ripple(bounded = false, radius = 16.dp), interactionSource = remember { MutableInteractionSource() }, ).onPointerEvent(PointerEventType.Press) { pointerEvent -> @@ -957,12 +1144,22 @@ fun NoteActionsRow( text = { Text("Quote") }, onClick = { activePopup = ActivePopup.None - // Copy note link to clipboard for quoting - val noteLink = "nostr:${NNote.create(event.id)}" - copyToClipboard(noteLink) + quoteEvent = event }, ) } + + // Boosts popup (long-press) + if (activePopup is ActivePopup.Boosts && note != null) { + BoostsPopup( + note = note, + localCache = localCache, + relayManager = relayManager, + onDismiss = { activePopup = ActivePopup.None }, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + ) + } } if (localRepostCount > 0) { Text( @@ -1026,7 +1223,9 @@ fun NoteActionsRow( ZapReceiptsPopup( note = note, localCache = localCache, + relayManager = relayManager, onDismiss = { activePopup = ActivePopup.None }, + onNavigateToProfile = onNavigateToProfile, ) } } @@ -1194,6 +1393,16 @@ fun NoteActionsRow( onDismiss = { showZapReceiptsDialog = false }, ) } + + // Quote compose dialog + if (quoteEvent != null) { + ComposeNoteDialog( + onDismiss = { quoteEvent = null }, + relayManager = relayManager, + account = account, + quoteOf = quoteEvent, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d67732731e..c1878269a0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.feeds.FeedState @@ -62,12 +63,18 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription +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.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote /** * Desktop Thread Screen - displays a note and all its replies in a thread view. @@ -177,6 +184,38 @@ fun ThreadScreen( } } + // Fetch quoted notes referenced in thread content + val quotedNoteIds = + remember(threadNotes) { + threadNotes + .mapNotNull { it.event } + .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 } + .distinct() + } + + rememberSubscription(connectedRelays, quotedNoteIds, relayManager = relayManager) { + if (connectedRelays.isEmpty() || quotedNoteIds.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("thread-quoted"), + filters = listOf(FilterBuilders.byIds(quotedNoteIds)), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + // Calculate reply level for a note based on e-tags fun calculateLevel(note: Note): Int { val event = note.event ?: return 1