From 956ae219263ecb7fc9afeb693b69d4252386ca23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 20:29:14 +0000 Subject: [PATCH] refactor: replace FlowRow-based RenderTextParagraph with single Text() composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderTextParagraph previously assembled each word/segment as a separate composable inside a FlowRow. It now outputs a single Text() composable with a built AnnotatedString and an InlineTextContent map so images, videos, and custom emojis are shown inline within the text flow: - Images and videos (with preview): InlineTextContent with a fixed-size media placeholder (300×200 sp) containing ZoomableContentView - Custom emojis: InlineTextContent with emoji-sized placeholder per image glyph via CustomEmoji.assembleAnnotatedList - Hashtags: LinkAnnotation.Clickable span + optional InlineTextContent icon for decorated hashtags (#bitcoin, #nostr, etc.) - Clickable URLs, emails (mailto:), phones (tel:): LinkAnnotation.Url spans with primary-colour styling - Block-level widgets (URL previews, invoices, LNURL withdraw, Cashu, secret emojis, Nostr note/profile references): InlineTextContent with a 300×300 sp block placeholder - User/event tag references without quote preview: InlineTextContent with a narrow text-height reference placeholder (150 sp × emojiSize) The spaceWidth / measureSpaceWidth plumbing is removed from RenderTextParagraph and its callers (RenderRegular callback no longer receives Dp). measureSpaceWidth is kept because Highlight.kt still uses it for its own FlowRow layout. RenderWordWithPreview, RenderWordWithoutPreview, the private ZoomableContentView string-URL wrapper, RenderCustomEmoji, and NoProtocolUrlRenderer are all inlined into RenderTextParagraph and removed. HashtagIcon.kt @Preview is updated to the new API. https://claude.ai/code/session_01TtfLvFoR5Qpr2bHBWr5z3f --- .../amethyst/model/HashtagIcon.kt | 27 +- .../amethyst/ui/components/RichTextViewer.kt | 684 ++++++++++-------- 2 files changed, 404 insertions(+), 307 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index d26468f1c..5e53d3b67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -45,29 +45,36 @@ import com.vitorpamplona.amethyst.commons.hashtags.Skull import com.vitorpamplona.amethyst.commons.hashtags.Tunestr import com.vitorpamplona.amethyst.commons.hashtags.Weed import com.vitorpamplona.amethyst.commons.hashtags.Zap +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment -import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment -import com.vitorpamplona.amethyst.ui.components.HashTag import com.vitorpamplona.amethyst.ui.components.RenderRegular import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Preview @Composable fun RenderHashTagIconsPreview() { + val accountViewModel = mockAccountViewModel() ThemeComparisonColumn { RenderRegular( "Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", EmptyTagList, - ) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - when (word) { - is HashTagSegment -> HashTag(word, EmptyNav()) - is RegularTextSegment -> Text(word.segmentText) - } - } + ) { paragraph, state, modifier -> + RenderTextParagraph( + paragraph = paragraph, + modifier = modifier, + state = state, + canPreview = false, + quotesLeft = 0, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + callbackUri = null, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 73aa212b6..e47534281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -20,14 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -50,16 +47,24 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.compose.produceCachedState import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -82,7 +87,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment 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.model.HashtagIcon @@ -105,7 +109,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders -import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier @@ -150,21 +153,8 @@ fun RenderStrangeNamePreview() { RenderRegular( "If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here", EmptyTagList, - ) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - when (word) { - is BechSegment -> { - Text( - "FreeFrom Official \uD80C\uDD66", - modifier = Modifier.border(1.dp, Color.Red), - ) - } - - is RegularTextSegment -> { - Text(word.segmentText) - } - } - } + ) { paragraph, state, modifier -> + RenderTextParagraph(paragraph, modifier, state, canPreview = false, quotesLeft = 0, backgroundColor = remember { mutableStateOf(Color.Transparent) }, callbackUri = null, accountViewModel = mockAccountViewModel(), nav = EmptyNav()) } } } @@ -177,51 +167,8 @@ fun RenderRegularPreview() { "nostr:npub1e0z776cpe0gllgktjk54fuzv8pdfxmq6smsmh8xd7t8s7n474n9smk0txy but i'm Monthly funding" + " 7 other humans vitor@vitorpamplona.com at the moment so spread #test a bit thin, but won't always be the case.", EmptyTagList, - ) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> { - RenderCustomEmoji(word.segmentText, state) - } - - // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> { - ClickableEmail(word.segmentText) - } - - is PhoneSegment -> { - ClickablePhone(word.segmentText) - } - - is BechSegment -> { - CreateClickableText( - word.segmentText.substring(0, 10), - "", - 1, - route = Route.EventRedirect(word.segmentText), - nav = EmptyNav(), - ) - } - - is HashTagSegment -> { - HashTag(word, EmptyNav()) - } - - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> { - NoProtocolUrlRenderer(word) - } - - is RegularTextSegment -> { - Text(word.segmentText) - } - } - } + ) { paragraph, state, modifier -> + RenderTextParagraph(paragraph, modifier, state, canPreview = false, quotesLeft = 0, backgroundColor = remember { mutableStateOf(Color.Transparent) }, callbackUri = null, accountViewModel = mockAccountViewModel(), nav = EmptyNav()) } } } @@ -232,30 +179,8 @@ fun RenderRegularPreview2() { RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, - ) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - - // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - - is PhoneSegment -> ClickablePhone(word.segmentText) - - // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, EmptyNav()) - - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - - is RegularTextSegment -> Text(word.segmentText) - } - } + ) { paragraph, state, modifier -> + RenderTextParagraph(paragraph, modifier, state, canPreview = false, quotesLeft = 0, backgroundColor = remember { mutableStateOf(Color.Transparent) }, callbackUri = null, accountViewModel = mockAccountViewModel(), nav = EmptyNav()) } } @@ -279,31 +204,8 @@ fun RenderRegularPreview3() { "#ioメシヨソイゲーム\n" + "https://misskey.io/play/9g3qza4jow", tags, - ) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, null, accountViewModel) - - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - - // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - - is PhoneSegment -> ClickablePhone(word.segmentText) - - // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, EmptyNav()) - - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - - is RegularTextSegment -> Text(word.segmentText) - } - } + ) { paragraph, state, modifier -> + RenderTextParagraph(paragraph, modifier, state, canPreview = true, quotesLeft = 1, backgroundColor = remember { mutableStateOf(Color.Transparent) }, callbackUri = null, accountViewModel = accountViewModel, nav = EmptyNav()) } } @@ -318,41 +220,27 @@ private fun RenderRegular( accountViewModel: AccountViewModel, nav: INav, ) { - if (canPreview) { - RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> - if (paragraph is ImageGalleryParagraph) { - ImageGallery( - images = paragraph, - state = state, - accountViewModel = accountViewModel, - modifier = modifier, - roundedCorner = true, - ) - } else { - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - RenderWordWithPreview( - word, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - } - } - } else { - RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - RenderWordWithoutPreview( - word, - state, - backgroundColor, - accountViewModel, - nav, - ) - } + RenderRegular(content, tags, callbackUri) { paragraph, state, modifier -> + if (paragraph is ImageGalleryParagraph) { + ImageGallery( + images = paragraph, + state = state, + accountViewModel = accountViewModel, + modifier = modifier, + roundedCorner = true, + ) + } else { + RenderTextParagraph( + paragraph = paragraph, + modifier = modifier, + state = state, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) } } } @@ -360,32 +248,346 @@ private fun RenderRegular( @Composable fun RenderTextParagraph( paragraph: ParagraphState, - spaceWidth: Dp, modifier: Modifier, - renderWord: @Composable (word: Segment) -> Unit, + state: RichTextViewerState, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + callbackUri: String?, + accountViewModel: AccountViewModel, + nav: INav, ) { - FlowRow( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - renderWord(word) + val primaryColor = MaterialTheme.colorScheme.primary + val onBackgroundColor = MaterialTheme.colorScheme.onBackground + val fontSize = LocalTextStyle.current.fontSize + + val emojiSize = if (fontSize == TextUnit.Unspecified) 20.sp else fontSize.times(1.1f) + val emojiPlaceholder = Placeholder(emojiSize, emojiSize, PlaceholderVerticalAlign.Center) + + // Fixed-size placeholder for media (images / videos) + val mediaPlaceholder = Placeholder(300.sp, 200.sp, PlaceholderVerticalAlign.Bottom) + + // Fixed-size placeholder for block-level widgets (URL previews, invoices, note cards, etc.) + val blockPlaceholder = Placeholder(300.sp, 300.sp, PlaceholderVerticalAlign.Bottom) + + // Inline-text-height placeholder for user / event references shown as links + val refPlaceholder = Placeholder(150.sp, emojiSize, PlaceholderVerticalAlign.TextCenter) + + // Both the annotated string and the inlineContent map are built in a single pass so + // their "inline_$idx" keys always match. + val inlineContent = mutableMapOf() + + val annotatedText = + buildAnnotatedString { + paragraph.words.forEachIndexed { idx, word -> + if (idx > 0) append(" ") + + when (word) { + is RegularTextSegment -> append(word.segmentText) + + is EmojiSegment -> { + val emojiList = + com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji + .assembleAnnotatedList(word.segmentText, state.customEmoji) + if (emojiList != null) { + emojiList.forEachIndexed { emojiIdx, renderable -> + when (renderable) { + is com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji.TextType -> + append(renderable.text) + is com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji.ImageUrlType -> { + val key = "inline_${idx}_$emojiIdx" + appendInlineContent(key, renderable.url) + val url = renderable.url + inlineContent[key] = + InlineTextContent(emojiPlaceholder) { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(1.dp), + ) + } + } + } + } + } else { + append(word.segmentText) + } + } + + is HashTagSegment -> { + val hashtagIcon = checkForHashtagWithIcon(word.hashtag) + withLink( + LinkAnnotation.Clickable( + tag = "hashtag_$idx", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + nav.nav(Route.Hashtag(word.hashtag.lowercase())) + }, + ) { + append("#${word.hashtag}") + if (hashtagIcon != null) { + val key = "inline_$idx" + appendInlineContent(key, "[icon]") + val icon = hashtagIcon + inlineContent[key] = + InlineTextContent(inlinePlaceholder) { + Icon( + imageVector = icon.icon, + contentDescription = icon.description, + tint = Color.Unspecified, + modifier = icon.modifier, + ) + } + } + } + word.extras?.let { withStyle(SpanStyle(color = onBackgroundColor)) { append(it) } } + } + + is ImageSegment -> { + val key = "inline_$idx" + if (canPreview && state.imagesForPager.containsKey(word.segmentText)) { + appendInlineContent(key, "[image]") + val url = word.segmentText + inlineContent[key] = + InlineTextContent(mediaPlaceholder) { + state.imagesForPager[url]?.let { media -> + Box(Modifier.fillMaxSize()) { + ZoomableContentView( + content = media, + images = state.imageList, + roundedCorner = true, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } else { + withLink( + LinkAnnotation.Url( + word.segmentText, + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.segmentText) } + } + } + + is VideoSegment -> { + val key = "inline_$idx" + if (canPreview && state.imagesForPager.containsKey(word.segmentText)) { + appendInlineContent(key, "[video]") + val url = word.segmentText + inlineContent[key] = + InlineTextContent(mediaPlaceholder) { + state.imagesForPager[url]?.let { media -> + Box(Modifier.fillMaxSize()) { + ZoomableContentView( + content = media, + images = state.imageList, + roundedCorner = true, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } else { + withLink( + LinkAnnotation.Url( + word.segmentText, + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.segmentText) } + } + } + + is Base64Segment -> { + val key = "inline_$idx" + if (canPreview) { + appendInlineContent(key, "[image]") + val url = word.segmentText + inlineContent[key] = + InlineTextContent(mediaPlaceholder) { + state.imagesForPager[url]?.let { media -> + Box(Modifier.fillMaxSize()) { + ZoomableContentView( + content = media, + images = state.imageList, + roundedCorner = true, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } else { + append(word.segmentText) + } + } + + is LinkSegment -> { + val key = "inline_$idx" + if (canPreview) { + appendInlineContent(key, word.segmentText) + val url = word.segmentText + inlineContent[key] = + InlineTextContent(blockPlaceholder) { + LoadUrlPreview(url, url, callbackUri, accountViewModel) + } + } else { + withLink( + LinkAnnotation.Url( + word.segmentText, + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.segmentText) } + } + } + + is InvoiceSegment -> { + if (canPreview) { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val invoice = word.segmentText + inlineContent[key] = + InlineTextContent(blockPlaceholder) { + MayBeInvoicePreview(invoice, accountViewModel) + } + } else { + append(word.segmentText) + } + } + + is WithdrawSegment -> { + if (canPreview) { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val withdraw = word.segmentText + inlineContent[key] = + InlineTextContent(blockPlaceholder) { + MayBeWithdrawal(withdraw, accountViewModel) + } + } else { + append(word.segmentText) + } + } + + is CashuSegment -> { + if (canPreview) { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val cashu = word.segmentText + inlineContent[key] = + InlineTextContent(blockPlaceholder) { + CashuPreview(cashu, accountViewModel) + } + } else { + append(word.segmentText) + } + } + + is EmailSegment -> + withLink( + LinkAnnotation.Url( + "mailto:${word.segmentText}", + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.segmentText) } + + is PhoneSegment -> + withLink( + LinkAnnotation.Url( + "tel:${word.segmentText}", + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.segmentText) } + + is SecretEmoji -> { + if (canPreview && quotesLeft > 0) { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val segment = word + inlineContent[key] = + InlineTextContent(blockPlaceholder) { + DisplaySecretEmoji( + segment = segment, + state = state, + callbackUri = callbackUri, + canPreview = true, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + append(word.segmentText) + } + } + + is BechSegment -> { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val bech = word.segmentText + val noteBlockPlaceholder = + if (canPreview && quotesLeft > 0) blockPlaceholder else refPlaceholder + inlineContent[key] = + InlineTextContent(noteBlockPlaceholder) { + BechLink(bech, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } + } + + is HashIndexUserSegment -> { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val segment = word + inlineContent[key] = + InlineTextContent(refPlaceholder) { + TagLink(segment, accountViewModel, nav) + } + } + + is HashIndexEventSegment -> { + val key = "inline_$idx" + appendInlineContent(key, word.segmentText) + val segment = word + val eventPlaceholder = + if (canPreview && quotesLeft > 0) blockPlaceholder else refPlaceholder + inlineContent[key] = + InlineTextContent(eventPlaceholder) { + TagLink(segment, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } + } + + is SchemelessUrlSegment -> { + withLink( + LinkAnnotation.Url( + "https://${word.url}", + TextLinkStyles(SpanStyle(color = primaryColor)), + ), + ) { append(word.url) } + word.extras?.let { withStyle(SpanStyle(color = onBackgroundColor)) { append(it) } } + } + } + } } - } + + Text( + text = annotatedText, + inlineContent = inlineContent, + modifier = modifier, + ) } -@OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( content: String, tags: ImmutableListOfLists, callbackUri: String? = null, - renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit, + renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, modifier: Modifier) -> Unit, ) { val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } - val spaceWidth = measureSpaceWidth(LocalTextStyle.current) - val currentTextStyle = LocalTextStyle.current val textStyle = @@ -410,7 +612,6 @@ fun RenderRegular( renderParagraph( paragraph, state, - spaceWidth, Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), ) } @@ -434,114 +635,6 @@ fun measureSpaceWidth(textStyle: TextStyle): Dp { } } -@Composable -private fun RenderWordWithoutPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: INav, -) { - when (word) { - // Don't preview Images - is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) - - // Don't preview Videos - is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText) - - is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) - - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - - // Don't offer to pay invoices - is InvoiceSegment -> Text(word.segmentText) - - // Don't offer to withdraw - is WithdrawSegment -> Text(word.segmentText) - - is CashuSegment -> Text(word.segmentText) - - is EmailSegment -> ClickableEmail(word.segmentText) - - is SecretEmoji -> Text(word.segmentText) - - is PhoneSegment -> ClickablePhone(word.segmentText) - - is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - - is HashTagSegment -> HashTag(word, nav) - - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - - is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) - - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - - is RegularTextSegment -> Text(word.segmentText) - } -} - -@Composable -private fun RenderWordWithPreview( - word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - when (word) { - is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) - is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) - is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) - is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> Text(word.segmentText) - is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) - } -} - -@Composable -private fun ZoomableContentView( - word: String, - state: RichTextViewerState, - accountViewModel: AccountViewModel, -) { - state.imagesForPager[word]?.let { - Box(modifier = HalfVertPadding) { - ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel) - } - } -} - -@Composable -private fun NoProtocolUrlRenderer(segment: SchemelessUrlSegment) { - ClickableUrl(segment.url, "https://${segment.url}") - segment.extras?.let { it1 -> Text(it1) } -} - -@Composable -fun RenderCustomEmoji( - word: String, - state: RichTextViewerState, -) { - CreateTextWithEmoji( - text = word, - emojis = state.customEmoji, - ) -} - @Composable fun BechLink( word: String, @@ -657,7 +750,6 @@ fun DisplaySecretEmoji( } } -@OptIn(ExperimentalLayoutApi::class) @Composable fun CoreSecretMessage( localSecretContent: RichTextViewerState, @@ -668,35 +760,33 @@ fun CoreSecretMessage( nav: INav, ) { if (localSecretContent.paragraphs.size == 1) { - localSecretContent.paragraphs[0].words.forEach { word -> - RenderWordWithPreview( - word = word, - state = localSecretContent, - backgroundColor = backgroundColor, - quotesLeft = quotesLeft, - callbackUri = callbackUri, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RenderTextParagraph( + paragraph = localSecretContent.paragraphs[0], + modifier = Modifier, + state = localSecretContent, + canPreview = true, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) } else if (localSecretContent.paragraphs.size > 1) { - val spaceWidth = measureSpaceWidth(LocalTextStyle.current) - Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> val modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start) - RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> - RenderWordWithPreview( - word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } + RenderTextParagraph( + paragraph = paragraph, + modifier = modifier, + state = localSecretContent, + canPreview = true, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) } } }