mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+56
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+690
@@ -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)
|
||||
}
|
||||
+22
-175
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user