mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
+182
-61
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user