From 0cf1534861b0792d6e72d44bc04fb44f1c718abb Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Aug 2026 21:54:04 +0200 Subject: [PATCH 1/2] fix(media): stop rendering non-media NIP-94 files as video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind-1063 file header was classified by a binary `isImage` test: anything that wasn't an image fell through to MediaUrlVideo. A webxdc app (application/x-webxdc, a zip) therefore reached ExoPlayer and buffered forever, as did every archive, installer and — since MediaUrlPdf was never constructed here — every NIP-94 PDF. --- .../ui/components/FileAttachmentCard.kt | 142 ++++++++++++++++++ .../ui/components/pdf/PdfPreviewCard.kt | 45 +----- .../amethyst/ui/note/types/FileHeader.kt | 142 ++++++++++++------ .../amethyst/ui/note/types/Video.kt | 5 +- .../amethyst/ui/note/types/VideoDisplay.kt | 5 +- .../loggedIn/shorts/VideoCardCompose.kt | 5 +- .../loggedIn/video/FileHeaderCardCompose.kt | 48 ++---- .../commons/richtext/MediaContentKind.kt | 37 +++++ .../commons/richtext/RichTextParser.kt | 83 +++++----- .../commons/richtext/ClassifyMediaTest.kt | 138 +++++++++++++++++ 10 files changed, 492 insertions(+), 158 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt new file mode 100644 index 0000000000..759d5e7e83 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -0,0 +1,142 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes +import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename +import com.vitorpamplona.amethyst.ui.note.types.prettyMime +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +/** + * The renderer for a declared file that none of the media viewers can display — a webxdc app, + * an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia] + * returns null for. + * + * It exists so those files have somewhere to land other than the video player: an unknown blob + * used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a + * zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the + * card costs no network round-trip — unlike routing the URL through the OpenGraph previewer, + * which would try to download the blob just to rediscover the type the event already declared. + */ +@Composable +fun FileAttachmentCard( + url: String, + description: String?, + mimeType: String?, + sizeInBytes: Long?, +) { + val uriHandler = LocalUriHandler.current + val filename = remember(url) { extractFilename(url) } + val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) } + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .clickable { uriHandler.openUri(url) }, + ) { + FileAttachmentRow( + symbol = MaterialSymbols.AttachFile, + // The alt/content text names the file for a human ("Webxdc app: Quake"); + // the hashed URL basename is the fallback when the event omits it. + title = description?.ifBlank { null } ?: filename, + subtitle = subtitle, + titleMaxLines = 2, + ) + + Spacer(modifier = DoubleVertSpacer) + } +} + +/** + * The icon + title + subtitle row shared by every card that stands in for a file it can't + * render inline: this one and the PDF placeholder/skeleton in + * [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard]. + */ +@Composable +internal fun FileAttachmentRow( + symbol: MaterialSymbol, + title: String, + subtitle: String?, + titleMaxLines: Int = 1, +) { + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } +} + +/** "APK · 16 MB", dropping either half when the event doesn't declare it. */ +private fun fileSubtitle( + mimeType: String?, + sizeInBytes: Long?, +): String? = + listOfNotNull( + mimeType?.ifBlank { null }?.let(::prettyMime), + sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes), + ).joinToString(" · ").ifEmpty { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index c352077e42..0b975ede46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) { private fun FilenameRow( filename: String, subtitle: String, -) { - Row( - modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - symbol = MaterialSymbols.PictureAsPdf, - contentDescription = null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = filename, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } -} +) = FileAttachmentRow( + symbol = MaterialSymbols.PictureAsPdf, + title = filename, + subtitle = subtitle, +) private fun renderFirstPage( file: java.io.File, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 62d91d39aa..96730249ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,50 +46,103 @@ fun FileHeaderDisplay( ) { val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return + val mimeType = remember(note) { event.mimeType() } + val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - val content: BaseMediaContent = - remember(note) { - val blurHash = event.blurhash() - val thumbHash = event.thumbhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - val mimeType = event.mimeType() - - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - } - } - + // The sensitivity gate wraps both branches: a content warning is about the file, not about + // which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate. SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, - accountViewModel = accountViewModel, - ) + if (content == null) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + } else { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } } } + +/** + * Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob. + * + * Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71 + * video event the kind itself asserts nothing about how to render the payload. A null here means + * the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player. + */ +internal fun FileHeaderEvent.toMediaContent( + note: Note, + url: String, + mimeType: String?, +): BaseMediaContent? { + val blurHash = blurhash() + val thumbHash = thumbhash() + val hash = hash() + val dimensions = dimensions() + val description = fileDescription() + val uri = note.toNostrUri() + + return when (RichTextParser.classifyMedia(url, mimeType)) { + MediaContentKind.IMAGE -> + MediaUrlImage( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.VIDEO -> + MediaUrlVideo( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.PDF -> + MediaUrlPdf( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + null -> null + } +} + +/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */ +@Composable +internal fun FileHeaderAttachmentCard( + event: FileHeaderEvent, + url: String, + mimeType: String?, +) { + val description = remember(event) { event.fileDescription() } + val sizeInBytes = remember(event) { event.size()?.toLong() } + + FileAttachmentCard( + url = url, + description = description, + mimeType = mimeType, + sizeInBytes = sizeInBytes, + ) +} + +/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */ +private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index d00bd6d2e8..1d8cdcc3d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -88,7 +89,9 @@ fun VideoDisplay( val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE val uri = note.toNostrUri() if (isImage) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 0abc16ac93..4f5c661810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -55,7 +56,9 @@ fun JustVideoDisplay( val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 6171a82067..9e52ec0362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -104,7 +105,9 @@ private fun VideoCardImage( val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index ac3778152f..f47613e775 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop @@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard +import com.vitorpamplona.amethyst.ui.note.types.toMediaContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -101,45 +98,22 @@ private fun FileHeaderCardImage( val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) } + val mimeType = remember(note) { event.mimeType() } val blurHash = remember(note) { event.blurhash() } val thumbHash = remember(note) { event.thumbhash() } val dimensions = remember(note) { event.dimensions() } - val content by remember(note) { - val hash = event.hash() - val description = event.content.ifEmpty { null } ?: event.alt() - val uri = note.toNostrUri() - val mimeType = event.mimeType() + val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - }, - ) + // VideoFeedFilter only admits image/video MIMEs and extensions, so a non-media blob should + // never reach this card. Render it as a link anyway rather than keeping an "unknown → video" + // default around: that default is what put a webxdc app into ExoPlayer in the note renderer. + if (content == null) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + return } + val isImage = content is MediaUrlImage val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl) ContentWarningGate( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt new file mode 100644 index 0000000000..57cce902b2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt @@ -0,0 +1,37 @@ +/* + * 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 + +/** + * Which player/viewer can render a declared blob, as resolved by + * [RichTextParser.classifyMedia]. + * + * The set is deliberately closed: it enumerates the renderers [BaseMediaContent] actually has + * (`MediaUrlImage`, `MediaUrlVideo`, `MediaUrlPdf`), so "no constant fits" — a `null` + * classification — is the honest answer for every other file type rather than a bucket some + * caller has to invent a default for. Audio folds into [VIDEO] because both play through the + * same pipeline; see `RichTextParser.videoExt`. + */ +enum class MediaContentKind { + IMAGE, + VIDEO, + PDF, +} 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 60f04f7578..0ecc3b82f0 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 @@ -61,41 +61,12 @@ class RichTextParser { val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() - var isImage = false - var isVideo = false - var isPdf = false + // Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash + // and forcing a URL-preview round-trip to rediscover a type the imeta already declared — + // which is why classifyMedia falls back to the extension before giving up. + val kind = classifyMedia(fullUrl, contentType) - if (contentType != null) { - isImage = contentType.startsWith("image/") - // HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl` - // and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the - // null bucket below and the renderer drops back to a plain hyperlink — even though - // the matching extension would have routed it to MediaUrlVideo. Mirror the canonical - // list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType. - isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType) - isPdf = contentType.startsWith("application/pdf") - } else if (fullUrl.startsWith("data:")) { - isImage = fullUrl.startsWith("data:image/") - isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") - isPdf = fullUrl.startsWith("data:application/pdf") - } - - // 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) } - isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) } - } - - return if (isImage) { + return if (kind == MediaContentKind.IMAGE) { MediaUrlImage( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -108,7 +79,7 @@ class RichTextParser { thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), authorPubKey = authorPubKey, ) - } else if (isVideo) { + } else if (kind == MediaContentKind.VIDEO) { MediaUrlVideo( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -125,7 +96,7 @@ class RichTextParser { thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), authorPubKey = authorPubKey, ) - } else if (isPdf) { + } else if (kind == MediaContentKind.PDF) { MediaUrlPdf( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -582,6 +553,46 @@ class RichTextParser { return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } + /** + * Resolves which renderer can display a declared blob — the single decision every media + * renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL. + * + * A declared MIME type wins; the URL extension is the fallback both for the no-MIME case + * and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which + * matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is + * genuine and the base64 payload is never extension-probed. + * + * Returns **null** when nothing can render the file. Callers must not substitute a media + * kind for that null: handing an arbitrary blob — a webxdc app, a zip, an APK — to the + * video player yields a permanently-buffering ExoPlayer where a plain link belongs. The one + * defensible default is on kinds whose *event* already asserts the type (a NIP-71 video + * event is a video however odd its imeta), and those call sites say so explicitly. + */ + fun classifyMedia( + url: String, + mimeType: String?, + ): MediaContentKind? { + if (mimeType != null) { + if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE + // HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType]. + if (mimeType.startsWith("video/") || mimeType.startsWith("audio/") || isHlsMimeType(mimeType)) return MediaContentKind.VIDEO + if (mimeType.startsWith("application/pdf")) return MediaContentKind.PDF + } else if (url.startsWith("data:")) { + if (url.startsWith("data:image/")) return MediaContentKind.IMAGE + if (url.startsWith("data:video/") || url.startsWith("data:audio/")) return MediaContentKind.VIDEO + if (url.startsWith("data:application/pdf")) return MediaContentKind.PDF + } + + if (url.startsWith("data:")) return null + + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE + if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO + if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF + + return null + } + fun isValidURL(url: String?): Boolean = isValidUrl(url) fun parseImageOrVideo(fullUrl: String): BaseMediaContent { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt new file mode 100644 index 0000000000..2482a8ae80 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt @@ -0,0 +1,138 @@ +/* + * 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 com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ClassifyMediaTest { + @Test + fun webxdcAppIsNotMedia() { + // Regression: a NIP-94 header for a webxdc app (a zip bundle) used to reach the + // ExoPlayer branch, because the only test was `isImage` and everything else fell + // through to video. https://blossom.ditto.pub/.xdc, m=application/x-webxdc + assertNull( + RichTextParser.classifyMedia( + "https://blossom.ditto.pub/d810ba7873d710b197fc402c0573cd95ce7d44fff7f904e8f58e48af3a47c107.xdc", + "application/x-webxdc", + ), + ) + } + + @Test + fun unknownTypesAreNotMedia() { + assertNull(RichTextParser.classifyMedia("https://x.com/app.apk", "application/vnd.android.package-archive")) + assertNull(RichTextParser.classifyMedia("https://x.com/archive.zip", "application/zip")) + assertNull(RichTextParser.classifyMedia("https://x.com/notes.txt", "text/plain")) + // No mime at all and an extension we don't render. + assertNull(RichTextParser.classifyMedia("https://x.com/file.xdc", null)) + assertNull(RichTextParser.classifyMedia("https://x.com/no-extension-at-all", null)) + } + + @Test + fun declaredMimeTypesClassify() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a", "image/png")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "video/mp4")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpeg")) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a", "application/pdf")) + } + + @Test + fun hlsPlaylistMimesAreVideo() { + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/vnd.apple.mpegurl")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/x-mpegURL")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpegurl")) + } + + @Test + fun extensionIsUsedWhenMimeIsAbsent() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.m3u8", null)) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a.pdf", null)) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null)) + } + + @Test + fun extensionRescuesAMalformedMime() { + // Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win + // over "unknown". Preserves the behaviour createMediaContent already documented. + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "jpeg")) + } + + @Test + fun queryStringsAndFragmentsAreStripped() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg?token=1", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4#t=10", null)) + assertNull(RichTextParser.classifyMedia("https://x.com/a.xdc?token=1", null)) + } + + @Test + fun dataUrisAreClassifiedByTheirPrefixOnly() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("data:image/png;base64,AAAA", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("data:video/mp4;base64,AAAA", null)) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("data:application/pdf;base64,AAAA", null)) + // A data: URI carries its type in the prefix, so a miss there is genuine — the + // payload must never be extension-probed (base64 can end in any letters). + assertNull(RichTextParser.classifyMedia("data:application/zip;base64,AAAAmp4", null)) + } + + @Test + fun classifyMediaAgreesWithCreateMediaContent() { + // createMediaContent is the long-standing reference for this decision; the two must + // not drift, since half the renderers call one and half the other. + val cases = + listOf( + "https://x.com/a.jpg" to null, + "https://x.com/a" to "image/png", + "https://x.com/a" to "video/mp4", + "https://x.com/a" to "audio/mpeg", + "https://x.com/a" to "application/pdf", + "https://x.com/a" to "application/vnd.apple.mpegurl", + "https://x.com/a.xdc" to "application/x-webxdc", + "https://x.com/a.zip" to "application/zip", + "https://x.com/a.jpg" to "jpeg", + "data:image/png;base64,AAAA" to null, + "data:application/zip;base64,AAAAmp4" to null, + ) + + cases.forEach { (url, mime) -> + val tags = mime?.let { mapOf(url to imeta(url, it)) } ?: emptyMap() + val expected = + when (RichTextParser().createMediaContent(url, tags, null)) { + is MediaUrlImage -> MediaContentKind.IMAGE + is MediaUrlVideo -> MediaContentKind.VIDEO + is MediaUrlPdf -> MediaContentKind.PDF + null -> null + else -> error("unexpected content type for $url / $mime") + } + + assertEquals(expected, RichTextParser.classifyMedia(url, mime), "disagreement on $url / $mime") + } + } + + private fun imeta( + url: String, + mimeType: String, + ) = IMetaTag(url = url, properties = mapOf("m" to listOf(mimeType))) +} From 3565f75847c1de37046361525e2c7274413f2f88 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Aug 2026 22:16:14 +0200 Subject: [PATCH 2/2] Code review: - gate the file-attachment card - move prettyMime to commons - add tests --- .../ui/components/FileAttachmentCard.kt | 2 +- .../amethyst/ui/note/types/SoftwareApp.kt | 22 +------- .../loggedIn/video/FileHeaderCardCompose.kt | 19 +++++-- .../amethyst/commons/util/MimeTypeLabels.kt | 52 +++++++++++++++++++ .../commons/richtext/ClassifyMediaTest.kt | 23 ++++++++ 5 files changed, 92 insertions(+), 26 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt index 759d5e7e83..ed4aba59d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes +import com.vitorpamplona.amethyst.commons.util.prettyMime import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename -import com.vitorpamplona.amethyst.ui.note.types.prettyMime import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 059fa68747..8e999414f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -65,6 +65,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.util.prettyMime import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note @@ -766,27 +767,6 @@ fun RenderSoftwareAsset( } } -internal fun prettyMime(mime: String): String = - when (mime) { - "application/vnd.android.package-archive" -> "APK" - "application/vnd.apple.ipa" -> "IPA" - "application/x-apple-diskimage" -> "DMG" - "application/vnd.apple.installer+xml" -> "PKG" - "application/x-msi" -> "MSI" - "application/vnd.appimage" -> "AppImage" - "application/vnd.flatpak" -> "Flatpak" - "application/vnd.oci.image.manifest.v1+json" -> "OCI" - "application/x-executable" -> "ELF" - "application/x-mach-binary" -> "Mach-O" - "application/vnd.microsoft.portable-executable" -> "EXE" - "application/vsix" -> "VSIX" - "application/x-chrome-extension" -> "CRX" - "application/x-xpinstall" -> "XPI" - "application/wasm" -> "WASM" - "application/webbundle" -> "Web Bundle" - else -> mime - } - internal fun formatBytes(bytes: Long): String { if (bytes < 1024L) return "$bytes B" val kb = bytes / 1024.0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index f47613e775..d5b18976f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -105,11 +105,22 @@ private fun FileHeaderCardImage( val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - // VideoFeedFilter only admits image/video MIMEs and extensions, so a non-media blob should - // never reach this card. Render it as a link anyway rather than keeping an "unknown → video" - // default around: that default is what put a webxdc app into ExoPlayer in the note renderer. + // Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on + // `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event + // whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps + // the viewer in FileHeaderDisplay: a content warning is about the file, and the card still + // spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults + // (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash + // to show behind it. if (content == null) { - FileHeaderAttachmentCard(event, fullUrl, mimeType) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = emptyList(), + accountViewModel = accountViewModel, + ) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + } return } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt new file mode 100644 index 0000000000..1911a22019 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt @@ -0,0 +1,52 @@ +/* + * 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.util + +/** + * The short label a user recognises for a distributable file type — "APK", not + * "application/vnd.android.package-archive". + * + * Unmapped types return the raw MIME unchanged, which is the honest fallback: a bare + * `application/x-webxdc` still tells the reader more than an invented label would. + * + * Used by the NIP-82 software-app chips and by the file-attachment card that stands in for any + * blob no viewer can render. + */ +fun prettyMime(mime: String): String = + when (mime) { + "application/vnd.android.package-archive" -> "APK" + "application/vnd.apple.ipa" -> "IPA" + "application/x-apple-diskimage" -> "DMG" + "application/vnd.apple.installer+xml" -> "PKG" + "application/x-msi" -> "MSI" + "application/vnd.appimage" -> "AppImage" + "application/vnd.flatpak" -> "Flatpak" + "application/vnd.oci.image.manifest.v1+json" -> "OCI" + "application/x-executable" -> "ELF" + "application/x-mach-binary" -> "Mach-O" + "application/vnd.microsoft.portable-executable" -> "EXE" + "application/vsix" -> "VSIX" + "application/x-chrome-extension" -> "CRX" + "application/x-xpinstall" -> "XPI" + "application/wasm" -> "WASM" + "application/webbundle" -> "Web Bundle" + else -> mime + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt index 2482a8ae80..240d4f0baf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt @@ -73,6 +73,29 @@ class ClassifyMediaTest { assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null)) } + @Test + fun aDeclaredMimeBeatsAContradictingExtension() { + // The check this replaced was an OR — `mime.startsWith("image/") || isImageUrl(url)` — + // so a poster-named video URL classified as an image. A declared MIME is the publisher + // stating the type; the extension is only a guess for when they didn't. Pins the + // precedence against a future "simplification" back to OR-semantics. + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/thumb.jpg", "video/mp4")) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/clip.mp4", "image/png")) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/scan.png", "application/pdf")) + } + + @Test + fun anUnrecognisedMimeDefersToTheExtensionRatherThanVetoingIt() { + // Precedence applies only to MIMEs we recognise. An unrecognised one means "no usable + // declaration", not "declared unrenderable" — the two are indistinguishable here, and + // treating them alike is what lets [extensionRescuesAMalformedMime] work. So a real + // video mislabelled `application/x-webxdc` still plays… + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/bundle.mp4", "application/x-webxdc")) + // …while the webxdc app that motivated this class stays unrenderable, because nothing + // rescues it: `.xdc` is in no extension list either. + assertNull(RichTextParser.classifyMedia("https://x.com/bundle.xdc", "application/x-webxdc")) + } + @Test fun extensionRescuesAMalformedMime() { // Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win