refactor: replace FlowRow-based RenderTextParagraph with single Text() composable

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
This commit is contained in:
Claude
2026-02-28 20:29:14 +00:00
parent 1bff239183
commit 956ae21926
2 changed files with 404 additions and 307 deletions
@@ -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(),
)
}
}
}
@@ -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<Color>,
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<String, InlineTextContent>()
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<String>,
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<Color>,
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<Color>,
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,
)
}
}
}