From 953458214126d6d7610147da2d998fab0ac1f521 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:25:05 +0200 Subject: [PATCH 01/16] Collect batches of images Send them to new image gallery --- .../amethyst/model/HashtagIcon.kt | 4 +- .../amethyst/ui/components/ImageGallery.kt | 237 ++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 255 ++++++++++++++---- 3 files changed, 446 insertions(+), 50 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt 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 567b61ac3e..b000d16c02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -50,21 +50,19 @@ 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.navigation.navs.EmptyNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @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, ) { word, state -> when (word) { - is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav) is RegularTextSegment -> Text(word.segmentText) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt new file mode 100644 index 0000000000..173a97ba41 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -0,0 +1,237 @@ +/** + * 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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun ImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + roundedCorner: Boolean = true, +) { + when { + images.isEmpty() -> { + // No images to display + } + images.size == 1 -> { + // Single image - display full width + Box(modifier = modifier.fillMaxWidth()) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } + images.size == 2 -> { + // Two images - side by side in 4:3 ratio + TwoImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + images.size == 3 -> { + // Three images - one large, two small + ThreeImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + images.size == 4 -> { + // Four images - 2x2 grid + FourImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + else -> { + // Many images - use staggered grid with 4:3 ratio + ManyImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + } +} + +@Composable +private fun TwoImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Row( + modifier = modifier.aspectRatio(4f / 3f), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { index -> + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[index], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } +} + +@Composable +private fun ThreeImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Row( + modifier = modifier.aspectRatio(4f / 3f), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + // Large image on the left + Box(modifier = Modifier.weight(2f).fillMaxSize()) { + ZoomableContentView( + content = images[0], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + + // Two smaller images on the right + Column( + modifier = Modifier.weight(1f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { index -> + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[index + 1], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +private fun FourImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Column( + modifier = modifier.aspectRatio(4f / 3f), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { rowIndex -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { colIndex -> + val imageIndex = rowIndex * 2 + colIndex + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[imageIndex], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } + } +} + +@Composable +private fun ManyImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Surface( + modifier = modifier.aspectRatio(4f / 3f), + color = MaterialTheme.colorScheme.surface, + ) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(100.dp), + verticalItemSpacing = Size5dp, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.padding(Size5dp), + ) { + items(images) { image -> + Box(modifier = Modifier.aspectRatio(1f)) { + ZoomableContentView( + content = image, + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} 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 720f01c583..0266b474fa 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,6 +73,8 @@ 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 @@ -107,6 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -143,8 +147,6 @@ fun RichTextViewer( @Preview @Composable fun RenderStrangeNamePreview() { - val nav = EmptyNav - Column(modifier = Modifier.padding(10.dp)) { RenderRegular( "If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here", @@ -167,7 +169,6 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -194,7 +195,7 @@ fun RenderRegularPreview() { ) } - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -208,7 +209,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() + RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -223,7 +224,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -264,7 +265,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -284,18 +285,10 @@ private fun RenderRegular( accountViewModel: AccountViewModel, nav: INav, ) { - RenderRegular(content, tags, callbackUri) { word, state -> - if (canPreview) { - RenderWordWithPreview( - word, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } else { + if (canPreview) { + RenderRegularWithGallery(content, tags, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + } else { + RenderRegular(content, tags, callbackUri) { word, state -> RenderWordWithoutPreview( word, state, @@ -307,6 +300,126 @@ private fun RenderRegular( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderRegularWithGallery( + content: String, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } + + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + + Column { + // Process paragraphs and group consecutive image-only paragraphs + var i = 0 + while (i < state.paragraphs.size) { + val paragraph = state.paragraphs[i] + + // Check if this paragraph contains only images + val isImageOnlyParagraph = + paragraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + + if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { + // Collect consecutive image-only paragraphs + val imageParagraphs = mutableListOf() + var j = i + while (j < state.paragraphs.size) { + val currentParagraph = state.paragraphs[j] + val isCurrentImageOnly = + currentParagraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + if (isCurrentImageOnly && currentParagraph.words.isNotEmpty()) { + imageParagraphs.add(currentParagraph) + j++ + } else { + break + } + } + + // Combine all image words from consecutive paragraphs + val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() + + if (allImageWords.size > 1) { + // Multiple images - render as gallery + RenderWordsWithImageGallery( + allImageWords, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } else { + // Single image - render normally + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + } + + i = j // Skip processed paragraphs + } else { + // Non-image paragraph - render normally + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + i++ + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( @@ -391,7 +504,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, 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) @@ -399,6 +512,59 @@ private fun RenderWordWithoutPreview( } } +@Composable +private fun RenderWordsWithImageGallery( + words: ImmutableList, + state: RichTextViewerState, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + var i = 0 + while (i < words.size) { + val word = words[i] + + if (word is ImageSegment || word is Base64Segment) { + // Collect consecutive images + val imageSegments = mutableListOf() + var j = i + while (j < words.size && (words[j] is ImageSegment || words[j] is Base64Segment)) { + imageSegments.add(words[j]) + j++ + } + + if (imageSegments.size > 1) { + // Multiple images - render as gallery + val imageContents = + imageSegments + .mapNotNull { segment -> + val imageUrl = segment.segmentText + state.imagesForPager[imageUrl] as? MediaUrlImage + }.toImmutableList() + + if (imageContents.isNotEmpty()) { + ImageGallery( + images = imageContents, + accountViewModel = accountViewModel, + roundedCorner = true, + ) + } + } else { + // Single image - render normally + RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + } + + i = j // Skip processed images + } else { + // Non-image word - render normally + RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + i++ + } + } +} + @Composable private fun RenderWordWithPreview( word: Segment, @@ -420,7 +586,7 @@ private fun RenderWordWithPreview( 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, 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) @@ -585,17 +751,15 @@ fun CoreSecretMessage( nav: INav, ) { if (localSecretContent.paragraphs.size == 1) { - localSecretContent.paragraphs[0].words.forEach { word -> - RenderWordWithPreview( - word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } + RenderWordsWithImageGallery( + localSecretContent.paragraphs[0].words.toImmutableList(), + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) @@ -605,17 +769,15 @@ fun CoreSecretMessage( modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), horizontalArrangement = Arrangement.spacedBy(spaceWidth), ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } } } @@ -625,7 +787,6 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, - accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary @@ -693,8 +854,8 @@ fun TagLink( } else { Row { DisplayUserFromTag(it, accountViewModel, nav) - word.extras?.let { - Text(text = it) + word.extras?.let { it2 -> + Text(text = it2) } } } @@ -708,7 +869,7 @@ fun LoadNote( content: @Composable (Note?) -> Unit, ) { var note by - remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } + remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } if (note == null) { LaunchedEffect(key1 = baseNoteHex) { From 062182a7ec8f343a96597e4daa8368718caa33c3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:50:21 +0200 Subject: [PATCH 02/16] fix for when images are separated by empty lines refactor to reduce duplication: Extract RenderParagraphWithFlowRow Extract collectConsecutiveImageParagraphs --- .../amethyst/ui/components/RichTextViewer.kt | 166 +++++++++++------- 1 file changed, 101 insertions(+), 65 deletions(-) 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 0266b474fa..fb7442f25a 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 @@ -329,27 +329,13 @@ fun RenderRegularWithGallery( if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { // Collect consecutive image-only paragraphs - val imageParagraphs = mutableListOf() - var j = i - while (j < state.paragraphs.size) { - val currentParagraph = state.paragraphs[j] - val isCurrentImageOnly = - currentParagraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } - if (isCurrentImageOnly && currentParagraph.words.isNotEmpty()) { - imageParagraphs.add(currentParagraph) - j++ - } else { - break - } - } + val (imageParagraphs, totalProcessedCount) = collectConsecutiveImageParagraphs(state.paragraphs, i) // Combine all image words from consecutive paragraphs val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() if (allImageWords.size > 1) { - // Multiple images - render as gallery + // Multiple images - render as gallery (no FlowRow wrapper needed) RenderWordsWithImageGallery( allImageWords, state, @@ -361,65 +347,115 @@ fun RenderRegularWithGallery( ) } else { // Single image - render normally - CompositionLocalProvider( - LocalLayoutDirection provides - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - }, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - } + RenderParagraphWithFlowRow( + paragraph, + paragraph.words.toImmutableList(), + spaceWidth, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } - i = j // Skip processed paragraphs + i += totalProcessedCount // Skip processed paragraphs (including empty ones) } else { // Non-image paragraph - render normally - CompositionLocalProvider( - LocalLayoutDirection provides - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr - }, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - } + RenderParagraphWithFlowRow( + paragraph, + paragraph.words.toImmutableList(), + spaceWidth, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) i++ } } } } +@Composable +private fun RenderParagraphWithFlowRow( + paragraph: ParagraphState, + words: ImmutableList, + spaceWidth: Dp, + state: RichTextViewerState, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + words, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } +} + +private fun collectConsecutiveImageParagraphs( + paragraphs: ImmutableList, + startIndex: Int, +): Pair, Int> { + val imageParagraphs = mutableListOf() + var j = startIndex + while (j < paragraphs.size) { + val currentParagraph = paragraphs[j] + val isEmpty = + currentParagraph.words.isEmpty() || + ( + currentParagraph.words.size == 1 && + currentParagraph.words.first() is RegularTextSegment && + currentParagraph.words + .first() + .segmentText + .isBlank() + ) + + val isCurrentImageOnly = + currentParagraph.words.isNotEmpty() && + currentParagraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + + if (isCurrentImageOnly) { + imageParagraphs.add(currentParagraph) + j++ + } else if (isEmpty) { + // Skip empty paragraphs but continue looking for consecutive images + j++ + } else { + // Hit a non-empty, non-image paragraph - stop collecting + break + } + } + return Pair(imageParagraphs, j - startIndex) // Return paragraphs and total processed count +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( From 1f6d7d3fd2951a614006a8d3d9223d31c31ea74d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:58:15 +0200 Subject: [PATCH 03/16] Simplify RenderRegularWithGallery Simplify collectConsecutiveImageParagraphs: just return the end index Extracting common signature parameters into a RenderContext data class Extracting GalleryImage helper function --- .../amethyst/ui/components/ImageGallery.kt | 122 +++++----- .../amethyst/ui/components/RichTextViewer.kt | 210 +++++++++--------- 2 files changed, 167 insertions(+), 165 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 173a97ba41..caf820a260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -42,6 +42,26 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList +@Composable +private fun GalleryImage( + image: MediaUrlImage, + allImages: ImmutableList, + modifier: Modifier, + roundedCorner: Boolean, + contentScale: ContentScale, + accountViewModel: AccountViewModel, +) { + Box(modifier = modifier) { + ZoomableContentView( + content = image, + images = allImages, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } +} + @Composable fun ImageGallery( images: ImmutableList, @@ -55,15 +75,14 @@ fun ImageGallery( } images.size == 1 -> { // Single image - display full width - Box(modifier = modifier.fillMaxWidth()) { - ZoomableContentView( - content = images.first(), - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = images.first(), + allImages = images, + modifier = modifier.fillMaxWidth(), + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) } images.size == 2 -> { // Two images - side by side in 4:3 ratio @@ -116,15 +135,14 @@ private fun TwoImageGallery( horizontalArrangement = Arrangement.spacedBy(Size5dp), ) { repeat(2) { index -> - Box(modifier = Modifier.weight(1f).fillMaxSize()) { - ZoomableContentView( - content = images[index], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = images[index], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -141,15 +159,14 @@ private fun ThreeImageGallery( horizontalArrangement = Arrangement.spacedBy(Size5dp), ) { // Large image on the left - Box(modifier = Modifier.weight(2f).fillMaxSize()) { - ZoomableContentView( - content = images[0], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = images[0], + allImages = images, + modifier = Modifier.weight(2f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) // Two smaller images on the right Column( @@ -157,15 +174,14 @@ private fun ThreeImageGallery( verticalArrangement = Arrangement.spacedBy(Size5dp), ) { repeat(2) { index -> - Box(modifier = Modifier.weight(1f).fillMaxSize()) { - ZoomableContentView( - content = images[index + 1], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = images[index + 1], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -189,15 +205,14 @@ private fun FourImageGallery( ) { repeat(2) { colIndex -> val imageIndex = rowIndex * 2 + colIndex - Box(modifier = Modifier.weight(1f).fillMaxSize()) { - ZoomableContentView( - content = images[imageIndex], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -222,15 +237,14 @@ private fun ManyImageGallery( modifier = Modifier.padding(Size5dp), ) { items(images) { image -> - Box(modifier = Modifier.aspectRatio(1f)) { - ZoomableContentView( - content = image, - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } 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 fb7442f25a..acb71545c4 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 @@ -114,6 +114,15 @@ 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("# ") || @@ -313,84 +322,78 @@ fun RenderRegularWithGallery( ) { val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } + val context = + RenderContext( + state = state, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) Column { - // Process paragraphs and group consecutive image-only paragraphs + // Process each paragraph uniformly var i = 0 while (i < state.paragraphs.size) { - val paragraph = state.paragraphs[i] - - // Check if this paragraph contains only images - val isImageOnlyParagraph = - paragraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } - - if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { - // Collect consecutive image-only paragraphs - val (imageParagraphs, totalProcessedCount) = collectConsecutiveImageParagraphs(state.paragraphs, i) - - // Combine all image words from consecutive paragraphs - val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() - - if (allImageWords.size > 1) { - // Multiple images - render as gallery (no FlowRow wrapper needed) - RenderWordsWithImageGallery( - allImageWords, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } else { - // Single image - render normally - RenderParagraphWithFlowRow( - paragraph, - paragraph.words.toImmutableList(), - spaceWidth, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - - i += totalProcessedCount // Skip processed paragraphs (including empty ones) - } else { - // Non-image paragraph - render normally - RenderParagraphWithFlowRow( - paragraph, - paragraph.words.toImmutableList(), - spaceWidth, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + i = + renderParagraphWithFlowRow( + paragraphs = state.paragraphs, + paragraphIndex = i, + spaceWidth = spaceWidth, + context = context, ) - i++ - } } } } @Composable -private fun RenderParagraphWithFlowRow( +private fun renderParagraphWithFlowRow( + paragraphs: ImmutableList, + paragraphIndex: Int, + spaceWidth: Dp, + context: RenderContext, +): Int { + val paragraph = paragraphs[paragraphIndex] + + // Check if this paragraph contains only images + val isImageOnlyParagraph = + paragraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + + if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { + // 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 { + // Non-image paragraph - render normally with FlowRow + RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 // Return next index to process + } +} + +@Composable +private fun RenderSingleParagraphWithFlowRow( paragraph: ParagraphState, words: ImmutableList, spaceWidth: Dp, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String?, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { CompositionLocalProvider( LocalLayoutDirection provides @@ -406,12 +409,7 @@ private fun RenderParagraphWithFlowRow( ) { RenderWordsWithImageGallery( words, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } } @@ -453,7 +451,7 @@ private fun collectConsecutiveImageParagraphs( break } } - return Pair(imageParagraphs, j - startIndex) // Return paragraphs and total processed count + return Pair(imageParagraphs, j) // Return collected paragraphs and next index to process } @OptIn(ExperimentalLayoutApi::class) @@ -551,12 +549,7 @@ private fun RenderWordWithoutPreview( @Composable private fun RenderWordsWithImageGallery( words: ImmutableList, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { var i = 0 while (i < words.size) { @@ -577,25 +570,25 @@ private fun RenderWordsWithImageGallery( imageSegments .mapNotNull { segment -> val imageUrl = segment.segmentText - state.imagesForPager[imageUrl] as? MediaUrlImage + context.state.imagesForPager[imageUrl] as? MediaUrlImage }.toImmutableList() if (imageContents.isNotEmpty()) { ImageGallery( images = imageContents, - accountViewModel = accountViewModel, + accountViewModel = context.accountViewModel, roundedCorner = true, ) } } else { // Single image - render normally - RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + RenderWordWithPreview(word, context) } i = j // Skip processed images } else { // Non-image word - render normally - RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + RenderWordWithPreview(word, context) i++ } } @@ -604,30 +597,25 @@ private fun RenderWordsWithImageGallery( @Composable private fun RenderWordWithPreview( word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { when (word) { - is ImageSegment -> 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 ImageSegment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, context.callbackUri, context.accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, context.state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, context.accountViewModel) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText, context.accountViewModel) + is CashuSegment -> CashuPreview(word.segmentText, context.accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) - is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) + is SecretEmoji -> DisplaySecretEmoji(word, context.state, context.callbackUri, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.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 BechSegment -> BechLink(word.segmentText, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) + is HashTagSegment -> HashTag(word, context.nav) + is HashIndexUserSegment -> TagLink(word, context.accountViewModel, context.nav) + is HashIndexEventSegment -> TagLink(word, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) - is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is Base64Segment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel) } } @@ -786,15 +774,20 @@ fun CoreSecretMessage( accountViewModel: AccountViewModel, nav: INav, ) { + val context = + RenderContext( + state = localSecretContent, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + if (localSecretContent.paragraphs.size == 1) { RenderWordsWithImageGallery( localSecretContent.paragraphs[0].words.toImmutableList(), - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) @@ -807,12 +800,7 @@ fun CoreSecretMessage( ) { RenderWordsWithImageGallery( paragraph.words.toImmutableList(), - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } } From f092326dcdd35a8e61b74ea9a6c101f9677469d7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Sep 2025 20:55:25 +0200 Subject: [PATCH 04/16] Fixing scrollable gallery issue --- .../amethyst/ui/components/ImageGallery.kt | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index caf820a260..2f079d4d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -27,16 +27,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size5dp @@ -226,25 +219,41 @@ private fun ManyImageGallery( roundedCorner: Boolean, modifier: Modifier, ) { - Surface( + // Calculate optimal grid layout for many images + val columns = + when { + images.size <= 6 -> 3 // 3 columns for 5-6 images + images.size <= 9 -> 3 // 3 columns for 7-9 images + else -> 4 // 4 columns for 10+ images + } + + val rows = (images.size + columns - 1) / columns // Ceiling division + + Column( modifier = modifier.aspectRatio(4f / 3f), - color = MaterialTheme.colorScheme.surface, + verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(100.dp), - verticalItemSpacing = Size5dp, - horizontalArrangement = Arrangement.spacedBy(Size5dp), - modifier = Modifier.padding(Size5dp), - ) { - items(images) { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + repeat(rows) { rowIndex -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(columns) { colIndex -> + val imageIndex = rowIndex * columns + colIndex + if (imageIndex < images.size) { + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } else { + // Empty space for incomplete rows + Box(modifier = Modifier.weight(1f)) + } + } } } } From 8cb9d1356767700dfbb6518868ce49c16a9f1b1b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:01:12 +0200 Subject: [PATCH 05/16] Use constrained LazyVerticalGrid with calculated height for more than 20 images for performance reasons added vertical spacing for some padding Fixing scrollable gallery issue --- .../amethyst/ui/components/ImageGallery.kt | 186 +++++++++++------- 1 file changed, 112 insertions(+), 74 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 2f079d4d54..26868953fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -27,11 +27,18 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList @@ -62,56 +69,59 @@ fun ImageGallery( modifier: Modifier = Modifier, roundedCorner: Boolean = true, ) { - when { - images.isEmpty() -> { - // No images to display - } - images.size == 1 -> { - // Single image - display full width - GalleryImage( - image = images.first(), - allImages = images, - modifier = modifier.fillMaxWidth(), - roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } - images.size == 2 -> { - // Two images - side by side in 4:3 ratio - TwoImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = modifier, - ) - } - images.size == 3 -> { - // Three images - one large, two small - ThreeImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = modifier, - ) - } - images.size == 4 -> { - // Four images - 2x2 grid - FourImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = modifier, - ) - } - else -> { - // Many images - use staggered grid with 4:3 ratio - ManyImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = modifier, - ) + // Add vertical padding around the entire gallery for better text separation + Column(modifier = modifier.padding(vertical = Size10dp)) { + when { + images.isEmpty() -> { + // No images to display + } + images.size == 1 -> { + // Single image - display full width + GalleryImage( + image = images.first(), + allImages = images, + modifier = Modifier.fillMaxWidth(), + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + images.size == 2 -> { + // Two images - side by side in 4:3 ratio + TwoImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = Modifier, + ) + } + images.size == 3 -> { + // Three images - one large, two small + ThreeImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = Modifier, + ) + } + images.size == 4 -> { + // Four images - 2x2 grid + FourImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = Modifier, + ) + } + else -> { + // Many images - use staggered grid with 4:3 ratio + ManyImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = Modifier, + ) + } } } } @@ -227,34 +237,62 @@ private fun ManyImageGallery( else -> 4 // 4 columns for 10+ images } - val rows = (images.size + columns - 1) / columns // Ceiling division + if (images.size <= 20) { + // For smaller sets, use non-lazy Column/Row approach (simpler, no constraint issues) + val rows = (images.size + columns - 1) / columns // Ceiling division - Column( - modifier = modifier.aspectRatio(4f / 3f), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(rows) { rowIndex -> - Row( - modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(columns) { colIndex -> - val imageIndex = rowIndex * columns + colIndex - if (imageIndex < images.size) { - GalleryImage( - image = images[imageIndex], - allImages = images, - modifier = Modifier.weight(1f).fillMaxSize(), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } else { - // Empty space for incomplete rows - Box(modifier = Modifier.weight(1f)) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(rows) { rowIndex -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(columns) { colIndex -> + val imageIndex = rowIndex * columns + colIndex + if (imageIndex < images.size) { + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } else { + // Empty space for incomplete rows + Box(modifier = Modifier.weight(1f)) + } } } } } + } else { + // For larger sets, use LazyVerticalGrid with explicit height constraint + val rows = (images.size + columns - 1) / columns + // Calculate height: (image height + spacing) * rows - last spacing + // Assume square images with 5dp spacing + val gridHeight = (100 * rows + 5 * (rows - 1)).dp + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = modifier.height(gridHeight), // Explicit height constraint + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + userScrollEnabled = false, + ) { + items(images) { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } } } From 4dace5aaf81b95ff55fbde121d9a001b8044d14b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:03:06 +0200 Subject: [PATCH 06/16] refactor using takeWhile Fix edge case with white space before images --- .../amethyst/ui/components/RichTextViewer.kt | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) 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 acb71545c4..ddc151c767 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 @@ -358,11 +358,8 @@ private fun renderParagraphWithFlowRow( ): Int { val paragraph = paragraphs[paragraphIndex] - // Check if this paragraph contains only images - val isImageOnlyParagraph = - paragraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } + // Check if this paragraph contains only images (ignoring whitespace) + val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { // Collect consecutive image-only paragraphs for gallery @@ -415,6 +412,22 @@ private fun RenderSingleParagraphWithFlowRow( } } +private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { + // A paragraph is "image only" if all non-whitespace words are images + val nonWhitespaceWords = + paragraph.words.filter { word -> + when (word) { + is RegularTextSegment -> word.segmentText.isNotBlank() + else -> true // All other word types (images, links, etc.) are considered non-whitespace + } + } + + return nonWhitespaceWords.isNotEmpty() && + nonWhitespaceWords.all { word -> + word is ImageSegment || word is Base64Segment + } +} + private fun collectConsecutiveImageParagraphs( paragraphs: ImmutableList, startIndex: Int, @@ -435,10 +448,7 @@ private fun collectConsecutiveImageParagraphs( ) val isCurrentImageOnly = - currentParagraph.words.isNotEmpty() && - currentParagraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } + currentParagraph.words.isNotEmpty() && isImageOnlyParagraph(currentParagraph) if (isCurrentImageOnly) { imageParagraphs.add(currentParagraph) @@ -556,13 +566,15 @@ private fun RenderWordsWithImageGallery( val word = words[i] if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive images - val imageSegments = mutableListOf() - var j = i - while (j < words.size && (words[j] is ImageSegment || words[j] is Base64Segment)) { - imageSegments.add(words[j]) - j++ - } + // Collect consecutive images (skipping whitespace) using takeWhile + fun isImageOrWhitespace(segment: Segment): Boolean = + segment is ImageSegment || + segment is Base64Segment || + (segment is RegularTextSegment && segment.segmentText.isBlank()) + + val consecutiveSegments = words.drop(i).takeWhile { isImageOrWhitespace(it) } + val imageSegments = consecutiveSegments.filter { it is ImageSegment || it is Base64Segment } + val j = i + consecutiveSegments.size if (imageSegments.size > 1) { // Multiple images - render as gallery From 8f1027b55dc9e2c3fce72d180754e9c63ae981f3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:52:36 +0200 Subject: [PATCH 07/16] refactor Removed duplicate GalleryImage boilerplate. Use .chunked() Pull out constants (ASPECT_RATIO, IMAGE_SPACING). --- .../amethyst/ui/components/ImageGallery.kt | 214 ++++++++---------- 1 file changed, 90 insertions(+), 124 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 26868953fb..d7bbaf7f62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -22,31 +22,37 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList +private const val ASPECT_RATIO = 4f / 3f +private val IMAGE_SPACING: Dp = Size5dp + @Composable private fun GalleryImage( image: MediaUrlImage, allImages: ImmutableList, - modifier: Modifier, + modifier: Modifier = Modifier, roundedCorner: Boolean, contentScale: ContentScale, accountViewModel: AccountViewModel, @@ -69,77 +75,48 @@ fun ImageGallery( modifier: Modifier = Modifier, roundedCorner: Boolean = true, ) { - // Add vertical padding around the entire gallery for better text separation + if (images.isEmpty()) return + Column(modifier = modifier.padding(vertical = Size10dp)) { - when { - images.isEmpty() -> { - // No images to display - } - images.size == 1 -> { - // Single image - display full width - GalleryImage( - image = images.first(), - allImages = images, - modifier = Modifier.fillMaxWidth(), - roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } - images.size == 2 -> { - // Two images - side by side in 4:3 ratio - TwoImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = Modifier, - ) - } - images.size == 3 -> { - // Three images - one large, two small - ThreeImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = Modifier, - ) - } - images.size == 4 -> { - // Four images - 2x2 grid - FourImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = Modifier, - ) - } - else -> { - // Many images - use staggered grid with 4:3 ratio - ManyImageGallery( - images = images, - accountViewModel = accountViewModel, - roundedCorner = roundedCorner, - modifier = Modifier, - ) - } + when (images.size) { + 1 -> SingleImageGallery(images, accountViewModel, roundedCorner) + 2 -> TwoImageGallery(images, accountViewModel, roundedCorner) + 3 -> ThreeImageGallery(images, accountViewModel, roundedCorner) + 4 -> FourImageGallery(images, accountViewModel, roundedCorner) + else -> ManyImageGallery(images, accountViewModel, roundedCorner) } } } +@Composable +private fun SingleImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, +) { + GalleryImage( + image = images.first(), + allImages = images, + modifier = Modifier.fillMaxWidth(), + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) +} + @Composable private fun TwoImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Row( - modifier = modifier.aspectRatio(4f / 3f), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { index -> + images.take(2).forEach { image -> GalleryImage( - image = images[index], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -155,13 +132,11 @@ private fun ThreeImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Row( - modifier = modifier.aspectRatio(4f / 3f), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - // Large image on the left GalleryImage( image = images[0], allImages = images, @@ -171,14 +146,13 @@ private fun ThreeImageGallery( accountViewModel = accountViewModel, ) - // Two smaller images on the right Column( modifier = Modifier.weight(1f).fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { index -> + images.drop(1).forEach { image -> GalleryImage( - image = images[index + 1], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -195,21 +169,19 @@ private fun FourImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Column( - modifier = modifier.aspectRatio(4f / 3f), - verticalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { rowIndex -> + images.chunked(2).forEach { rowImages -> Row( modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { colIndex -> - val imageIndex = rowIndex * 2 + colIndex + rowImages.forEach { image -> GalleryImage( - image = images[imageIndex], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -227,71 +199,65 @@ private fun ManyImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { - // Calculate optimal grid layout for many images val columns = when { - images.size <= 6 -> 3 // 3 columns for 5-6 images - images.size <= 9 -> 3 // 3 columns for 7-9 images - else -> 4 // 4 columns for 10+ images + images.size <= 9 -> 3 + else -> 4 } if (images.size <= 20) { - // For smaller sets, use non-lazy Column/Row approach (simpler, no constraint issues) - val rows = (images.size + columns - 1) / columns // Ceiling division - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(rows) { rowIndex -> + // Non-lazy for small sets + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + images.chunked(columns).forEach { rowImages -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Size5dp), ) { - repeat(columns) { colIndex -> - val imageIndex = rowIndex * columns + colIndex - if (imageIndex < images.size) { - GalleryImage( - image = images[imageIndex], - allImages = images, - modifier = Modifier.weight(1f).aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } else { - // Empty space for incomplete rows - Box(modifier = Modifier.weight(1f)) - } + rowImages.forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + repeat(columns - rowImages.size) { + Spacer(Modifier.weight(1f)) } } } } } else { - // For larger sets, use LazyVerticalGrid with explicit height constraint - val rows = (images.size + columns - 1) / columns - // Calculate height: (image height + spacing) * rows - last spacing - // Assume square images with 5dp spacing - val gridHeight = (100 * rows + 5 * (rows - 1)).dp + // Lazy for large sets — expands fully, no independent scroll + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val totalSpacing = Size5dp * (columns - 1) + val imageSize = (maxWidth - totalSpacing) / columns + val rows = (images.size + columns - 1) / columns + val gridHeight = (imageSize * rows) + (Size5dp * (rows - 1)) - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = modifier.height(gridHeight), // Explicit height constraint - verticalArrangement = Arrangement.spacedBy(Size5dp), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - userScrollEnabled = false, - ) { - items(images) { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = + Modifier + .fillMaxWidth() + .height(gridHeight), + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + userScrollEnabled = false, + ) { + items(images) { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.size(imageSize), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } From ee9e102eb22d2b054f16ea05b867e162c60601fa Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 12 Sep 2025 10:54:17 +0200 Subject: [PATCH 08/16] removed lazy component from 20+ images case to avoid nesting lazy components --- .../amethyst/ui/components/ImageGallery.kt | 57 +++---------------- 1 file changed, 9 insertions(+), 48 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index d7bbaf7f62..2f355ecf0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -22,19 +22,13 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -206,58 +200,25 @@ private fun ManyImageGallery( else -> 4 } - if (images.size <= 20) { - // Non-lazy for small sets - Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { - images.chunked(columns).forEach { rowImages -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - ) { - rowImages.forEach { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.weight(1f).aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } - repeat(columns - rowImages.size) { - Spacer(Modifier.weight(1f)) - } - } - } - } - } else { - // Lazy for large sets — expands fully, no independent scroll - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val totalSpacing = Size5dp * (columns - 1) - val imageSize = (maxWidth - totalSpacing) / columns - val rows = (images.size + columns - 1) / columns - val gridHeight = (imageSize * rows) + (Size5dp * (rows - 1)) - - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = - Modifier - .fillMaxWidth() - .height(gridHeight), - verticalArrangement = Arrangement.spacedBy(Size5dp), + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + images.chunked(columns).forEach { rowImages -> + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Size5dp), - userScrollEnabled = false, ) { - items(images) { image -> + rowImages.forEach { image -> GalleryImage( image = image, allImages = images, - modifier = Modifier.size(imageSize), + modifier = Modifier.weight(1f).aspectRatio(1f), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) } + repeat(columns - rowImages.size) { + Spacer(Modifier.weight(1f)) + } } } } From 16e3cba651c52c3bf5eaf5bc25cb66bb9b335b67 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 12 Sep 2025 12:14:05 +0200 Subject: [PATCH 09/16] found a place where image gallery was used for single images --- .../amethyst/ui/components/RichTextViewer.kt | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) 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 ddc151c767..0512894529 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 @@ -404,10 +404,9 @@ private fun RenderSingleParagraphWithFlowRow( FlowRow( horizontalArrangement = Arrangement.spacedBy(spaceWidth), ) { - RenderWordsWithImageGallery( - words, - context, - ) + words.forEach { word -> + RenderWordWithPreview(word, context) + } } } } @@ -797,7 +796,7 @@ fun CoreSecretMessage( ) if (localSecretContent.paragraphs.size == 1) { - RenderWordsWithImageGallery( + RenderSecretParagraphOptimized( localSecretContent.paragraphs[0].words.toImmutableList(), context, ) @@ -806,14 +805,44 @@ fun CoreSecretMessage( Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - context, - ) + RenderSecretParagraphOptimized( + paragraph.words.toImmutableList(), + context, + spaceWidth, + paragraph.isRTL, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RenderSecretParagraphOptimized( + words: ImmutableList, + context: RenderContext, + spaceWidth: Dp? = null, + isRTL: Boolean = false, +) { + // Check if we need single-image optimization + val imageSegments = words.filter { it is ImageSegment || it is Base64Segment } + + if (imageSegments.size > 1) { + // Multiple images - use gallery logic + RenderWordsWithImageGallery(words, context) + } else { + // Single or no images - render directly with FlowRow for optimal performance + val actualSpaceWidth = spaceWidth ?: measureSpaceWidth(LocalTextStyle.current) + + CompositionLocalProvider( + LocalLayoutDirection provides if (isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(actualSpaceWidth), + ) { + words.forEach { word -> + RenderWordWithPreview(word, context) } } } From 8e39da347aa8d12eeb5ebc25b2a630077cef3f14 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 18:16:43 +0200 Subject: [PATCH 10/16] don't use image gallery for secret message --- .../amethyst/ui/components/RichTextViewer.kt | 58 +++++-------------- 1 file changed, 16 insertions(+), 42 deletions(-) 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 0512894529..e1bfa656b5 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 @@ -796,53 +796,27 @@ fun CoreSecretMessage( ) if (localSecretContent.paragraphs.size == 1) { - RenderSecretParagraphOptimized( - localSecretContent.paragraphs[0].words.toImmutableList(), - context, - ) + localSecretContent.paragraphs[0].words.forEach { word -> + RenderWordWithPreview( + word, + context, + ) + } } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - RenderSecretParagraphOptimized( - paragraph.words.toImmutableList(), - context, - spaceWidth, - paragraph.isRTL, - ) - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RenderSecretParagraphOptimized( - words: ImmutableList, - context: RenderContext, - spaceWidth: Dp? = null, - isRTL: Boolean = false, -) { - // Check if we need single-image optimization - val imageSegments = words.filter { it is ImageSegment || it is Base64Segment } - - if (imageSegments.size > 1) { - // Multiple images - use gallery logic - RenderWordsWithImageGallery(words, context) - } else { - // Single or no images - render directly with FlowRow for optimal performance - val actualSpaceWidth = spaceWidth ?: measureSpaceWidth(LocalTextStyle.current) - - CompositionLocalProvider( - LocalLayoutDirection provides if (isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(actualSpaceWidth), - ) { - words.forEach { word -> - RenderWordWithPreview(word, context) + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + context, + ) + } } } } From a21b6115c7e886fc33e54b44088a1c7eaebf776c Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 20:44:00 +0200 Subject: [PATCH 11/16] Single pass to avoid multiple intermediate lists O(n) traversal (no drop/takeWhile/filter allocations) Mutable buffer for collecting images --- .../amethyst/ui/components/RichTextViewer.kt | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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 e1bfa656b5..a8540377a6 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 @@ -561,22 +561,27 @@ private fun RenderWordsWithImageGallery( context: RenderContext, ) { var i = 0 - while (i < words.size) { + val n = words.size + + while (i < n) { val word = words[i] if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive images (skipping whitespace) using takeWhile - fun isImageOrWhitespace(segment: Segment): Boolean = - segment is ImageSegment || - segment is Base64Segment || - (segment is RegularTextSegment && segment.segmentText.isBlank()) + // Collect consecutive image/whitespace segments without extra list allocations + val imageSegments = mutableListOf() + var j = i - val consecutiveSegments = words.drop(i).takeWhile { isImageOrWhitespace(it) } - val imageSegments = consecutiveSegments.filter { it is ImageSegment || it is Base64Segment } - val j = i + consecutiveSegments.size + 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) { - // Multiple images - render as gallery val imageContents = imageSegments .mapNotNull { segment -> @@ -592,13 +597,11 @@ private fun RenderWordsWithImageGallery( ) } } else { - // Single image - render normally - RenderWordWithPreview(word, context) + RenderWordWithPreview(imageSegments.firstOrNull() ?: word, context) } - i = j // Skip processed images + i = j // jump past processed run } else { - // Non-image word - render normally RenderWordWithPreview(word, context) i++ } From bfc920ca042e2c0727889ee492fa8937a94ae4ad Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 21:09:27 +0200 Subject: [PATCH 12/16] Added logging of image gallery computation time --- .../vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 a8540377a6..617149b5dd 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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -560,6 +561,8 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { + val startTime = System.currentTimeMillis() + var i = 0 val n = words.size @@ -606,6 +609,8 @@ private fun RenderWordsWithImageGallery( i++ } } + + Log.d("RichTextViewer", "RenderWordsWithImageGallery took ${System.currentTimeMillis() - startTime}ms for ${words.size} segments") } @Composable From 8f0a829a4d479ab2904cb2a9f359f971ec333f18 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 23:32:17 +0200 Subject: [PATCH 13/16] Optimised collectConsecutiveImageParagraphs: continue statements for early loop continuation --- .../amethyst/ui/components/RichTextViewer.kt | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) 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 617149b5dd..b78ff0b435 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,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -434,34 +433,36 @@ private fun collectConsecutiveImageParagraphs( ): Pair, Int> { val imageParagraphs = mutableListOf() var j = startIndex + while (j < paragraphs.size) { val currentParagraph = paragraphs[j] - val isEmpty = - currentParagraph.words.isEmpty() || - ( - currentParagraph.words.size == 1 && - currentParagraph.words.first() is RegularTextSegment && - currentParagraph.words - .first() - .segmentText - .isBlank() - ) + val words = currentParagraph.words - val isCurrentImageOnly = - currentParagraph.words.isNotEmpty() && isImageOnlyParagraph(currentParagraph) + // Fast path for empty check + if (words.isEmpty()) { + j++ + continue + } - if (isCurrentImageOnly) { + // 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 + if (isImageOnlyParagraph(currentParagraph)) { imageParagraphs.add(currentParagraph) j++ - } else if (isEmpty) { - // Skip empty paragraphs but continue looking for consecutive images - j++ } else { - // Hit a non-empty, non-image paragraph - stop collecting break } } - return Pair(imageParagraphs, j) // Return collected paragraphs and next index to process + + return imageParagraphs to j } @OptIn(ExperimentalLayoutApi::class) @@ -561,8 +562,6 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { - val startTime = System.currentTimeMillis() - var i = 0 val n = words.size @@ -609,8 +608,6 @@ private fun RenderWordsWithImageGallery( i++ } } - - Log.d("RichTextViewer", "RenderWordsWithImageGallery took ${System.currentTimeMillis() - startTime}ms for ${words.size} segments") } @Composable From 7e80ed2af91554843f3c84b8a151b53f990e8e50 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 11:40:20 +0200 Subject: [PATCH 14/16] Fix for images are mixed with text in a single paragraph nevent1qqs04pmdf8guxpyhjuvpeg72ccwuzje7vhlm09szxl2d8x28cse5fzgpz9mhxue69uhkummnw3ezuamfdejj7q3qwl89d7yazg500lehg08p45dj2jzhhyqg2erj067458e3wd30djnsxpqqqqqqzmdjgxd --- .../amethyst/ui/components/RichTextViewer.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 b78ff0b435..a67be18288 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 @@ -360,6 +360,8 @@ private fun renderParagraphWithFlowRow( // Check if this paragraph contains only images (ignoring whitespace) val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) + // Check if this paragraph contains multiple images (mixed with text is ok) + val hasMultipleImages = hasMultipleImages(paragraph) if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { // Collect consecutive image-only paragraphs for gallery @@ -378,6 +380,13 @@ private fun renderParagraphWithFlowRow( } return endIndex // Return next index to process + } else if (hasMultipleImages && paragraph.words.isNotEmpty()) { + // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + context, + ) + return paragraphIndex + 1 // Return next index to process } else { // Non-image paragraph - render normally with FlowRow RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) @@ -427,6 +436,15 @@ private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { } } +private fun hasMultipleImages(paragraph: ParagraphState): Boolean { + // Count the number of image segments in the paragraph + val imageCount = + paragraph.words.count { word -> + word is ImageSegment || word is Base64Segment + } + return imageCount > 1 +} + private fun collectConsecutiveImageParagraphs( paragraphs: ImmutableList, startIndex: Int, From 74cc9535ac43b7f25d0bcb83a1e26b3675aa100b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 12:10:59 +0200 Subject: [PATCH 15/16] Added a data structure for ParagraphImageAnalysis Single pass analysis All image-related decisions are made based on the single analysis result --- .../amethyst/ui/components/RichTextViewer.kt | 81 ++++++++++--------- 1 file changed, 43 insertions(+), 38 deletions(-) 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 a67be18288..4130cc5a08 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 @@ -358,39 +358,36 @@ private fun renderParagraphWithFlowRow( ): Int { val paragraph = paragraphs[paragraphIndex] - // Check if this paragraph contains only images (ignoring whitespace) - val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) - // Check if this paragraph contains multiple images (mixed with text is ok) - val hasMultipleImages = hasMultipleImages(paragraph) + if (paragraph.words.isEmpty()) { + // Empty paragraph - render normally with FlowRow (will render nothing) + RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } - if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { + 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, - ) + 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 (hasMultipleImages && paragraph.words.isNotEmpty()) { + } else if (analysis.hasMultipleImages) { // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - context, - ) - return paragraphIndex + 1 // Return next index to process + RenderWordsWithImageGallery(paragraph.words.toImmutableList(), context) + return paragraphIndex + 1 } else { - // Non-image paragraph - render normally with FlowRow + // Regular paragraph (no images or single image) - render normally with FlowRow RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 // Return next index to process + return paragraphIndex + 1 } } @@ -420,29 +417,36 @@ private fun RenderSingleParagraphWithFlowRow( } } -private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { - // A paragraph is "image only" if all non-whitespace words are images - val nonWhitespaceWords = - paragraph.words.filter { word -> - when (word) { - is RegularTextSegment -> word.segmentText.isNotBlank() - else -> true // All other word types (images, links, etc.) are considered non-whitespace +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. } + } - return nonWhitespaceWords.isNotEmpty() && - nonWhitespaceWords.all { word -> - word is ImageSegment || word is Base64Segment - } -} + val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent + val hasMultipleImages = imageCount > 1 -private fun hasMultipleImages(paragraph: ParagraphState): Boolean { - // Count the number of image segments in the paragraph - val imageCount = - paragraph.words.count { word -> - word is ImageSegment || word is Base64Segment - } - return imageCount > 1 + return ParagraphImageAnalysis( + imageCount = imageCount, + isImageOnly = isImageOnly, + hasMultipleImages = hasMultipleImages, + ) } private fun collectConsecutiveImageParagraphs( @@ -471,8 +475,9 @@ private fun collectConsecutiveImageParagraphs( } } - // Check if it's an image-only paragraph - if (isImageOnlyParagraph(currentParagraph)) { + // Check if it's an image-only paragraph using unified analysis + val analysis = analyzeParagraphImages(currentParagraph) + if (analysis.isImageOnly) { imageParagraphs.add(currentParagraph) j++ } else { From 43f8053b304e89be65985338c5204fee92f02e2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 13:17:19 +0200 Subject: [PATCH 16/16] 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 0000000000..a943b37aff --- /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 4130cc5a08..8a6d84cac7 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) }, + ) } } }