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..2f355ecf0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -0,0 +1,225 @@ +/** + * 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.Spacer +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.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 + +private const val ASPECT_RATIO = 4f / 3f +private val IMAGE_SPACING: Dp = Size5dp + +@Composable +private fun GalleryImage( + image: MediaUrlImage, + allImages: ImmutableList, + modifier: 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, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + roundedCorner: Boolean = true, +) { + if (images.isEmpty()) return + + Column(modifier = modifier.padding(vertical = Size10dp)) { + 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, +) { + Row( + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + images.take(2).forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +private fun ThreeImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, +) { + Row( + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + GalleryImage( + image = images[0], + allImages = images, + modifier = Modifier.weight(2f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + + Column( + modifier = Modifier.weight(1f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + images.drop(1).forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } +} + +@Composable +private fun FourImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, +) { + Column( + modifier = Modifier.aspectRatio(ASPECT_RATIO), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + images.chunked(2).forEach { rowImages -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + rowImages.forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +private fun ManyImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, +) { + val columns = + when { + images.size <= 9 -> 3 + else -> 4 + } + + 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)) + } + } + } + } +} 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 720f01c583..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 @@ -107,6 +107,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 +145,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 +167,6 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -194,7 +193,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 +207,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() + RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -223,7 +222,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 +263,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 +283,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 +298,51 @@ 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 context = + RenderContext( + state = state, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + val paragraphParser = remember { ParagraphParser() } + + Column { + 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) }, + ) + }, + renderImageGallery = { words, ctx -> RenderWordsWithImageGallery(words, ctx) }, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( @@ -391,7 +427,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,33 +435,49 @@ private fun RenderWordWithoutPreview( } } +@Composable +private fun RenderWordsWithImageGallery( + words: ImmutableList, + context: RenderContext, +) { + val paragraphParser = remember { ParagraphParser() } + + paragraphParser.ProcessWordsWithImageGrouping( + words = words, + context = context, + renderSingleWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + renderGallery = { imageContents, accountViewModel -> + ImageGallery( + images = imageContents, + accountViewModel = accountViewModel, + roundedCorner = true, + ) + }, + ) +} + @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, accountViewModel, 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) } } @@ -584,39 +636,36 @@ 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) { localSecretContent.paragraphs[0].words.forEach { word -> RenderWordWithPreview( word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } } 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, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - } + paragraphParser.RenderSingleParagraphWithFlowRow( + paragraph = paragraph, + words = paragraph.words.toImmutableList(), + spaceWidth = spaceWidth, + context = context, + renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + ) } } } @@ -625,7 +674,6 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, - accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary @@ -693,8 +741,8 @@ fun TagLink( } else { Row { DisplayUserFromTag(it, accountViewModel, nav) - word.extras?.let { - Text(text = it) + word.extras?.let { it2 -> + Text(text = it2) } } } @@ -708,7 +756,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) {