diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index b19a86f8c2..378b2e9145 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -60,9 +60,9 @@ class RichTextParser { val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() - val isImage: Boolean - val isVideo: Boolean - val isPdf: Boolean + var isImage = false + var isVideo = false + var isPdf = false if (contentType != null) { isImage = contentType.startsWith("image/") @@ -77,7 +77,17 @@ class RichTextParser { isImage = fullUrl.startsWith("data:image/") isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") isPdf = fullUrl.startsWith("data:application/pdf") - } else { + } + + // Fall back to file-extension detection when the type is still unknown. This covers both + // the no-MIME case and a *malformed* imeta MIME — e.g. Primal iOS emits `m jpeg` instead + // of `m image/jpeg`, which matches none of the `startsWith` prefixes above. Without this + // fallback such a URL returns null and drops to a plain link: that discards the imeta + // `dim`/blurhash (so the loading placeholder can't reserve the image's height and the + // feed jumps once the bitmap arrives) and forces a needless URL-preview network + // round-trip just to rediscover the type the imeta already declared. `data:` URIs carry + // their type in the prefix, so a miss there is genuine — don't extension-probe them. + if (!isImage && !isVideo && !isPdf && !fullUrl.startsWith("data:")) { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt index 8492017c4e..2dc2f906fa 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -66,4 +66,66 @@ class PdfParserTest { assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF")) assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc")) } + + // Primal iOS writes a bare subtype (`m jpeg`) instead of a full MIME (`m image/jpeg`). + // The bare subtype matches none of the `image/`/`video/`/`application/pdf` prefixes, so before + // the extension fallback the whole imeta was dropped: the URL rendered as a plain link, losing + // the `dim` needed to reserve the image's height (feed jump) and forcing a URL-preview fetch. + // The `.jpg` extension must still route it to a MediaUrlImage that carries `dim`. + @Test + fun detectsImageFromMalformedImetaMimeWithImageExtension() { + val url = "https://blossom.primal.net/33e7c01afbea894a64e1db44dece460b09a2426108f47143754e1cf4bfdf747c.jpg" + val content = "\n$url" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m jpeg", "dim 1009.0x680.0"), + arrayOf("client", "Primal iOS"), + ), + ) + + val state = RichTextParser().parseText(content, tags, null) + + val imageMedia = state.mediaForPager[url] + assertTrue(imageMedia is MediaUrlImage, "Expected MediaUrlImage despite the malformed `m jpeg` mime") + assertEquals("1009x680", imageMedia.dim?.toString(), "The imeta dim must survive so the loader can reserve space") + assertEquals(1009f / 680f, imageMedia.dim?.aspectRatio()) + + val segment = state.paragraphs.flatMap { it.words }.first { it.segmentText == url } + assertTrue(segment is ImageSegment, "Expected ImageSegment, got ${segment::class.simpleName}") + } + + // A malformed video subtype (`m mp4`) must likewise fall back to the `.mp4` extension. + @Test + fun detectsVideoFromMalformedImetaMimeWithVideoExtension() { + val url = "https://cdn.example.com/clip.mp4" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m mp4"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + val videoMedia = state.mediaForPager[url] + assertTrue(videoMedia is MediaUrlVideo, "Expected MediaUrlVideo despite the malformed `m mp4` mime") + } + + // A malformed mime on a URL with *no* recognizable extension can't be recovered — it stays a + // link. This documents the boundary of the fallback so it isn't mistaken for a regression. + @Test + fun malformedImetaMimeWithoutExtensionStaysUnclassified() { + val url = "https://files.example.com/abcd1234" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m jpeg"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + assertEquals(null, state.mediaForPager[url], "No extension to recover from -> not treated as media") + } }