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 package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -44,6 +47,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -72,6 +76,31 @@ fun SensitivityWarning(
note.event?.let { SensitivityWarning(it, accountViewModel, content) } 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 @Composable
fun SensitivityWarning( fun SensitivityWarning(
event: Event, 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 @Preview
@Composable @Composable
fun ContentWarningNotePreview() { 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 @Composable
fun ContentWarningNote( fun ContentWarningNote(
reason: String?, reason: String?,
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@@ -112,10 +113,19 @@ private fun PictureCardImage(
} }
if (images.isNotEmpty()) { 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) { if (images.size == 1) {
ZoomableContentView( ZoomableContentView(
content = images.first(), content = first,
images = images, images = images,
roundedCorner = false, roundedCorner = false,
contentScale = ContentScale.FillWidth, 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.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView 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( ZoomableContentView(
content = content, content = content,
roundedCorner = false, roundedCorner = false,