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 b8acdd51a3..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, ) }, @@ -209,12 +210,15 @@ 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(), reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, ) } } @@ -226,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() } @@ -257,12 +261,15 @@ 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(), reactionCount = reactionCount, replyCount = replyCount, repostCount = repostCount, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, ) } } @@ -407,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) { @@ -509,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 490125e088..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 @@ -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,11 +79,13 @@ 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 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 @@ -89,6 +112,24 @@ 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() + + data object Boosts : ActivePopup() +} + /** * Feedback from a zap operation for UI display. */ @@ -115,6 +156,7 @@ sealed class ZapFeedback { /** * Data class representing a zap receipt for display. */ +@Immutable data class ZapReceipt( val senderPubKey: String, val amountSats: Long, @@ -395,6 +437,387 @@ 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, + 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, metadataVersion) { + note.zaps + .mapNotNull { (request, receipt) -> + 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 } + ZapEntry(pubKey, pictureUrl, name, amount, message) + }.sortedByDescending { it.amount } + } + + val totalSats = remember(zapEntries) { zapEntries.sumOf { it.amount } } + + 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 { entry -> + Row( + 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 = entry.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!entry.message.isNullOrBlank()) { + Text( + text = entry.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Text( + text = "${formatSats(entry.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, + relayManager: DesktopRelayConnectionManager, + onDismiss: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, +) { + 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, + 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 avatars + names + reactionNotes.take(5).forEach { reactionNote -> + 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( + text = "and ${reactionNotes.size - 5} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + } + } + } +} + +/** + * 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. */ @@ -470,9 +893,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 +909,7 @@ fun NoteActionsRow( onReplyClick: () -> Unit, onZapFeedback: (ZapFeedback) -> Unit, modifier: Modifier = Modifier, + note: Note? = null, zapCount: Int = 0, zapAmountSats: Long = 0, zapReceipts: List = emptyList(), @@ -492,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) } @@ -502,16 +932,30 @@ fun NoteActionsRow( var showZapReceiptsDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + // 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), 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 +973,93 @@ 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, + relayManager = relayManager, + onDismiss = { activePopup = ActivePopup.None }, + onNavigateToProfile = onNavigateToProfile, + ) + } + + // 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 +1070,96 @@ 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 = { + if (note != null) { + activePopup = ActivePopup.Boosts + } + }, + 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 + 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( @@ -610,33 +1170,64 @@ 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, + relayManager = relayManager, + onDismiss = { activePopup = ActivePopup.None }, + onNavigateToProfile = onNavigateToProfile, + ) + } } if (zapAmountSats > 0) { Text( @@ -792,7 +1383,7 @@ fun NoteActionsRow( ) } - // Zap receipts dialog + // Zap receipts dialog (from clicking the amount text) if (showZapReceiptsDialog) { ZapReceiptsDialog( receipts = zapReceipts, @@ -802,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