From 1e12decd369d13a7fdc9637e2be4ef1f60629838 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 1 Nov 2025 16:23:35 +0100 Subject: [PATCH 1/2] add VideoSegment and failing tests --- .../commons/richtext/GalleryParserTest.kt | 111 +++++++++++++++--- .../richtext/RichTextParserSegments.kt | 5 + 2 files changed, 101 insertions(+), 15 deletions(-) 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 index a0b832feb..d49ad783c 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import android.R.attr.text import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList import org.junit.Assert @@ -31,7 +30,7 @@ import org.junit.runner.RunWith class GalleryParserTest { @Test fun testMixedImageAndVideoRenderedIndividually() { - // Test the bug: when mixed image + video, both should be rendered individually + // Test that when image + video are in consecutive paragraphs, both should be rendered individually (not as gallery) val text = "Renfield (2023)\n" + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + @@ -39,19 +38,29 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) - // Should have 2 image segments (image + video URLs) - Assert.assertEquals(2, state.paragraphs.size) + // Should have 3 paragraphs (text, image, video) + Assert.assertEquals(3, state.paragraphs.size) + // First paragraph is text Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) Assert.assertEquals(1, state.paragraphs[0].words.size) - Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[1].words.size) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) 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) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[2].words.size) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) } @Test fun testMixedImageAndVideoRenderedIndividuallyDoubled() { - // Test the bug: when mixed image + video, both should be rendered individually + // Test that when image + video are repeated, both should be rendered individually (not as gallery) val text = "Renfield (2023)\n" + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + @@ -62,19 +71,28 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) - // Should have 2 image segments (image + video URLs) - Assert.assertEquals(4, state.paragraphs.size) + // Should have 6 paragraphs (text, image, video, text, image, video) + Assert.assertEquals(6, state.paragraphs.size) + // First set: text, image, video Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) Assert.assertEquals(1, state.paragraphs[0].words.size) - Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) 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) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) + + // Second set: text, image, video + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[3].words.size) + Assert.assertTrue(state.paragraphs[4] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[4].words[0] is ImageSegment) + Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[4].words[0].segmentText) + Assert.assertTrue(state.paragraphs[5] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[5].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[5].words[0].segmentText) } @Test @@ -141,4 +159,67 @@ class GalleryParserTest { 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) } + + @Test + fun testMixedImageAndVideoInSameParagraph() { + // Test the user's specific example: mixed image and video in same paragraph should render individually + val text = + "The Crow (1994)\n" + + "https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg\n" + + "https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4\n" + + "#fantasy #action #thriller #film #movies #kinostr" + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 4 paragraphs (text, image, video, hashtags) + Assert.assertEquals(4, state.paragraphs.size) + + // First paragraph is text + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) + Assert.assertEquals("https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg", state.paragraphs[1].words[0].segmentText) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4", state.paragraphs[2].words[0].segmentText) + + // Fourth paragraph is hashtags + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + } + + @Test + fun testDesperadoExample() { + // Test the exact example provided by the user + val text = + "Desperado (1995)\n" + + "https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg\n" + + "https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4\n" + + "#action #crime #film #movies #kinostr" + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 4 paragraphs (text, image, video, hashtags) + Assert.assertEquals(4, state.paragraphs.size) + + // First paragraph is text + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + Assert.assertEquals("Desperado (1995)", state.paragraphs[0].words[0].segmentText) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[1].words.size) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) + Assert.assertEquals("https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg", state.paragraphs[1].words[0].segmentText) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[2].words.size) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4", state.paragraphs[2].words[0].segmentText) + + // Fourth paragraph is hashtags + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + } } 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 4fd05413a..f826220c6 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 @@ -58,6 +58,11 @@ class ImageSegment( segment: String, ) : Segment(segment) +@Immutable +class VideoSegment( + segment: String, +) : Segment(segment) + @Immutable class LinkSegment( segment: String, From 7c374fb5869c7de9f9127f17329be9cfe1596f2c Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 1 Nov 2025 16:27:28 +0100 Subject: [PATCH 2/2] Parse for video segments and exclude them from image gallery --- .../amethyst/ui/components/RichTextViewer.kt | 6 ++++-- .../amethyst/commons/richtext/GalleryParser.kt | 13 ++++++++++--- .../amethyst/commons/richtext/RichTextParser.kt | 11 +++++++++-- 3 files changed, 23 insertions(+), 7 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 b9388edba..ce7e7ea98 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 @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.LocalCache @@ -92,7 +93,6 @@ 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 @@ -112,7 +112,6 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.text.Typography.paragraph fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -416,6 +415,8 @@ private fun RenderWordWithoutPreview( when (word) { // Don't preview Images is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) + // Don't preview Videos + is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText) is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) // Don't offer to pay invoices @@ -447,6 +448,7 @@ private fun RenderWordWithPreview( ) { when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) 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 index 51eec881d..6eb4d9700 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt @@ -37,6 +37,7 @@ class GalleryParser { paragraph.words.forEach { word -> when (word) { is ImageSegment, is Base64Segment -> imageCount++ + is VideoSegment -> hasNonWhitespaceNonImageContent = true // Videos are not images is RegularTextSegment -> { if (word.segmentText.isNotBlank()) { hasNonWhitespaceNonImageContent = true @@ -146,13 +147,18 @@ class GalleryParser { val word = paragraph.words[i] if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive image/whitespace segments without extra list allocations + // Collect consecutive image/whitespace segments (but not videos) val imageSegments = mutableListOf() var j = i + var hasVideo = false while (j < n) { val seg = paragraph.words[j] when { + seg is VideoSegment -> { + hasVideo = true + break + } seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } else -> break @@ -160,8 +166,9 @@ class GalleryParser { j++ } - if (imageSegments.size <= 1) { - currentParagraphSegments.add(word) + // If we found a video, don't create a gallery - render images individually + if (hasVideo || imageSegments.size <= 1) { + currentParagraphSegments.addAll(imageSegments) } else { if (currentParagraphSegments.isNotEmpty()) { resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL)) 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 edeebed7f..14bc34a4f 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 @@ -139,9 +139,12 @@ class RichTextParser { val imagesForPager = urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url } + val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys + val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys + val emojiMap = CustomEmoji.createEmojiMap(tags) - val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) + val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags) val base64Images = segments.map { it.words.filterIsInstance() }.flatten() @@ -164,6 +167,7 @@ class RichTextParser { private fun findTextSegments( content: String, images: Set, + videos: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists, @@ -177,7 +181,7 @@ class RichTextParser { val wordList = paragraph.trimEnd().split(' ') val segments = ArrayList(wordList.size) wordList.forEach { word -> - segments.add(wordIdentifier(word, images, urls, emojis, tags)) + segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) @@ -229,6 +233,7 @@ class RichTextParser { private fun wordIdentifier( word: String, images: Set, + videos: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists, @@ -239,6 +244,8 @@ class RichTextParser { if (images.contains(word)) return ImageSegment(word) + if (videos.contains(word)) return VideoSegment(word) + if (urls.contains(word)) return LinkSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)