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, - ) -}