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.
This commit is contained in:
Claude
2026-04-18 22:59:39 +00:00
parent 00ab199e32
commit d34b5702a5
4 changed files with 228 additions and 87 deletions
@@ -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<String>,
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<String>,
media: List<MediaUrlImage>,
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<String>,
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<String>,
media: List<MediaUrlImage>,
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<String>,
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<String> {
val reasons = linkedSetOf<String>()
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
}
@@ -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,
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
}