From e33fef2ebd269ad45deece4986e5052c9526ea0c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 23 May 2026 15:46:46 +0300 Subject: [PATCH] feat(desktop): rich text migration, copy raw JSON, and profile metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../service/DesktopCachedRichTextParser.kt | 56 ++ .../amethyst/desktop/ui/EventExtensions.kt | 2 + .../amethyst/desktop/ui/NoteActions.kt | 7 + .../amethyst/desktop/ui/UserProfileScreen.kt | 130 ++++ .../desktop/ui/note/DesktopRichTextViewer.kt | 690 ++++++++++++++++++ .../amethyst/desktop/ui/note/NoteCard.kt | 197 +---- 6 files changed, 907 insertions(+), 175 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt new file mode 100644 index 0000000000..c478f7f6ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt @@ -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(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_CACHE_SIZE + }, + ) + + fun parseText( + content: String, + tags: ImmutableListOfLists, + 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("](") +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 26650f5140..0367228efa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -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), ) } 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..80877ac126 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 @@ -763,6 +763,13 @@ fun NoteActionsRow( showOverflowMenu = false }, ) + DropdownMenuItem( + text = { Text("Copy Raw JSON") }, + onClick = { + copyToClipboard(event.toJson()) + showOverflowMenu = false + }, + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 5f8661a258..2aa85742a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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 + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt new file mode 100644 index 0000000000..ecd357b1df --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -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, 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, +) { + 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) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 1b0208f63a..7b152971ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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 = EmptyTagList, ) /** @@ -99,8 +95,10 @@ fun NoteCard( onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, onMentionClick: ((String) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, 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): List = } } -/** - * 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, - bech32s: Collection, -): List { - val segments = mutableListOf() - 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, - resolvedBech32s: Map, - 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, - ) -}