From 4653951b54d4b86bc4dd76f29c3693ad95c07891 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:40:31 +0000 Subject: [PATCH] fix: recover media type from file extension when imeta MIME is malformed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a full type (`m image/jpeg`). `createMediaContent` classified media only by the MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched neither image/video/pdf and the whole imeta was dropped: the URL rendered as a plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so the loading placeholder cannot reserve the image's height and the feed jumps when the bitmap finally arrives, and it forces a URL-preview network round-trip just to rediscover the type the imeta already declared. Fall back to file-extension detection whenever the type is still unknown after the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification and keeps the imeta metadata, so the image renders through the fast media path with its dimensions reserved up front. `data:` URIs keep their type in the prefix and are left unprobed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc --- .../commons/richtext/RichTextParser.kt | 18 ++++-- .../commons/richtext/PdfParserTest.kt | 62 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) 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") + } }