From 75c96115cbb8945b957a70f02dbee1cd96f08fec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 15:53:41 +0000 Subject: [PATCH] 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,