feat(desktop): rich text migration, copy raw JSON, and profile metadata

Replace desktop's 3-segment custom parser with commons' 23-segment
RichTextParser. Add DesktopRichTextViewer rendering all segment types:
hashtags, invoices (with NWC pay), cashu tokens, custom emoji, nowhere
links, emails, relay URLs, markdown, image galleries, and more.

Add 'Copy Raw JSON' to note overflow menu. Add nip05, website, and
lightning address to profile card with right-click copy support.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-23 15:46:46 +03:00
co-authored by Claude
parent 0d91245e02
commit e33fef2ebd
6 changed files with 907 additions and 175 deletions
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
object DesktopCachedRichTextParser {
private const val MAX_CACHE_SIZE = 50
private val cache =
java.util.Collections.synchronizedMap(
object : LinkedHashMap<String, RichTextViewerState>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, RichTextViewerState>) = size > MAX_CACHE_SIZE
},
)
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
cache[content]?.let { return it }
val state = RichTextParser().parseText(content, tags, callbackUri)
cache[content] = state
return state
}
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -49,5 +50,6 @@ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
profilePictureUrl = pictureUrl,
content = content,
createdAt = createdAt,
tags = ImmutableListOfLists(tags),
)
}
@@ -763,6 +763,13 @@ fun NoteActionsRow(
showOverflowMenu = false
},
)
DropdownMenuItem(
text = { Text("Copy Raw JSON") },
onClick = {
copyToClipboard(event.toJson())
showOverflowMenu = false
},
)
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -43,6 +44,8 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -63,7 +66,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -138,6 +144,25 @@ fun UserProfileScreen(
)
}
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
var nip05 by remember {
mutableStateOf(
cachedMetadata
?.flow
?.value
?.info
?.nip05,
)
}
var website by remember {
mutableStateOf(
cachedMetadata
?.flow
?.value
?.info
?.website,
)
}
var lnAddress by remember { mutableStateOf(cachedMetadata?.lnAddress()) }
var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) }
var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) }
@@ -258,6 +283,9 @@ fun UserProfileScreen(
displayName = metadata.displayName ?: metadata.name
about = metadata.about
picture = metadata.picture
nip05 = metadata.nip05
website = metadata.website
lnAddress = metadata.lnAddress()
}
// Store MetadataEvent for editing (only for own profile)
@@ -678,6 +706,47 @@ fun UserProfileScreen(
)
}
// Profile metadata fields
nip05?.takeIf { it.isNotBlank() }?.let { addr ->
Spacer(Modifier.height(8.dp))
ProfileMetadataField(
text = addr,
icon = MaterialSymbols.CheckCircle,
onClick = {
runCatching {
java.awt.Desktop.getDesktop().browse(
java.net.URI("https://${addr.substringAfter("@")}"),
)
}
},
)
}
website?.takeIf { it.isNotBlank() }?.let { site ->
Spacer(Modifier.height(4.dp))
ProfileMetadataField(
text = site.removePrefix("https://").removePrefix("http://").removeSuffix("/"),
copyValue = site,
icon = MaterialSymbols.Language,
onClick = {
runCatching {
val url = if (site.contains("://")) site else "https://$site"
java.awt.Desktop
.getDesktop()
.browse(java.net.URI(url))
}
},
)
}
lnAddress?.takeIf { it.isNotBlank() }?.let { addr ->
Spacer(Modifier.height(4.dp))
ProfileMetadataField(
text = addr,
icon = MaterialSymbols.Bolt,
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -1173,3 +1242,64 @@ private fun PublishedHighlightCard(
}
}
}
@Composable
private fun ProfileMetadataField(
text: String,
copyValue: String = text,
icon: com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol? = null,
onClick: () -> Unit = {},
) {
var showContextMenu by remember { mutableStateOf(false) }
Box(
modifier =
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (
event.buttons.isSecondaryPressed &&
event.changes.any { it.pressed && !it.previousPressed }
) {
showContextMenu = true
}
}
}
}.clickable { onClick() },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
if (icon != null) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(copyValue), null)
showContextMenu = false
},
)
}
}
}
@@ -0,0 +1,690 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.collections.immutable.ImmutableMap
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.net.URI
data class RichTextCallbacks(
val onMentionClick: ((String) -> Unit)? = null,
val onHashtagClick: ((String) -> Unit)? = null,
val onNavigateToThread: ((String) -> Unit)? = null,
val onImageClick: ((List<String>, Int) -> Unit)? = null,
val onPayInvoice: ((String) -> Unit)? = null,
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DesktopRichTextViewer(
content: String,
state: RichTextViewerState,
localCache: DesktopLocalCache? = null,
callbacks: RichTextCallbacks = RichTextCallbacks(),
modifier: Modifier = Modifier,
) {
if (DesktopCachedRichTextParser.isMarkdown(content)) {
RenderMarkdown(
content = content,
onLinkClick = { url ->
when {
url.startsWith("nostr:") -> {
val parsed = Nip19Parser.uriToRoute(url)
when (val entity = parsed?.entity) {
is NPub -> {
callbacks.onMentionClick?.invoke(entity.hex)
}
is NProfile -> {
callbacks.onMentionClick?.invoke(entity.hex)
}
is NNote -> {
callbacks.onNavigateToThread?.invoke(entity.hex)
}
is NEvent -> {
callbacks.onNavigateToThread?.invoke(entity.hex)
}
else -> {}
}
}
else -> {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
}
}
},
modifier = modifier,
)
return
}
Column(modifier = modifier) {
for (paragraph in state.paragraphs) {
when (paragraph) {
is ImageGalleryParagraph -> {
val urls = paragraph.words.map { it.segmentText }
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
for ((index, segment) in paragraph.words.withIndex()) {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.weight(1f)
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.then(
if (callbacks.onImageClick != null) {
Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
} else {
Modifier
},
),
contentScale = ContentScale.Crop,
)
}
}
}
else -> {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
for (word in paragraph.words) {
RenderSegment(word, state, localCache, callbacks)
}
}
}
}
}
}
}
@Composable
private fun RenderSegment(
segment: Segment,
state: RichTextViewerState,
localCache: DesktopLocalCache?,
callbacks: RichTextCallbacks,
) {
when (segment) {
is RegularTextSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
is LinkSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is SchemelessUrlSegment -> {
ClickableLink("https://${segment.segmentText}", segment.segmentText)
}
is BechSegment -> {
RenderBechSegment(segment, localCache, callbacks)
}
is HashTagSegment -> {
val display = "#${segment.hashtag}" + (segment.extras ?: "")
Text(
text = display,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { callbacks.onHashtagClick?.invoke(segment.hashtag) },
)
}
is HashIndexUserSegment -> {
val user = localCache?.getUserIfExists(segment.hex)
val display = "@${user?.toBestDisplayName() ?: segment.hex.take(8) + "..."}"
Text(
text = display + (segment.extras ?: ""),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { callbacks.onMentionClick?.invoke(segment.hex) },
)
}
is HashIndexEventSegment -> {
QuotedNoteEmbed(
noteId = segment.hex,
localCache = localCache,
onMentionClick = callbacks.onMentionClick,
onNavigateToThread = callbacks.onNavigateToThread,
)
}
is EmailSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI("mailto:${segment.segmentText}"))
}
},
)
}
is PhoneSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
is RelayUrlSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { copyToClipboard(segment.segmentText) },
)
}
is EmojiSegment -> {
RenderCustomEmojiSegment(segment.segmentText, state.customEmoji)
}
is NowhereLinkSegment -> {
RenderNowhereLinkCard(segment)
}
is InvoiceSegment -> {
RenderInvoiceCard(segment.segmentText, callbacks)
}
is CashuSegment -> {
RenderCashuCard(segment.segmentText)
}
is WithdrawSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is BlossomUriSegment -> {
ClickableLink(segment.segmentText, segment.segmentText)
}
is PdfSegment -> {
RenderPdfCard(segment.segmentText)
}
is Base64Segment -> {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
)
}
is ImageSegment -> {
AsyncImage(
model = segment.segmentText,
contentDescription = null,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
)
}
is VideoSegment -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(segment.segmentText))
}
},
)
}
is SecretEmoji -> {
RenderSecretEmoji(segment.segmentText)
}
else -> {
Text(
text = segment.segmentText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun ClickableLink(
url: String,
displayText: String,
) {
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
},
)
}
@Composable
private fun RenderBechSegment(
segment: BechSegment,
localCache: DesktopLocalCache?,
callbacks: RichTextCallbacks,
) {
val resolved =
remember(segment.segmentText, localCache) {
resolveBech32(segment.segmentText, localCache)
}
when {
resolved.noteIdHex != null -> {
QuotedNoteEmbed(
noteId = resolved.noteIdHex,
localCache = localCache,
onMentionClick = callbacks.onMentionClick,
onNavigateToThread = callbacks.onNavigateToThread,
)
}
resolved.pubKeyHex != null -> {
Text(
text = resolved.displayText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { callbacks.onMentionClick?.invoke(resolved.pubKeyHex) },
)
}
else -> {
Text(
text = resolved.displayText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun RenderInvoiceCard(
invoice: String,
callbacks: RichTextCallbacks,
) {
val amount =
remember(invoice) {
runCatching { LnInvoiceUtil.getAmountInSats(invoice) }.getOrNull()
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = "Lightning Invoice",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
text = if (amount != null) "$amount sats" else "Lightning Invoice",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { copyToClipboard(invoice) }) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Copy")
}
if (callbacks.onPayInvoice != null) {
TextButton(onClick = { callbacks.onPayInvoice.invoke(invoice) }) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = "Pay",
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Pay")
}
}
}
}
}
}
@Composable
private fun RenderCashuCard(token: String) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Cashu Token",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = token.take(40) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
TextButton(onClick = { copyToClipboard(token) }) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Copy")
}
}
}
}
@Composable
private fun RenderPdfCard(url: String) {
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(url))
}
},
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.PictureAsPdf,
contentDescription = "PDF",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
text = url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun RenderSecretEmoji(text: String) {
var expanded by remember { mutableStateOf(false) }
if (expanded) {
val decoded = remember(text) { runCatching { EmojiCoder.decode(text) }.getOrDefault(text) }
Text(
text = decoded,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.clickable { expanded = false },
)
} else {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable { expanded = true },
)
}
}
@Composable
private fun RenderCustomEmojiSegment(
word: String,
customEmoji: ImmutableMap<String, String>,
) {
val matchedEmoji = remember(word, customEmoji) { customEmoji.entries.firstOrNull { word.contains(it.key) } }
if (matchedEmoji != null) {
val parts = word.split(matchedEmoji.key, limit = 2)
Row(verticalAlignment = Alignment.CenterVertically) {
if (parts[0].isNotEmpty()) {
Text(parts[0], style = MaterialTheme.typography.bodyMedium)
}
AsyncImage(
model = matchedEmoji.value,
contentDescription = matchedEmoji.key,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
if (parts.size > 1 && parts[1].isNotEmpty()) {
Text(parts[1], style = MaterialTheme.typography.bodyMedium)
}
}
} else {
Text(word, style = MaterialTheme.typography.bodyMedium)
}
}
private val nowhereToolLabels =
mapOf(
"e" to "Nowhere Event",
"f" to "Nowhere Fundraiser",
"s" to "Nowhere Store",
"p" to "Nowhere Petition",
"m" to "Nowhere Message",
"d" to "Nowhere Drop",
"a" to "Nowhere Art",
"fo" to "Nowhere Forum",
)
@Composable
private fun RenderNowhereLinkCard(segment: NowhereLinkSegment) {
val label = nowhereToolLabels[segment.tool] ?: "Nowhere Site"
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.pointerHoverIcon(PointerIcon.Hand)
.clickable {
runCatching {
java.awt.Desktop
.getDesktop()
.browse(URI(segment.segmentText))
}
},
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = segment.host,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun copyToClipboard(text: String) {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(text), null)
}
@@ -46,20 +46,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.Urls
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
@@ -85,6 +80,7 @@ data class NoteDisplayData(
val profilePictureUrl: String? = null,
val content: String,
val createdAt: Long,
val tags: ImmutableListOfLists<String> = EmptyTagList,
)
/**
@@ -99,8 +95,10 @@ fun NoteCard(
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
onMentionClick: ((String) -> Unit)? = null,
onHashtagClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
onPayInvoice: ((String) -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
@@ -135,18 +133,6 @@ fun NoteCard(
}
text
}
val strippedUrls =
remember(urls, mediaUrls) {
Urls(
withScheme = urls.withScheme - mediaUrls,
withoutScheme = urls.withoutScheme,
emails = urls.emails,
bech32s = urls.bech32s,
relayUrls = urls.relayUrls,
blossomUris = urls.blossomUris,
)
}
// Cap media height to half the window so text is never pushed off-screen
val windowState = LocalWindowState.current
val maxMediaHeight =
@@ -212,11 +198,22 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
if (strippedContent.isNotBlank()) {
RichTextContent(
val richState =
remember(strippedContent, note.tags) {
DesktopCachedRichTextParser.parseText(strippedContent, note.tags)
}
DesktopRichTextViewer(
content = strippedContent,
urls = strippedUrls,
state = richState,
localCache = localCache,
onMentionClick = onMentionClick,
callbacks =
RichTextCallbacks(
onMentionClick = onMentionClick,
onHashtagClick = onHashtagClick,
onNavigateToThread = null,
onImageClick = onImageClick,
onPayInvoice = onPayInvoice,
),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -325,7 +322,7 @@ fun NoteCard(
/**
* Resolved bech32 mention with display text and optional pubkey for click navigation.
*/
private data class ResolvedMention(
internal data class ResolvedMention(
val displayText: String,
val pubKeyHex: String? = null,
val noteIdHex: String? = null,
@@ -336,7 +333,7 @@ private data class ResolvedMention(
* For npub/nprofile → @displayName + pubkey hex for navigation.
* For note/nevent → truncated note ID.
*/
private fun resolveBech32(
internal fun resolveBech32(
bech32: String,
localCache: DesktopLocalCache?,
): ResolvedMention {
@@ -386,99 +383,6 @@ fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
}
}
/**
* Renders text content with highlighted URLs and clickable nostr: bech32 mentions.
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
* and navigate to profile on click.
*/
private data class ContentSegment(
val start: Int,
val raw: String,
val isUrl: Boolean,
)
private fun buildSegments(
content: String,
schemeUrls: Collection<String>,
bech32s: Collection<String>,
): List<ContentSegment> {
val segments = mutableListOf<ContentSegment>()
for (url in schemeUrls) {
val idx = content.indexOf(url)
if (idx != -1) segments.add(ContentSegment(idx, url, true))
}
for (bech32 in bech32s) {
val idx = content.indexOf(bech32)
if (idx != -1) segments.add(ContentSegment(idx, bech32, false))
}
segments.sortBy { it.start }
return segments
}
@Composable
fun RichTextContent(
content: String,
urls: Urls,
localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null,
onNavigateToThread: ((String) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val defaultColor = MaterialTheme.colorScheme.onSurface
val primaryColor = MaterialTheme.colorScheme.primary
if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = defaultColor,
modifier = modifier,
)
return
}
// Resolve bech32s to find quoted notes vs inline mentions
val resolvedBech32s =
remember(urls.bech32s, localCache) {
urls.bech32s.associateWith { resolveBech32(it, localCache) }
}
// Collect quoted note IDs (nevent/note references)
val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys }
val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() }
if (quotedNoteIds.isEmpty()) {
// No quoted notes — render everything as annotated text
val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) }
RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier)
} else {
// Has quoted notes — render text + embedded note cards
Column(modifier = modifier) {
// Strip quoted note bech32 references from text
val strippedText =
remember(content, quotedBech32s) {
var text = content
for (bech32 in quotedBech32s) {
text = text.replace(bech32, "").trim()
}
text
}
if (strippedText.isNotBlank()) {
val inlineBech32s = urls.bech32s - quotedBech32s
val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) }
RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick)
}
// Render quoted notes as embedded cards
for (noteId in quotedNoteIds) {
Spacer(Modifier.height(8.dp))
QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread)
}
}
}
}
/**
* Renders a quoted note by ID. Observes the note's metadata flow so it
* recomposes when the event arrives asynchronously from a relay fetch.
@@ -533,60 +437,3 @@ fun QuotedNoteEmbed(
}
}
}
@Composable
private fun RichAnnotatedText(
content: String,
segments: List<ContentSegment>,
resolvedBech32s: Map<String, ResolvedMention>,
defaultColor: androidx.compose.ui.graphics.Color,
primaryColor: androidx.compose.ui.graphics.Color,
onMentionClick: ((String) -> Unit)?,
modifier: Modifier = Modifier,
) {
val annotatedText =
buildAnnotatedString {
var lastIndex = 0
for (seg in segments) {
if (seg.start < lastIndex) continue
if (seg.start > lastIndex) {
append(content.substring(lastIndex, seg.start))
}
if (seg.isUrl) {
withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) {
append(seg.raw)
}
} else {
val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw)
if (resolved.pubKeyHex != null && onMentionClick != null) {
val pubKey = resolved.pubKeyHex
withLink(
LinkAnnotation.Clickable(
tag = "mention",
styles = TextLinkStyles(SpanStyle(color = primaryColor)),
) {
onMentionClick(pubKey)
},
) {
append(resolved.displayText)
}
} else {
withStyle(SpanStyle(color = primaryColor)) {
append(resolved.displayText)
}
}
}
lastIndex = seg.start + seg.raw.length
}
if (lastIndex < content.length) {
append(content.substring(lastIndex))
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium,
color = defaultColor,
modifier = modifier,
)
}