From 75c96115cbb8945b957a70f02dbee1cd96f08fec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 15:53:41 +0000 Subject: [PATCH 1/6] feat(feeds): overlay content warning on blurhash at media size In the picture and video feeds, render the sensitivity warning over the media's blurhash placeholder using the media's aspect ratio, instead of a fixed-size dialog that hides the context entirely. Falls back to the existing dialog when no blurhash is available. --- .../ui/components/SensitivityWarning.kt | 157 ++++++++++++++++++ .../loggedIn/pictures/PictureCardCompose.kt | 14 +- .../loggedIn/shorts/VideoCardCompose.kt | 11 +- 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 29eb4d8a1..387c6c99c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -44,6 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -72,6 +76,31 @@ fun SensitivityWarning( note.event?.let { SensitivityWarning(it, accountViewModel, content) } } +@Composable +fun SensitivityWarning( + note: Note, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val event = note.event + if (event == null) { + content() + return + } + + val hasSensitiveContent = remember(event) { event.isSensitiveOrNSFW() } + + if (hasSensitiveContent) { + val reason = remember(event) { event.contentWarningReason() } + ObserveSensitivityWarningOverBlurhash(reason, blurhash, ratio, description, accountViewModel, content) + } else { + content() + } +} + @Composable fun SensitivityWarning( event: Event, @@ -120,6 +149,32 @@ fun ObserveSensitivityWarning( } } +@Composable +fun ObserveSensitivityWarningOverBlurhash( + reason: String?, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + if (blurhash != null) { + ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + } else { + ContentWarningNote(reason) { showContentWarningNote = false } + } + } else { + content() + } + } +} + @Preview @Composable fun ContentWarningNotePreview() { @@ -144,6 +199,108 @@ fun ContentWarningNoteWithBigReasonPreview() { } } +@Preview +@Composable +fun ContentWarningOverBlurhashPreview() { + ThemeComparisonColumn { + ContentWarningOverBlurhash( + reason = "Spoilers", + blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + ratio = 16f / 9f, + description = null, + onDismiss = {}, + ) + } +} + +@Composable +fun ContentWarningOverBlurhash( + reason: String?, + blurhash: String, + ratio: Float?, + description: String?, + onDismiss: () -> Unit, +) { + val sizingModifier = + if (ratio != null) { + Modifier.fillMaxWidth().aspectRatio(ratio) + } else { + Modifier.fillMaxWidth() + } + + Box(modifier = sizingModifier) { + DisplayBlurHash( + blurhash = blurhash, + description = description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(80.dp) + .width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(70.dp) + .align(Alignment.BottomStart), + tint = Color.White, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(30.dp) + .align(Alignment.TopEnd), + tint = Color.White, + ) + } + + Text( + text = + if (reason.isNullOrBlank()) { + stringRes(R.string.content_warning) + } else { + stringRes(R.string.content_warning_with_reason, reason) + }, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + softWrap = true, + textAlign = TextAlign.Center, + ) + + FilledTonalButton( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + contentPadding = ButtonPadding, + ) { + Text( + text = stringRes(R.string.show_anyway), + ) + } + } + } + } +} + @Composable fun ContentWarningNote( reason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 864eaacc8..325e3313b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.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.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -112,10 +113,19 @@ private fun PictureCardImage( } if (images.isNotEmpty()) { - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val first = images.first() + val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) + + SensitivityWarning( + note = note, + blurhash = first.blurhash, + ratio = ratio, + description = first.description, + accountViewModel = accountViewModel, + ) { if (images.size == 1) { ZoomableContentView( - content = images.first(), + content = first, images = images, roundedCorner = false, contentScale = ContentScale.FillWidth, 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 ba77fe0b6..18dbd921d 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 @@ -42,6 +42,7 @@ 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.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView @@ -130,7 +131,15 @@ private fun VideoCardImage( ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + SensitivityWarning( + note = note, + blurhash = imeta.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { ZoomableContentView( content = content, roundedCorner = false, From 11666541493f1c9e35b23ddec5110d79fbe3f1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 18:51:17 +0000 Subject: [PATCH 2/6] refactor(feeds): push content warning into ZoomableContentView per media Move the blurhash-aware sensitivity warning from feed card wrappers into ZoomableContentView so every media item shows its own warning sized to its own aspect ratio over its own blurhash. Grid posts now warn per image instead of once over the whole grid. - Add isSensitive to MediaUrl{Image,Video} (defaults to contentWarning != null) - Populate isSensitive and contentWarning when building media in the picture, video, and file-header feeds plus their display variants - Drop the outer SensitivityWarning wrappers now handled inside ZoomableContentView via SensitivityWarningOverBlurhash --- .../ui/components/SensitivityWarning.kt | 83 +++++++------------ .../ui/components/ZoomableContentView.kt | 20 ++++- .../amethyst/ui/note/types/PictureDisplay.kt | 83 ++++++++++--------- .../amethyst/ui/note/types/VideoDisplay.kt | 23 +++-- .../loggedIn/pictures/PictureCardCompose.kt | 43 ++++------ .../loggedIn/shorts/VideoCardCompose.kt | 30 +++---- .../loggedIn/video/FileHeaderCardCompose.kt | 23 +++-- .../commons/richtext/MediaContentModels.kt | 8 +- 8 files changed, 158 insertions(+), 155 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 387c6c99c..0375cfa1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -76,31 +76,6 @@ fun SensitivityWarning( note.event?.let { SensitivityWarning(it, accountViewModel, content) } } -@Composable -fun SensitivityWarning( - note: Note, - blurhash: String?, - ratio: Float?, - description: String?, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - val event = note.event - if (event == null) { - content() - return - } - - val hasSensitiveContent = remember(event) { event.isSensitiveOrNSFW() } - - if (hasSensitiveContent) { - val reason = remember(event) { event.contentWarningReason() } - ObserveSensitivityWarningOverBlurhash(reason, blurhash, ratio, description, accountViewModel, content) - } else { - content() - } -} - @Composable fun SensitivityWarning( event: Event, @@ -130,6 +105,38 @@ fun SensitivityWarning( } } +@Composable +fun SensitivityWarningOverBlurhash( + isSensitive: Boolean, + reason: String?, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + if (!isSensitive) { + content() + return + } + + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + if (blurhash != null) { + ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + } else { + ContentWarningNote(reason) { showContentWarningNote = false } + } + } else { + content() + } + } +} + @Composable fun ObserveSensitivityWarning( reason: String?, @@ -149,32 +156,6 @@ fun ObserveSensitivityWarning( } } -@Composable -fun ObserveSensitivityWarningOverBlurhash( - reason: String?, - blurhash: String?, - ratio: Float?, - description: String?, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() - - var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - - CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { - if (it) { - if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } - } else { - ContentWarningNote(reason) { showContentWarningNote = false } - } - } else { - content() - } - } -} - @Preview @Composable fun ContentWarningNotePreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 08f3ccc1c..1de17f9ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -150,7 +150,15 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + SensitivityWarningOverBlurhash( + isSensitive = content.isSensitive, + reason = content.contentWarning, + blurhash = content.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = Modifier @@ -164,7 +172,15 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + SensitivityWarningOverBlurhash( + isSensitive = content.isSensitive, + reason = content.contentWarning, + blurhash = content.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), contentAlignment = Alignment.Center, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 825a9d9f4..f6ac70130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -39,12 +39,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -60,6 +61,8 @@ fun PictureDisplay( ) { val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() val images by remember(note) { @@ -74,6 +77,8 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = it.mimeType, ) }.toImmutableList(), @@ -85,53 +90,51 @@ fun PictureDisplay( if (first != null) { val title = event.title() - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column { - if (title != null) { - Text( - modifier = Modifier.padding(padding), - text = title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(StdVertSpacer) - } + Column { + if (title != null) { + Text( + modifier = Modifier.padding(padding), + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(StdVertSpacer) + } - if (images.size == 1) { + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { ZoomableContentView( - content = images.first(), + content = images[it], images = images, roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) - } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } } - - TranslatableRichTextViewer( - content = event.content, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.padding(padding), - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = uri, - accountViewModel = accountViewModel, - nav = nav, - ) } + + TranslatableRichTextViewer( + content = event.content, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.padding(padding), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = uri, + accountViewModel = accountViewModel, + nav = nav, + ) } } } 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 581ef978b..a5b73df9e 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 @@ -30,11 +30,12 @@ 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.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView 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.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable @@ -53,6 +54,8 @@ fun JustVideoDisplay( remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -63,6 +66,8 @@ fun JustVideoDisplay( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -74,18 +79,18 @@ fun JustVideoDisplay( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, - accountViewModel = accountViewModel, - ) - } + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 325e3313b..8cb831ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -39,15 +39,15 @@ 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.MediaUrlImage -import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -92,6 +92,8 @@ private fun PictureCardImage( accountViewModel: AccountViewModel, ) { val uri = note.toNostrUri() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() val images by remember(note) { @@ -106,6 +108,8 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = it.mimeType, ) }.toImmutableList(), @@ -113,34 +117,23 @@ private fun PictureCardImage( } if (images.isNotEmpty()) { - val first = images.first() - val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) - - SensitivityWarning( - note = note, - blurhash = first.blurhash, - ratio = ratio, - description = first.description, - accountViewModel = accountViewModel, - ) { - if (images.size == 1) { + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { ZoomableContentView( - content = first, + content = images[it], images = images, roundedCorner = false, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) - } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } } } } 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 18dbd921d..21cab732e 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 @@ -42,9 +42,7 @@ 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.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow @@ -52,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent import kotlin.text.ifEmpty @@ -104,6 +104,8 @@ private fun VideoCardImage( remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -114,6 +116,8 @@ private fun VideoCardImage( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -125,28 +129,20 @@ private fun VideoCardImage( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) - - SensitivityWarning( - note = note, - blurhash = imeta.blurhash, - ratio = ratio, - description = content.description, + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, - ) { - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + ) } @Composable 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 3b6dca98a..c117b8eba 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 @@ -43,13 +43,14 @@ 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.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow 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.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlin.text.ifEmpty @@ -103,6 +104,8 @@ private fun FileHeaderCardImage( val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() val mimeType = event.mimeType() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -113,6 +116,8 @@ private fun FileHeaderCardImage( blurhash = blurHash, dim = dimensions, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = mimeType, ) } else { @@ -124,20 +129,20 @@ private fun FileHeaderCardImage( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = mimeType, ) }, ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) } @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index d390a1834..5f9d30c06 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -51,6 +51,7 @@ open class MediaUrlImage( dim: DimensionTag? = null, uri: String? = null, val contentWarning: String? = null, + val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -62,11 +63,12 @@ class EncryptedMediaUrlImage( dim: DimensionTag? = null, uri: String? = null, contentWarning: String? = null, + isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, isSensitive, mimeType) @Immutable open class MediaUrlVideo( @@ -79,6 +81,7 @@ open class MediaUrlVideo( val authorName: String? = null, blurhash: String? = null, val contentWarning: String? = null, + val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -93,11 +96,12 @@ class EncryptedMediaUrlVideo( authorName: String? = null, blurhash: String? = null, contentWarning: String? = null, + isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, isSensitive, mimeType) @Immutable abstract class MediaPreloadedContent( From 00ab199e3215aaffab361ee9820423cf878051bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:36:04 +0000 Subject: [PATCH 3/6] fix(feeds): clip + preload content warning on cropped grid cells - Pass contentScale through to ContentWarningOverBlurhash so grid cells (ContentScale.Crop) use fillMaxSize instead of forcing the image's native aspect ratio, keeping the blurhash and overlay within the cell. - Clip the overlay to its bounds to stop the icon/button from drawing over neighboring cells or other components. - Preload the image through Coil while the warning is visible so the image is cached by the time the user taps "Show anyway". --- .../ui/components/SensitivityWarning.kt | 30 +++++++++++++++---- .../ui/components/ZoomableContentView.kt | 4 +++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 0375cfa1a..6a86c1fe2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -40,20 +40,25 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -112,6 +117,8 @@ fun SensitivityWarningOverBlurhash( blurhash: String?, ratio: Float?, description: String?, + contentScale: ContentScale, + preloadUrl: String?, accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { @@ -124,10 +131,19 @@ fun SensitivityWarningOverBlurhash( var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + if (showContentWarningNote && preloadUrl != null) { + val context = LocalContext.current + LaunchedEffect(preloadUrl) { + runCatching { + context.imageLoader.enqueue(ImageRequest.Builder(context).data(preloadUrl).build()) + } + } + } + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + ContentWarningOverBlurhash(reason, blurhash, ratio, description, contentScale) { showContentWarningNote = false } } else { ContentWarningNote(reason) { showContentWarningNote = false } } @@ -189,6 +205,7 @@ fun ContentWarningOverBlurhashPreview() { blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", ratio = 16f / 9f, description = null, + contentScale = ContentScale.FillWidth, onDismiss = {}, ) } @@ -200,16 +217,17 @@ fun ContentWarningOverBlurhash( blurhash: String, ratio: Float?, description: String?, + contentScale: ContentScale, onDismiss: () -> Unit, ) { val sizingModifier = - if (ratio != null) { - Modifier.fillMaxWidth().aspectRatio(ratio) - } else { - Modifier.fillMaxWidth() + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() } - Box(modifier = sizingModifier) { + Box(modifier = sizingModifier.clipToBounds()) { DisplayBlurHash( blurhash = blurhash, description = description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1de17f9ea..5602f9aa1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -157,6 +157,8 @@ fun ZoomableContentView( blurhash = content.blurhash, ratio = ratio, description = content.description, + contentScale = contentScale, + preloadUrl = content.url, accountViewModel = accountViewModel, ) { TwoSecondController(content) { controllerVisible -> @@ -179,6 +181,8 @@ fun ZoomableContentView( blurhash = content.blurhash, ratio = ratio, description = content.description, + contentScale = contentScale, + preloadUrl = null, accountViewModel = accountViewModel, ) { Box( From d34b5702a5dee9fd0d565779c77d3fbb088b4dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:59:39 +0000 Subject: [PATCH 4/6] feat(feeds): single grid-level content warning with distinct reasons Multi-image picture posts now show one warning covering the whole grid instead of one per image (which required tapping each individually). The warning backdrop is a grid of the images' blurhashes, and the overlay lists any distinct reasons collected from both the event's content-warning tag and per-imeta content-warning fragments as chips. Preloads every image in the grid while the warning is visible. --- .../ui/components/SensitivityWarning.kt | 243 +++++++++++++----- .../ui/components/ZoomableContentView.kt | 4 +- .../amethyst/ui/note/types/PictureDisplay.kt | 33 ++- .../loggedIn/pictures/PictureCardCompose.kt | 35 ++- 4 files changed, 228 insertions(+), 87 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 6a86c1fe2..2a6b2a491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -35,6 +36,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -60,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.imageLoader import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip92IMeta.imetas @Composable fun SensitivityWarning( @@ -113,7 +119,7 @@ fun SensitivityWarning( @Composable fun SensitivityWarningOverBlurhash( isSensitive: Boolean, - reason: String?, + reasons: Set, blurhash: String?, ratio: Float?, description: String?, @@ -143,9 +149,9 @@ fun SensitivityWarningOverBlurhash( CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description, contentScale) { showContentWarningNote = false } + ContentWarningOverBlurhash(reasons, blurhash, ratio, description, contentScale) { showContentWarningNote = false } } else { - ContentWarningNote(reason) { showContentWarningNote = false } + ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } } } else { content() @@ -153,6 +159,43 @@ fun SensitivityWarningOverBlurhash( } } +@Composable +fun SensitivityWarningOverBlurhashGrid( + isSensitive: Boolean, + reasons: Set, + media: List, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + if (!isSensitive || media.isEmpty()) { + content() + return + } + + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + if (showContentWarningNote) { + val context = LocalContext.current + LaunchedEffect(media) { + media.forEach { item -> + runCatching { + context.imageLoader.enqueue(ImageRequest.Builder(context).data(item.url).build()) + } + } + } + } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + ContentWarningOverBlurhashGrid(reasons, media) { showContentWarningNote = false } + } else { + content() + } + } +} + @Composable fun ObserveSensitivityWarning( reason: String?, @@ -201,7 +244,22 @@ fun ContentWarningNoteWithBigReasonPreview() { fun ContentWarningOverBlurhashPreview() { ThemeComparisonColumn { ContentWarningOverBlurhash( - reason = "Spoilers", + reasons = setOf("Spoilers"), + blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + ratio = 16f / 9f, + description = null, + contentScale = ContentScale.FillWidth, + onDismiss = {}, + ) + } +} + +@Preview +@Composable +fun ContentWarningOverBlurhashMultiReasonPreview() { + ThemeComparisonColumn { + ContentWarningOverBlurhash( + reasons = setOf("Spoilers", "Violence", "Nudity"), blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", ratio = 16f / 9f, description = null, @@ -213,7 +271,7 @@ fun ContentWarningOverBlurhashPreview() { @Composable fun ContentWarningOverBlurhash( - reason: String?, + reasons: Set, blurhash: String, ratio: Float?, description: String?, @@ -234,68 +292,119 @@ fun ContentWarningOverBlurhash( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) + ContentWarningOverlayBody(reasons, onDismiss) + } +} - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier - .height(80.dp) - .width(90.dp), - ) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringRes(R.string.content_warning), - modifier = - Modifier - .size(70.dp) - .align(Alignment.BottomStart), - tint = Color.White, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringRes(R.string.content_warning), - modifier = - Modifier - .size(30.dp) - .align(Alignment.TopEnd), - tint = Color.White, - ) - } - - Text( - text = - if (reason.isNullOrBlank()) { - stringRes(R.string.content_warning) - } else { - stringRes(R.string.content_warning_with_reason, reason) - }, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - color = Color.White, - softWrap = true, - textAlign = TextAlign.Center, +@Composable +fun ContentWarningOverBlurhashGrid( + reasons: Set, + media: List, + onDismiss: () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f).clipToBounds()) { + AutoNonlazyGrid(media.size) { idx -> + val item = media[idx] + if (item.blurhash != null) { + DisplayBlurHash( + blurhash = item.blurhash, + description = item.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), ) + } + } + ContentWarningOverlayBody(reasons, onDismiss) + } +} - FilledTonalButton( - modifier = Modifier.padding(top = 10.dp), - onClick = onDismiss, - shape = ButtonBorder, - contentPadding = ButtonPadding, +@Composable +private fun ContentWarningOverlayBody( + reasons: Set, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(80.dp) + .width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(70.dp) + .align(Alignment.BottomStart), + tint = Color.White, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(30.dp) + .align(Alignment.TopEnd), + tint = Color.White, + ) + } + + Text( + text = stringRes(R.string.content_warning), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + softWrap = true, + textAlign = TextAlign.Center, + ) + + if (reasons.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = stringRes(R.string.show_anyway), - ) + reasons.forEach { reason -> + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = reason, + color = Color.White, + ) + }, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = Color.White.copy(alpha = 0.15f), + disabledLabelColor = Color.White, + ), + border = null, + ) + } } } + + FilledTonalButton( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + contentPadding = ButtonPadding, + ) { + Text( + text = stringRes(R.string.show_anyway), + ) + } } } } @@ -377,3 +486,15 @@ fun ContentWarningNote( } } } + +fun collectPictureReasons(event: Event): Set { + val reasons = linkedSetOf() + event.contentWarningReason()?.takeIf { it.isNotBlank() }?.let { reasons.add(it) } + event.imetas().forEach { iMeta -> + iMeta.properties[ContentWarningTag.TAG_NAME] + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { reasons.add(it) } + } + return reasons +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 5602f9aa1..e101247aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -153,7 +153,7 @@ fun ZoomableContentView( val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) SensitivityWarningOverBlurhash( isSensitive = content.isSensitive, - reason = content.contentWarning, + reasons = setOfNotNull(content.contentWarning), blurhash = content.blurhash, ratio = ratio, description = content.description, @@ -177,7 +177,7 @@ fun ZoomableContentView( val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) SensitivityWarningOverBlurhash( isSensitive = content.isSensitive, - reason = content.contentWarning, + reasons = setOfNotNull(content.contentWarning), blurhash = content.blurhash, ratio = ratio, description = content.description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index f6ac70130..f08939a36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -39,12 +39,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectPictureReasons import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -62,7 +63,8 @@ fun PictureDisplay( val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() + val reasons = collectPictureReasons(event) + val inGrid = event.imetaTags().size > 1 val images by remember(note) { @@ -77,8 +79,8 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, + contentWarning = reasons.firstOrNull(), + isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -112,14 +114,21 @@ fun PictureDisplay( accountViewModel = accountViewModel, ) } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + SensitivityWarningOverBlurhashGrid( + isSensitive = isSensitive, + reasons = reasons, + media = images, + accountViewModel = accountViewModel, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 8cb831ca8..23031d6bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -41,12 +41,13 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectPictureReasons import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -93,7 +94,8 @@ private fun PictureCardImage( ) { val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() + val reasons = collectPictureReasons(event) + val inGrid = event.imetaTags().size > 1 val images by remember(note) { @@ -108,8 +110,10 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, + contentWarning = reasons.firstOrNull(), + // In grid mode the warning is handled at the grid level to avoid + // one overlay per image. Single-image posts still warn per media. + isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -126,14 +130,21 @@ private fun PictureCardImage( accountViewModel = accountViewModel, ) } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + SensitivityWarningOverBlurhashGrid( + isSensitive = isSensitive, + reasons = reasons, + media = images, + accountViewModel = accountViewModel, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } From 60a56440d121d7ed8805f62e2afcdc8e21683766 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:58:36 +0000 Subject: [PATCH 5/6] refactor(feeds): unify content warning behind ContentWarningGate Collapse the per-media, per-blurhash and grid-of-blurhash variants into a single ContentWarningGate that takes a backdrop slot and a sizing modifier. Callers choose the backdrop (single blurhash, grid of blurhashes, or none). Remove the isSensitive field from MediaUrl{Image,Video}; it was a rendering concern leaked into a media model. Sensitivity is now only expressed to the gate. Picture/Video feeds and their display variants compute isSensitive + reasons themselves and wrap with the gate. ZoomableContentView keeps a default internal gate (fires when content.contentWarning != null) so the 14 callers that don't need grid-level handling still get the blurhash overlay automatically. Also rename collectPictureReasons to collectContentWarningReasons. --- .../ui/components/SensitivityWarning.kt | 163 +++++------------- .../ui/components/ZoomableContentView.kt | 24 ++- .../amethyst/ui/note/types/PictureDisplay.kt | 47 +++-- .../amethyst/ui/note/types/VideoDisplay.kt | 37 ++-- .../loggedIn/pictures/PictureCardCompose.kt | 67 ++++--- .../loggedIn/shorts/VideoCardCompose.kt | 37 ++-- .../loggedIn/video/FileHeaderCardCompose.kt | 42 +++-- .../commons/richtext/MediaContentModels.kt | 8 +- 8 files changed, 202 insertions(+), 223 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 2a6b2a491..85e57f6d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -117,15 +117,13 @@ fun SensitivityWarning( } @Composable -fun SensitivityWarningOverBlurhash( +fun ContentWarningGate( isSensitive: Boolean, reasons: Set, - blurhash: String?, - ratio: Float?, - description: String?, - contentScale: ContentScale, - preloadUrl: String?, + preloadUrls: List, accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.fillMaxWidth(), + backdrop: (@Composable () -> Unit)? = null, content: @Composable () -> Unit, ) { if (!isSensitive) { @@ -137,51 +135,12 @@ fun SensitivityWarningOverBlurhash( var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - if (showContentWarningNote && preloadUrl != null) { + if (showContentWarningNote && preloadUrls.isNotEmpty()) { val context = LocalContext.current - LaunchedEffect(preloadUrl) { - runCatching { - context.imageLoader.enqueue(ImageRequest.Builder(context).data(preloadUrl).build()) - } - } - } - - CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { - if (it) { - if (blurhash != null) { - ContentWarningOverBlurhash(reasons, blurhash, ratio, description, contentScale) { showContentWarningNote = false } - } else { - ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } - } - } else { - content() - } - } -} - -@Composable -fun SensitivityWarningOverBlurhashGrid( - isSensitive: Boolean, - reasons: Set, - media: List, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - if (!isSensitive || media.isEmpty()) { - content() - return - } - - val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() - - var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - - if (showContentWarningNote) { - val context = LocalContext.current - LaunchedEffect(media) { - media.forEach { item -> + LaunchedEffect(preloadUrls) { + preloadUrls.forEach { url -> runCatching { - context.imageLoader.enqueue(ImageRequest.Builder(context).data(item.url).build()) + context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).build()) } } } @@ -189,7 +148,14 @@ fun SensitivityWarningOverBlurhashGrid( CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { - ContentWarningOverBlurhashGrid(reasons, media) { showContentWarningNote = false } + if (backdrop != null) { + Box(modifier = modifier.clipToBounds()) { + backdrop() + ContentWarningOverlayBody(reasons) { showContentWarningNote = false } + } + } else { + ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } + } } else { content() } @@ -239,85 +205,44 @@ fun ContentWarningNoteWithBigReasonPreview() { } } -@Preview @Composable -fun ContentWarningOverBlurhashPreview() { - ThemeComparisonColumn { - ContentWarningOverBlurhash( - reasons = setOf("Spoilers"), - blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", - ratio = 16f / 9f, - description = null, - contentScale = ContentScale.FillWidth, - onDismiss = {}, - ) - } -} - -@Preview -@Composable -fun ContentWarningOverBlurhashMultiReasonPreview() { - ThemeComparisonColumn { - ContentWarningOverBlurhash( - reasons = setOf("Spoilers", "Violence", "Nudity"), - blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", - ratio = 16f / 9f, - description = null, - contentScale = ContentScale.FillWidth, - onDismiss = {}, - ) - } -} - -@Composable -fun ContentWarningOverBlurhash( - reasons: Set, +fun BlurhashBackdrop( blurhash: String, - ratio: Float?, description: String?, - contentScale: ContentScale, - onDismiss: () -> Unit, ) { - val sizingModifier = - when { - contentScale == ContentScale.Crop -> Modifier.fillMaxSize() - ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) - else -> Modifier.fillMaxWidth() - } - - Box(modifier = sizingModifier.clipToBounds()) { - DisplayBlurHash( - blurhash = blurhash, - description = description, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - ContentWarningOverlayBody(reasons, onDismiss) - } + DisplayBlurHash( + blurhash = blurhash, + description = description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } @Composable -fun ContentWarningOverBlurhashGrid( - reasons: Set, - media: List, - onDismiss: () -> Unit, -) { - Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f).clipToBounds()) { - AutoNonlazyGrid(media.size) { idx -> - val item = media[idx] - if (item.blurhash != null) { - DisplayBlurHash( - blurhash = item.blurhash, - description = item.description, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } +fun BlurhashGridBackdrop(media: List) { + AutoNonlazyGrid(media.size) { idx -> + val item = media[idx] + if (item.blurhash != null) { + DisplayBlurHash( + blurhash = item.blurhash, + description = item.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } - ContentWarningOverlayBody(reasons, onDismiss) } } +fun mediaSizingModifier( + ratio: Float?, + contentScale: ContentScale, +): Modifier = + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() + } + @Composable private fun ContentWarningOverlayBody( reasons: Set, @@ -487,7 +412,7 @@ fun ContentWarningNote( } } -fun collectPictureReasons(event: Event): Set { +fun collectContentWarningReasons(event: Event): Set { val reasons = linkedSetOf() event.contentWarningReason()?.takeIf { it.isNotBlank() }?.let { reasons.add(it) } event.imetas().forEach { iMeta -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e101247aa..a90440d19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -151,15 +151,13 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) - SensitivityWarningOverBlurhash( - isSensitive = content.isSensitive, + ContentWarningGate( + isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), - blurhash = content.blurhash, - ratio = ratio, - description = content.description, - contentScale = contentScale, - preloadUrl = content.url, + preloadUrls = listOf(content.url), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = @@ -175,15 +173,13 @@ fun ZoomableContentView( is MediaUrlVideo -> { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) - SensitivityWarningOverBlurhash( - isSensitive = content.isSensitive, + ContentWarningGate( + isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), - blurhash = content.blurhash, - ratio = ratio, - description = content.description, - contentScale = contentScale, - preloadUrl = null, + preloadUrls = emptyList(), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index f08939a36..466764b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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 @@ -37,12 +39,16 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.collectPictureReasons +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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -63,8 +69,7 @@ fun PictureDisplay( val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectPictureReasons(event) - val inGrid = event.imetaTags().size > 1 + val reasons = collectContentWarningReasons(event) val images by remember(note) { @@ -79,8 +84,6 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = reasons.firstOrNull(), - isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -106,19 +109,31 @@ fun PictureDisplay( } if (images.size == 1) { - ZoomableContentView( - content = images.first(), - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } else { - SensitivityWarningOverBlurhashGrid( + val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) + ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - media = images, + preloadUrls = listOf(first.url), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } }, + ) { + ZoomableContentView( + content = first, + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, ) { AutoNonlazyGrid(images.size) { ZoomableContentView( 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 a5b73df9e..50041196f 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 @@ -29,12 +29,16 @@ 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 +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier 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.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -49,13 +53,13 @@ fun JustVideoDisplay( val event = (videoEvent as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -66,8 +70,6 @@ fun JustVideoDisplay( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -79,18 +81,27 @@ fun JustVideoDisplay( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 23031d6bc..d6c210eda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -39,11 +40,15 @@ 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.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.collectPictureReasons +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.screen.loggedIn.AccountViewModel @@ -94,8 +99,7 @@ private fun PictureCardImage( ) { val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectPictureReasons(event) - val inGrid = event.imetaTags().size > 1 + val reasons = collectContentWarningReasons(event) val images by remember(note) { @@ -110,41 +114,50 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = reasons.firstOrNull(), - // In grid mode the warning is handled at the grid level to avoid - // one overlay per image. Single-image posts still warn per media. - isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), ) } - if (images.isNotEmpty()) { - if (images.size == 1) { + if (images.isEmpty()) return + + if (images.size == 1) { + val single = images.first() + val ratio = single.dim?.aspectRatio() ?: MediaAspectRatioCache.get(single.url) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = listOf(single.url), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } }, + ) { ZoomableContentView( - content = images.first(), + content = single, images = images, roundedCorner = false, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) - } else { - SensitivityWarningOverBlurhashGrid( - isSensitive = isSensitive, - reasons = reasons, - media = images, - accountViewModel = accountViewModel, - ) { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } 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 21cab732e..5b0cace57 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 @@ -42,15 +42,19 @@ 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 +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent import kotlin.text.ifEmpty @@ -99,13 +103,13 @@ private fun VideoCardImage( val event = (event as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -116,8 +120,6 @@ private fun VideoCardImage( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -129,20 +131,29 @@ private fun VideoCardImage( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } @Composable 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 c117b8eba..8d90d907d 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 @@ -42,14 +42,18 @@ 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 +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +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.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlin.text.ifEmpty @@ -96,16 +100,17 @@ private fun FileHeaderCardImage( ) { val fullUrl = event.url() ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) + val blurHash = event.blurhash() + val dimensions = event.dimensions() + val content by remember(note) { - val blurHash = event.blurhash() 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() - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -116,8 +121,6 @@ private fun FileHeaderCardImage( blurhash = blurHash, dim = dimensions, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = mimeType, ) } else { @@ -129,20 +132,29 @@ private fun FileHeaderCardImage( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, + val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(fullUrl) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 5f9d30c06..d390a1834 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -51,7 +51,6 @@ open class MediaUrlImage( dim: DimensionTag? = null, uri: String? = null, val contentWarning: String? = null, - val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -63,12 +62,11 @@ class EncryptedMediaUrlImage( dim: DimensionTag? = null, uri: String? = null, contentWarning: String? = null, - isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, isSensitive, mimeType) +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) @Immutable open class MediaUrlVideo( @@ -81,7 +79,6 @@ open class MediaUrlVideo( val authorName: String? = null, blurhash: String? = null, val contentWarning: String? = null, - val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -96,12 +93,11 @@ class EncryptedMediaUrlVideo( authorName: String? = null, blurhash: String? = null, contentWarning: String? = null, - isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, isSensitive, mimeType) +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) @Immutable abstract class MediaPreloadedContent( From dcaa6607ea0ef7e6d343e23d133f778271892258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:47:20 +0000 Subject: [PATCH 6/6] perf(feeds): remember isSensitive and content warning reasons per note These derivations iterate every tag and parse every iMeta, so running them on each recomposition churns through feed scrolls. Cache them in remember(note) alongside the other per-note derivations. --- .../amethyst/ui/note/types/PictureDisplay.kt | 6 +++--- .../amethyst/ui/note/types/VideoDisplay.kt | 6 +++--- .../ui/screen/loggedIn/pictures/PictureCardCompose.kt | 6 +++--- .../ui/screen/loggedIn/shorts/VideoCardCompose.kt | 6 +++--- .../ui/screen/loggedIn/video/FileHeaderCardCompose.kt | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 466764b6a..6730e72c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -67,9 +67,9 @@ fun PictureDisplay( nav: INav, ) { val event = (note.event as? PictureEvent) ?: return - val uri = note.toNostrUri() - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images by remember(note) { 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 50041196f..63cb577eb 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 @@ -53,9 +53,9 @@ fun JustVideoDisplay( val event = (videoEvent as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + 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) } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index d6c210eda..d7aa97489 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -97,9 +97,9 @@ private fun PictureCardImage( backgroundColor: MutableState, accountViewModel: AccountViewModel, ) { - val uri = note.toNostrUri() - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images 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 5b0cace57..11385dd60 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 @@ -103,9 +103,9 @@ private fun VideoCardImage( val event = (event as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + 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) } 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 8d90d907d..2efc3243a 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 @@ -100,11 +100,11 @@ private fun FileHeaderCardImage( ) { val fullUrl = event.url() ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val blurHash = event.blurhash() - val dimensions = event.dimensions() + 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 blurHash = remember(note) { event.blurhash() } + val dimensions = remember(note) { event.dimensions() } val content by remember(note) { val hash = event.hash()