From 43f8053b304e89be65985338c5204fee92f02e2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 13:17:19 +0200 Subject: [PATCH] Extracted Parsing logic into helper class --- .../amethyst/ui/components/ParagraphParser.kt | 272 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 250 +++------------- 2 files changed, 307 insertions(+), 215 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt new file mode 100644 index 000000000..a943b37af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt @@ -0,0 +1,272 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import com.vitorpamplona.amethyst.commons.richtext.Base64Segment +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.ParagraphState +import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RenderContext( + val state: RichTextViewerState, + val backgroundColor: MutableState, + val quotesLeft: Int, + val callbackUri: String?, + val accountViewModel: AccountViewModel, + val nav: INav, +) + +data class ParagraphImageAnalysis( + val imageCount: Int, + val isImageOnly: Boolean, + val hasMultipleImages: Boolean, +) + +class ParagraphParser { + fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis { + var imageCount = 0 + var hasNonWhitespaceNonImageContent = false + + paragraph.words.forEach { word -> + when (word) { + is ImageSegment, is Base64Segment -> imageCount++ + is RegularTextSegment -> { + if (word.segmentText.isNotBlank()) { + hasNonWhitespaceNonImageContent = true + } + } + else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc. + } + } + + val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent + val hasMultipleImages = imageCount > 1 + + return ParagraphImageAnalysis( + imageCount = imageCount, + isImageOnly = isImageOnly, + hasMultipleImages = hasMultipleImages, + ) + } + + fun collectConsecutiveImageParagraphs( + paragraphs: ImmutableList, + startIndex: Int, + ): Pair, Int> { + val imageParagraphs = mutableListOf() + var j = startIndex + + while (j < paragraphs.size) { + val currentParagraph = paragraphs[j] + val words = currentParagraph.words + + // Fast path for empty check + if (words.isEmpty()) { + j++ + continue + } + + // Check for single whitespace word + if (words.size == 1) { + val firstWord = words.first() + if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) { + j++ + continue + } + } + + // Check if it's an image-only paragraph using unified analysis + val analysis = analyzeParagraphImages(currentParagraph) + if (analysis.isImageOnly) { + imageParagraphs.add(currentParagraph) + j++ + } else { + break + } + } + + return imageParagraphs to j + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun processParagraph( + paragraphs: ImmutableList, + paragraphIndex: Int, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, RenderContext) -> Unit, + ): Int { + val paragraph = paragraphs[paragraphIndex] + + if (paragraph.words.isEmpty()) { + // Empty paragraph - render normally with FlowRow (will render nothing) + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } + + val analysis = analyzeParagraphImages(paragraph) + + if (analysis.isImageOnly) { + // Collect consecutive image-only paragraphs for gallery + val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) + val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() + + if (allImageWords.size > 1) { + // Multiple images - render as gallery (no FlowRow wrapper needed) + renderImageGallery(allImageWords, context) + } else { + // Single image - render with FlowRow wrapper + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + } + + return endIndex // Return next index to process + } else if (analysis.hasMultipleImages) { + // Mixed paragraph with multiple images - use renderImageGallery for smart grouping + renderImageGallery(paragraph.words.toImmutableList(), context) + return paragraphIndex + 1 + } else { + // Regular paragraph (no images or single image) - render normally with FlowRow + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } + } + + @Composable + fun ProcessWordsWithImageGrouping( + words: ImmutableList, + context: RenderContext, + renderSingleWord: @Composable (Segment, RenderContext) -> Unit, + renderGallery: @Composable (ImmutableList, AccountViewModel) -> Unit, + ) { + var i = 0 + val n = words.size + + while (i < n) { + val word = words[i] + + if (word is ImageSegment || word is Base64Segment) { + // Collect consecutive image/whitespace segments without extra list allocations + val imageSegments = mutableListOf() + var j = i + + while (j < n) { + val seg = words[j] + when { + seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) + seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } + else -> break + } + j++ + } + + if (imageSegments.size > 1) { + val imageContents = + imageSegments + .mapNotNull { segment -> + val imageUrl = segment.segmentText + context.state.imagesForPager[imageUrl] as? MediaUrlImage + }.toImmutableList() + + if (imageContents.isNotEmpty()) { + renderGallery(imageContents, context.accountViewModel) + } + } else { + renderSingleWord(imageSegments.firstOrNull() ?: word, context) + } + + i = j // jump past processed run + } else { + renderSingleWord(word, context) + i++ + } + } + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun RenderSingleParagraphWithFlowRow( + paragraph: ParagraphState, + words: ImmutableList, + spaceWidth: Dp, + context: RenderContext, + renderWord: @Composable (Segment, RenderContext) -> Unit, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + words.forEach { word -> + renderWord(word, context) + } + } + } + } + + @Composable + fun ProcessAllParagraphs( + paragraphs: ImmutableList, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, RenderContext) -> Unit, + ) { + var i = 0 + while (i < paragraphs.size) { + i = + processParagraph( + paragraphs = paragraphs, + paragraphIndex = i, + spaceWidth = spaceWidth, + context = context, + renderSingleParagraph = renderSingleParagraph, + renderImageGallery = renderImageGallery, + ) + } + } +} 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 4130cc5a0..8a6d84cac 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 @@ -73,8 +73,6 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment 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.MediaUrlImage -import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState @@ -114,15 +112,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -data class RenderContext( - val state: RichTextViewerState, - val backgroundColor: MutableState, - val quotesLeft: Int, - val callbackUri: String?, - val accountViewModel: AccountViewModel, - val nav: INav, -) - fun isMarkdown(content: String): Boolean = content.startsWith("> ") || content.startsWith("# ") || @@ -333,161 +322,27 @@ fun RenderRegularWithGallery( ) val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + val paragraphParser = remember { ParagraphParser() } Column { - // Process each paragraph uniformly - var i = 0 - while (i < state.paragraphs.size) { - i = - renderParagraphWithFlowRow( - paragraphs = state.paragraphs, - paragraphIndex = i, - spaceWidth = spaceWidth, - context = context, + paragraphParser.ProcessAllParagraphs( + paragraphs = state.paragraphs, + spaceWidth = spaceWidth, + context = context, + renderSingleParagraph = { paragraph, words, width, ctx -> + paragraphParser.RenderSingleParagraphWithFlowRow( + paragraph = paragraph, + words = words, + spaceWidth = width, + context = ctx, + renderWord = { word, renderContext -> RenderWordWithPreview(word, renderContext) }, ) - } - } -} - -@Composable -private fun renderParagraphWithFlowRow( - paragraphs: ImmutableList, - paragraphIndex: Int, - spaceWidth: Dp, - context: RenderContext, -): Int { - val paragraph = paragraphs[paragraphIndex] - - if (paragraph.words.isEmpty()) { - // Empty paragraph - render normally with FlowRow (will render nothing) - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 - } - - val analysis = analyzeParagraphImages(paragraph) - - if (analysis.isImageOnly) { - // Collect consecutive image-only paragraphs for gallery - val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) - val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() - - if (allImageWords.size > 1) { - // Multiple images - render as gallery (no FlowRow wrapper needed) - RenderWordsWithImageGallery(allImageWords, context) - } else { - // Single image - render with FlowRow wrapper - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - } - - return endIndex // Return next index to process - } else if (analysis.hasMultipleImages) { - // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping - RenderWordsWithImageGallery(paragraph.words.toImmutableList(), context) - return paragraphIndex + 1 - } else { - // Regular paragraph (no images or single image) - render normally with FlowRow - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 - } -} - -@Composable -private fun RenderSingleParagraphWithFlowRow( - paragraph: ParagraphState, - words: ImmutableList, - spaceWidth: Dp, - context: RenderContext, -) { - CompositionLocalProvider( - LocalLayoutDirection provides - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr }, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - words.forEach { word -> - RenderWordWithPreview(word, context) - } - } + renderImageGallery = { words, ctx -> RenderWordsWithImageGallery(words, ctx) }, + ) } } -data class ParagraphImageAnalysis( - val imageCount: Int, - val isImageOnly: Boolean, - val hasMultipleImages: Boolean, -) - -private fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis { - var imageCount = 0 - var hasNonWhitespaceNonImageContent = false - - paragraph.words.forEach { word -> - when (word) { - is ImageSegment, is Base64Segment -> imageCount++ - is RegularTextSegment -> { - if (word.segmentText.isNotBlank()) { - hasNonWhitespaceNonImageContent = true - } - } - else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc. - } - } - - val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent - val hasMultipleImages = imageCount > 1 - - return ParagraphImageAnalysis( - imageCount = imageCount, - isImageOnly = isImageOnly, - hasMultipleImages = hasMultipleImages, - ) -} - -private fun collectConsecutiveImageParagraphs( - paragraphs: ImmutableList, - startIndex: Int, -): Pair, Int> { - val imageParagraphs = mutableListOf() - var j = startIndex - - while (j < paragraphs.size) { - val currentParagraph = paragraphs[j] - val words = currentParagraph.words - - // Fast path for empty check - if (words.isEmpty()) { - j++ - continue - } - - // Check for single whitespace word - if (words.size == 1) { - val firstWord = words.first() - if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) { - j++ - continue - } - } - - // Check if it's an image-only paragraph using unified analysis - val analysis = analyzeParagraphImages(currentParagraph) - if (analysis.isImageOnly) { - imageParagraphs.add(currentParagraph) - j++ - } else { - break - } - } - - return imageParagraphs to j -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( @@ -585,52 +440,20 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { - var i = 0 - val n = words.size + val paragraphParser = remember { ParagraphParser() } - while (i < n) { - val word = words[i] - - if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive image/whitespace segments without extra list allocations - val imageSegments = mutableListOf() - var j = i - - while (j < n) { - val seg = words[j] - when { - seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) - seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } - else -> break - } - j++ - } - - if (imageSegments.size > 1) { - val imageContents = - imageSegments - .mapNotNull { segment -> - val imageUrl = segment.segmentText - context.state.imagesForPager[imageUrl] as? MediaUrlImage - }.toImmutableList() - - if (imageContents.isNotEmpty()) { - ImageGallery( - images = imageContents, - accountViewModel = context.accountViewModel, - roundedCorner = true, - ) - } - } else { - RenderWordWithPreview(imageSegments.firstOrNull() ?: word, context) - } - - i = j // jump past processed run - } else { - RenderWordWithPreview(word, context) - i++ - } - } + paragraphParser.ProcessWordsWithImageGrouping( + words = words, + context = context, + renderSingleWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + renderGallery = { imageContents, accountViewModel -> + ImageGallery( + images = imageContents, + accountViewModel = accountViewModel, + roundedCorner = true, + ) + }, + ) } @Composable @@ -832,20 +655,17 @@ fun CoreSecretMessage( } } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + val paragraphParser = remember { ParagraphParser() } Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - context, - ) - } - } + paragraphParser.RenderSingleParagraphWithFlowRow( + paragraph = paragraph, + words = paragraph.words.toImmutableList(), + spaceWidth = spaceWidth, + context = context, + renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + ) } } }