From 3a73de682830b88d1eb60228ab8a1222d0fa4ecd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Oct 2025 17:11:34 -0400 Subject: [PATCH] Separates the new GaleryParser from Composables so that later we can cache it and parse it on the Default thread. --- .../amethyst/ParagraphParserTest.kt | 217 ----------- .../amethyst/model/HashtagIcon.kt | 11 +- .../amethyst/ui/components/ImageGallery.kt | 29 +- .../amethyst/ui/components/ParagraphParser.kt | 279 -------------- .../amethyst/ui/components/RichTextViewer.kt | 346 +++++++++--------- .../commons/richtext/GalleryParserTest.kt | 148 ++++++++ .../commons/richtext/RichTextParserTest.kt | 5 +- .../commons/richtext/GalleryParser.kt | 190 ++++++++++ .../commons/richtext/RichTextParser.kt | 30 +- .../richtext/RichTextParserSegments.kt | 8 +- 10 files changed, 556 insertions(+), 707 deletions(-) delete mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt create mode 100644 commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt deleted file mode 100644 index 8e33ba4f7..000000000 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt +++ /dev/null @@ -1,217 +0,0 @@ -/** - * 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 - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.richtext.ImageSegment -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.commons.richtext.Segment -import com.vitorpamplona.amethyst.ui.components.ParagraphParser -import com.vitorpamplona.amethyst.ui.components.RenderContext -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList -import io.mockk.mockk -import kotlinx.collections.immutable.toImmutableList -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ParagraphParserTest { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun testMixedImageAndVideoRenderedIndividually() { - // Test the bug: when mixed image + video, both should be rendered individually - val text = - "Renfield (2023)\n" + - "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + - "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4" - - val state = RichTextParser().parseText(text, EmptyTagList, null) - - // Extract the image segments from the parsed paragraphs - val imageSegments = mutableListOf() - state.paragraphs.forEach { paragraph -> - paragraph.words.forEach { word -> - if (word is ImageSegment) { - imageSegments.add(word) - } - } - } - - // Should have 2 image segments (image + video URLs) - assertEquals(2, imageSegments.size) - - // Track what gets rendered - val singleWordRenders = mutableListOf() - val galleryRenders = mutableListOf>() - - // Set up the test with mocked dependencies - val context = - RenderContext( - state = state, - backgroundColor = mutableStateOf(Color.White), - quotesLeft = 3, - callbackUri = null, - accountViewModel = mockk(relaxed = true), - nav = mockk(relaxed = true), - ) - - // Execute the actual ParagraphParser method - composeTestRule.setContent { - ParagraphParser().ProcessWordsWithImageGrouping( - words = imageSegments.toImmutableList(), - context = context, - renderSingleWord = { segment, _ -> - singleWordRenders.add(segment) - }, - renderGallery = { images, _ -> - galleryRenders.add(images) - }, - ) - } - - composeTestRule.waitForIdle() - - assertTrue( - "Mixed image/video should be rendered individually (2 renders), not as gallery (0 renders). " + - "Found: $singleWordRenders individual, $galleryRenders gallery", - singleWordRenders.size == 2 && galleryRenders.isEmpty(), - ) - } - - @Test - fun testMultipleImagesRenderedAsGallery() { - // Test that multiple images (no videos) are correctly grouped as gallery - val text = - "Gallery:\n" + - "https://example.com/image1.jpg\n" + - "https://example.com/image2.png" - - val state = RichTextParser().parseText(text, EmptyTagList, null) - - // Extract the image segments - val imageSegments = mutableListOf() - state.paragraphs.forEach { paragraph -> - paragraph.words.forEach { word -> - if (word is ImageSegment) { - imageSegments.add(word) - } - } - } - - assertEquals(2, imageSegments.size) - - // Track renders - val singleWordRenders = mutableListOf() - val galleryRenders = mutableListOf>() - - val context = - RenderContext( - state = state, - backgroundColor = mutableStateOf(Color.White), - quotesLeft = 3, - callbackUri = null, - accountViewModel = mockk(relaxed = true), - nav = mockk(relaxed = true), - ) - - composeTestRule.setContent { - ParagraphParser().ProcessWordsWithImageGrouping( - words = imageSegments.toImmutableList(), - context = context, - renderSingleWord = { segment, _ -> - singleWordRenders.add(segment) - }, - renderGallery = { images, _ -> - galleryRenders.add(images) - }, - ) - } - - composeTestRule.waitForIdle() - - // Should render as gallery (1 gallery with 2 images) - assertEquals("Should render 1 gallery", 1, galleryRenders.size) - assertEquals("Gallery should contain 2 images", 2, galleryRenders[0].size) - assertEquals("Should not render individually", 0, singleWordRenders.size) - } - - @Test - fun testSingleImageRenderedIndividually() { - // Test that a single image is rendered individually, not as gallery - val text = "Single image:\nhttps://example.com/image.jpg" - - val state = RichTextParser().parseText(text, EmptyTagList, null) - - val imageSegments = mutableListOf() - state.paragraphs.forEach { paragraph -> - paragraph.words.forEach { word -> - if (word is ImageSegment) { - imageSegments.add(word) - } - } - } - - assertEquals(1, imageSegments.size) - - val singleWordRenders = mutableListOf() - val galleryRenders = mutableListOf>() - - val context = - RenderContext( - state = state, - backgroundColor = mutableStateOf(Color.White), - quotesLeft = 3, - callbackUri = null, - accountViewModel = mockk(relaxed = true), - nav = mockk(relaxed = true), - ) - - composeTestRule.setContent { - ParagraphParser().ProcessWordsWithImageGrouping( - words = imageSegments.toImmutableList(), - context = context, - renderSingleWord = { segment, _ -> - singleWordRenders.add(segment) - }, - renderGallery = { images, _ -> - galleryRenders.add(images) - }, - ) - } - - composeTestRule.waitForIdle() - - // Should render individually (not as gallery) - assertEquals("Should render 1 individual image", 1, singleWordRenders.size) - assertEquals("Should not render as gallery", 0, galleryRenders.size) - } -} 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 468cedc6f..26411410e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.ui.components.HashTag import com.vitorpamplona.amethyst.ui.components.RenderRegular +import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList @@ -60,10 +61,12 @@ fun RenderHashTagIconsPreview() { 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, EmptyNav) - is RegularTextSegment -> Text(word.segmentText) + ) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + when (word) { + 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 index 2f355ecf0..3573002d3 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 @@ -33,11 +33,16 @@ 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.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState 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 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet private const val ASPECT_RATIO = 4f / 3f private val IMAGE_SPACING: Dp = Size5dp @@ -64,20 +69,28 @@ private fun GalleryImage( @Composable fun ImageGallery( - images: ImmutableList, + images: ImageGalleryParagraph, + state: RichTextViewerState, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, roundedCorner: Boolean = true, ) { - if (images.isEmpty()) return + if (images.words.isEmpty()) return + + val resolvedImages = + images.words + .mapNotNull { segment -> + val imageUrl = segment.segmentText + state.imagesForPager[imageUrl] as? MediaUrlImage + }.toImmutableList() 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) + when (resolvedImages.size) { + 1 -> SingleImageGallery(resolvedImages, accountViewModel, roundedCorner) + 2 -> TwoImageGallery(resolvedImages, accountViewModel, roundedCorner) + 3 -> ThreeImageGallery(resolvedImages, accountViewModel, roundedCorner) + 4 -> FourImageGallery(resolvedImages, accountViewModel, roundedCorner) + else -> ManyImageGallery(resolvedImages, accountViewModel, roundedCorner) } } } 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 deleted file mode 100644 index e26cefc06..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt +++ /dev/null @@ -1,279 +0,0 @@ -/** - * 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) { - renderSingleWord(imageSegments.firstOrNull() ?: word, context) - } else { - val resolvedImages = - imageSegments.mapNotNull { segment -> - val imageUrl = segment.segmentText - context.state.imagesForPager[imageUrl] as? MediaUrlImage - } - - // Render gallery only if all segments are images - if (resolvedImages.size == imageSegments.size) { - renderGallery( - resolvedImages.toImmutableList(), - context.accountViewModel, - ) - } else { - imageSegments.forEach { segment -> - renderSingleWord(segment, 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 0d2b19196..7557d4ebc 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 @@ -67,12 +67,15 @@ import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment +import com.vitorpamplona.amethyst.commons.richtext.GalleryParser import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment +import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph 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.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState @@ -90,6 +93,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -107,10 +111,10 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList import com.vitorpamplona.quartz.nip01Core.core.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 +import kotlin.text.Typography.paragraph fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -149,15 +153,18 @@ fun RenderStrangeNamePreview() { RenderRegular( "If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here", EmptyTagList, - ) { word, state -> - when (word) { - is BechSegment -> { - Text( - "FreeFrom Official \uD80C\uDD66", - modifier = Modifier.border(1.dp, Color.Red), - ) + ) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + when (word) { + is BechSegment -> { + Text( + "FreeFrom Official \uD80C\uDD66", + modifier = Modifier.border(1.dp, Color.Red), + ) + } + + is RegularTextSegment -> Text(word.segmentText) } - is RegularTextSegment -> Text(word.segmentText) } } } @@ -166,14 +173,51 @@ fun RenderStrangeNamePreview() { @Preview @Composable fun RenderRegularPreview() { - val nav = EmptyNav - Column(modifier = Modifier.padding(10.dp)) { RenderRegular( "nostr:npub1e0z776cpe0gllgktjk54fuzv8pdfxmq6smsmh8xd7t8s7n474n9smk0txy but i'm Monthly funding" + " 7 other humans vitor@vitorpamplona.com at the moment so spread #test a bit thin, but won't always be the case.", EmptyTagList, - ) { word, state -> + ) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + is BechSegment -> { + CreateClickableText( + word.segmentText.substring(0, 10), + "", + 1, + route = Route.EventRedirect(word.segmentText), + nav = EmptyNav, + ) + } + + is HashTagSegment -> HashTag(word, EmptyNav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } + } + } + } +} + +@Preview +@Composable +fun RenderRegularPreview2() { + RenderRegular( + "#Amethyst v0.84.1: ncryptsec support (NIP-49)", + EmptyTagList, + ) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> when (word) { // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) @@ -183,17 +227,8 @@ fun RenderRegularPreview() { // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> { - CreateClickableText( - word.segmentText.substring(0, 10), - "", - 1, - route = Route.EventRedirect(word.segmentText), - nav = nav, - ) - } - - is HashTagSegment -> HashTag(word, nav) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, EmptyNav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -203,34 +238,6 @@ fun RenderRegularPreview() { } } -@Preview -@Composable -fun RenderRegularPreview2() { - val nav = EmptyNav - - RenderRegular( - "#Amethyst v0.84.1: ncryptsec support (NIP-49)", - EmptyTagList, - ) { word, state -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> Text(word.segmentText) - } - } -} - @Preview @Composable fun RenderRegularPreview3() { @@ -244,7 +251,6 @@ fun RenderRegularPreview3() { arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), ), ) - val nav = EmptyNav val accountViewModel = mockAccountViewModel() RenderRegular( @@ -252,22 +258,24 @@ fun RenderRegularPreview3() { "#ioメシヨソイゲーム\n" + "https://misskey.io/play/9g3qza4jow", tags, - ) { word, state -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, null, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> Text(word.segmentText) + ) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, null, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + // is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + // is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, EmptyNav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } } } } @@ -284,62 +292,58 @@ private fun RenderRegular( nav: INav, ) { if (canPreview) { - RenderRegularWithGallery(content, tags, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> + if (paragraph is ImageGalleryParagraph) { + ImageGallery( + images = paragraph, + state = state, + accountViewModel = accountViewModel, + modifier = modifier, + roundedCorner = true, + ) + } else { + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + RenderWordWithPreview( + word, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + } } else { - RenderRegular(content, tags, callbackUri) { word, state -> - RenderWordWithoutPreview( - word, - state, - backgroundColor, - accountViewModel, - nav, - ) + RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + RenderWordWithoutPreview( + word, + state, + backgroundColor, + accountViewModel, + nav, + ) + } } } } -@OptIn(ExperimentalLayoutApi::class) @Composable -fun RenderRegularWithGallery( - content: String, - tags: ImmutableListOfLists, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, +fun RenderTextParagraph( + paragraph: ParagraphState, + spaceWidth: Dp, + modifier: Modifier, + renderWord: @Composable (word: Segment) -> Unit, ) { - 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) }, - ) + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + renderWord(word) + } } } @@ -349,7 +353,7 @@ fun RenderRegular( content: String, tags: ImmutableListOfLists, callbackUri: String? = null, - wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit, + renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit, ) { val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } @@ -376,14 +380,12 @@ fun RenderRegular( }, LocalTextStyle provides textStyle, ) { - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - wordRenderer(word, state) - } - } + renderParagraph( + paragraph, + state, + spaceWidth, + Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + ) } } } @@ -435,49 +437,33 @@ 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, - context: RenderContext, + state: RichTextViewerState, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String? = null, + accountViewModel: AccountViewModel, + nav: INav, ) { when (word) { - 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 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 EmailSegment -> ClickableEmail(word.segmentText) - is SecretEmoji -> DisplaySecretEmoji(word, context.state, context.callbackUri, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) + is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) - 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 BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) - is Base64Segment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel) + is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) } } @@ -636,36 +622,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, - context, + word = word, + state = localSecretContent, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, ) } } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) - val paragraphParser = remember { ParagraphParser() } Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - paragraphParser.RenderSingleParagraphWithFlowRow( - paragraph = paragraph, - words = paragraph.words.toImmutableList(), - spaceWidth = spaceWidth, - context = context, - renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, - ) + val modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start) + + RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> + RenderWordWithPreview( + word, + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } } } } diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt new file mode 100644 index 000000000..deffa135a --- /dev/null +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt @@ -0,0 +1,148 @@ +/** + * 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.commons.richtext + +import android.R.attr.text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList +import kotlinx.collections.immutable.toImmutableList +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GalleryParserTest { + @Test + fun testMixedImageAndVideoRenderedIndividually() { + // Test the bug: when mixed image + video, both should be rendered individually + val text = + "Renfield (2023)\n" + + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + + "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 2 image segments (image + video URLs) + Assert.assertEquals(2, state.paragraphs.size) + + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[0].words.size) + Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText) + } + + @Test + fun testMixedImageAndVideoRenderedIndividuallyDoubled() { + // Test the bug: when mixed image + video, both should be rendered individually + val text = + "Renfield (2023)\n" + + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + + "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4\n" + + "Renfield (2023)\n" + + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + + "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 2 image segments (image + video URLs) + Assert.assertEquals(4, state.paragraphs.size) + + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[0].words.size) + Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[2].words.size) + Assert.assertTrue(state.paragraphs[3] is ImageGalleryParagraph) + Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[3].words[0].segmentText) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[3].words[1].segmentText) + } + + @Test + fun testMultipleImagesRenderedAsGallery() { + // Test that multiple images (no videos) are correctly grouped as gallery + val text = + "Gallery:\n" + + "https://example.com/image1.jpg\n" + + "https://example.com/image2.png" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 2 image segments (image + video URLs) + Assert.assertEquals(2, state.paragraphs.size) + + // Should render as gallery (1 gallery with 2 images) + Assert.assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertEquals("Gallery should contain 2 images", 2, state.paragraphs[1].words.size) + Assert.assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) + Assert.assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) + } + + @Test + fun testMultipleImagesInAParagraphRenderedAsGallery() { + // Test that multiple images (no videos) are correctly grouped as gallery + val text = + "Gallery:\n" + + "https://example.com/image1.jpg https://example.com/image2.png" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 2 image segments (image + video URLs) + Assert.assertEquals(2, state.paragraphs.size) + + // Should render as gallery (1 gallery with 2 images) + Assert.assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertEquals("Gallery should contain 2 images", 2, state.paragraphs[1].words.size) + Assert.assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) + Assert.assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) + } + + @Test + fun testSingleImageRenderedIndividually() { + // Test that a single image is rendered individually, not as gallery + val text = "Single image:\nhttps://example.com/image.jpg" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should render individually (not as gallery) + Assert.assertTrue("Should render 1 individual image", state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertTrue("Should not render as gallery", state.paragraphs[0] !is ImageGalleryParagraph) + Assert.assertEquals("https://example.com/image.jpg", state.paragraphs[1].words[0].segmentText) + } + + @Test + fun testGigiMessage() { + val text = "I played The Typing of the Dead a lot when I was younger, and now I just found out that there's a new take on it: The Last Sentence. Tempted! https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should render individually (not as gallery) + Assert.assertEquals("I played The Typing of the Dead a lot when I was younger, and now I just found out that there's a new take on it: The Last Sentence. Tempted!", state.paragraphs[0].words[0].segmentText) + Assert.assertTrue("Should render as gallery", state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertEquals("https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg", state.paragraphs[1].words[0].segmentText) + Assert.assertEquals("https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg", state.paragraphs[1].words[1].segmentText) + } +} diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index dc90095fb..af10eface 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -4042,7 +4042,7 @@ class RichTextParserTest { org.junit.Assert.assertTrue(state.imageList.isEmpty()) org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) org.junit.Assert.assertEquals( - "Hi, how are you doing? ", + "Hi, how are you doing?", state.paragraphs .firstOrNull() ?.words @@ -4060,7 +4060,7 @@ class RichTextParserTest { org.junit.Assert.assertTrue(state.imageList.isEmpty()) org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) org.junit.Assert.assertEquals( - "\nHi, \nhow\n\n\n are you doing? \n", + "\nHi,\nhow\n\n\n are you doing?\n", state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, ) } @@ -4231,7 +4231,6 @@ class RichTextParserTest { listOf( "RegularText(Goon Night everybody :sleep:)", "Image(81ca16-b665-4f57-80cb-11a58461fb61.avif)", - "RegularText()", "Image(https://bae.st/media/66b08dde784287ed8f92c455bc62076a04671ccb44097550626a532185a5d3ed.avif?name=81ca16-b665-4f57-80cb-11a58461fb61.avif)", ) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt new file mode 100644 index 000000000..2eb4259d8 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt @@ -0,0 +1,190 @@ +/** + * 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.commons.richtext + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.ArrayList + +data class ParagraphImageAnalysis( + val imageCount: Int, + val isImageOnly: Boolean, + val hasMultipleImages: Boolean, +) + +class GalleryParser { + 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: List, + 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) + fun processParagraphs(paragraphs: List): List { + val result = mutableListOf() + + var paragraphIndex = 0 + while (paragraphIndex < paragraphs.size) { + val paragraph = paragraphs[paragraphIndex] + + if (paragraph.words.isEmpty()) { + // Empty paragraph - render normally with FlowRow (will render nothing) + result.add(paragraph) + paragraphIndex++ + } else { + 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) { + result.add(ImageGalleryParagraph(allImageWords, paragraph.isRTL)) + } else { + // Single image - render with FlowRow wrapper + result.add(paragraph) + } + + paragraphIndex = endIndex // Return next index to process + } else if (analysis.hasMultipleImages) { + // Mixed paragraph with multiple images - break it down into many paragraphs + result.addAll(processWordsWithImageGrouping(paragraph)) + paragraphIndex++ + } else { + result.add(paragraph) + paragraphIndex++ + } + } + } + + return result + } + + fun processWordsWithImageGrouping(paragraph: ParagraphState): List { + val resultingParagraphs = mutableListOf() + var i = 0 + val n = paragraph.words.size + + var currentParagraphSegments = mutableListOf() + while (i < n) { + val word = paragraph.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 = paragraph.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) { + currentParagraphSegments.add(word) + } else { + if (currentParagraphSegments.isNotEmpty()) { + resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL)) + currentParagraphSegments = mutableListOf() + } + + resultingParagraphs.add(ImageGalleryParagraph(imageSegments.toImmutableList(), paragraph.isRTL)) + } + + i = j // jump past processed run + } else { + currentParagraphSegments.add(word) + i++ + } + } + + if (currentParagraphSegments.isNotEmpty()) { + resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL)) + } + + return resultingParagraphs + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index fbc87e92c..edeebed7f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -172,30 +172,30 @@ class RichTextParser { val paragraphSegments = ArrayList(lines.size) lines.forEach { paragraph -> - var isDirty = false val isRTL = isArabic(paragraph) val wordList = paragraph.trimEnd().split(' ') val segments = ArrayList(wordList.size) wordList.forEach { word -> - val wordSegment = wordIdentifier(word, images, urls, emojis, tags) - if (wordSegment !is RegularTextSegment) { - isDirty = true - } - segments.add(wordSegment) + segments.add(wordIdentifier(word, images, urls, emojis, tags)) } - val newSegments = - if (isDirty) { - ParagraphState(segments.toPersistentList(), isRTL) - } else { - ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) - } - - paragraphSegments.add(newSegments) + paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) } - return paragraphSegments.toImmutableList() + val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments) + + return segmentsWithGalleries + .map { paragraph -> + if (paragraph.words.isEmpty() || paragraph.words.any { it !is RegularTextSegment }) { + paragraph + } else { + ParagraphState( + persistentListOf(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })), + paragraph.isRTL, + ) + } + }.toImmutableList() } private fun isNumber(word: String) = numberPattern.matcher(word).matches() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 35c19442c..4fd05413a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -37,11 +37,17 @@ class RichTextViewerState( ) @Immutable -data class ParagraphState( +open class ParagraphState( val words: ImmutableList, val isRTL: Boolean, ) +@Immutable +class ImageGalleryParagraph( + words: ImmutableList, + isRTL: Boolean, +) : ParagraphState(words, isRTL) + @Immutable open class Segment( val segmentText: String,