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.
This commit is contained in:
Claude
2026-04-18 15:53:41 +00:00
parent 8c71d490a7
commit 75c96115cb
3 changed files with 179 additions and 3 deletions
@@ -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?,
@@ -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,
@@ -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,