Merge pull request #3034 from nrobi144/feat/desktop-note-action-ux

feat(desktop): note action bar — long-press details popups + right-click customize
This commit is contained in:
Vitor Pamplona
2026-05-22 18:05:22 -04:00
committed by GitHub
5 changed files with 819 additions and 112 deletions
@@ -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 =
@@ -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<String?>(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<IMetaTag> = emptyList(),
relays: Set<NormalizedRelayUrl>,
) {
@@ -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) {
@@ -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<String>()
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()) {
@@ -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<ZapReceipt> = 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>(ActivePopup.None) }
// Quote compose state
var quoteEvent by remember { mutableStateOf<Event?>(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,
)
}
}
/**
@@ -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