Merge pull request #2447 from vitorpamplona/claude/content-warning-media-overlay-zjdoP

Refactor content warning UI with backdrop support and image preloading
This commit is contained in:
Vitor Pamplona
2026-04-19 10:56:56 -04:00
committed by GitHub
7 changed files with 382 additions and 61 deletions
@@ -20,10 +20,14 @@
*/ */
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.FlowRow
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
@@ -32,25 +36,34 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.rounded.Warning 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.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.imageLoader
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -60,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip92IMeta.imetas
@Composable @Composable
fun SensitivityWarning( fun SensitivityWarning(
@@ -101,6 +116,52 @@ fun SensitivityWarning(
} }
} }
@Composable
fun ContentWarningGate(
isSensitive: Boolean,
reasons: Set<String>,
preloadUrls: List<String>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.fillMaxWidth(),
backdrop: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit,
) {
if (!isSensitive) {
content()
return
}
val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
if (showContentWarningNote && preloadUrls.isNotEmpty()) {
val context = LocalContext.current
LaunchedEffect(preloadUrls) {
preloadUrls.forEach { url ->
runCatching {
context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).build())
}
}
}
}
CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) {
if (it) {
if (backdrop != null) {
Box(modifier = modifier.clipToBounds()) {
backdrop()
ContentWarningOverlayBody(reasons) { showContentWarningNote = false }
}
} else {
ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false }
}
} else {
content()
}
}
}
@Composable @Composable
fun ObserveSensitivityWarning( fun ObserveSensitivityWarning(
reason: String?, reason: String?,
@@ -144,6 +205,135 @@ fun ContentWarningNoteWithBigReasonPreview() {
} }
} }
@Composable
fun BlurhashBackdrop(
blurhash: String,
description: String?,
) {
DisplayBlurHash(
blurhash = blurhash,
description = description,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
@Composable
fun BlurhashGridBackdrop(media: List<MediaUrlImage>) {
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(),
)
}
}
}
fun mediaSizingModifier(
ratio: Float?,
contentScale: ContentScale,
): Modifier =
when {
contentScale == ContentScale.Crop -> Modifier.fillMaxSize()
ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio)
else -> Modifier.fillMaxWidth()
}
@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),
) {
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),
)
}
}
}
}
@Composable @Composable
fun ContentWarningNote( fun ContentWarningNote(
reason: String?, reason: String?,
@@ -221,3 +411,15 @@ fun ContentWarningNote(
} }
} }
} }
fun collectContentWarningReasons(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
}
@@ -150,7 +150,15 @@ fun ZoomableContentView(
when (content) { when (content) {
is MediaUrlImage -> { is MediaUrlImage -> {
SensitivityWarning(content.contentWarning, accountViewModel) { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(content.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
) {
TwoSecondController(content) { controllerVisible -> TwoSecondController(content) { controllerVisible ->
val mainImageModifier = val mainImageModifier =
Modifier Modifier
@@ -164,7 +172,15 @@ fun ZoomableContentView(
} }
is MediaUrlVideo -> { is MediaUrlVideo -> {
SensitivityWarning(content.contentWarning, accountViewModel) { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
) {
Box( Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,14 +39,20 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.EmptyTagList
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.BlurhashBackdrop
import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop
import com.vitorpamplona.amethyst.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -59,7 +67,9 @@ fun PictureDisplay(
nav: INav, nav: INav,
) { ) {
val event = (note.event as? PictureEvent) ?: return val event = (note.event as? PictureEvent) ?: return
val uri = note.toNostrUri() val uri = remember(note) { note.toNostrUri() }
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val images by val images by
remember(note) { remember(note) {
@@ -85,29 +95,46 @@ fun PictureDisplay(
if (first != null) { if (first != null) {
val title = event.title() val title = event.title()
SensitivityWarning(note = note, accountViewModel = accountViewModel) { Column {
Column { if (title != null) {
if (title != null) { Text(
Text( modifier = Modifier.padding(padding),
modifier = Modifier.padding(padding), text = title,
text = title, style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.bodyLarge, maxLines = 1,
maxLines = 1, overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.Ellipsis, )
) } else {
} else { Spacer(StdVertSpacer)
Spacer(StdVertSpacer) }
}
if (images.size == 1) { if (images.size == 1) {
val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url)
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = listOf(first.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } },
) {
ZoomableContentView( ZoomableContentView(
content = images.first(), content = first,
images = images, images = images,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} else { }
} else {
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = images.map { it.url },
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
backdrop = { BlurhashGridBackdrop(images) },
) {
AutoNonlazyGrid(images.size) { AutoNonlazyGrid(images.size) {
ZoomableContentView( ZoomableContentView(
content = images[it], content = images[it],
@@ -118,20 +145,20 @@ fun PictureDisplay(
) )
} }
} }
TranslatableRichTextViewer(
content = event.content,
canPreview = false,
quotesLeft = 0,
modifier = Modifier.padding(padding),
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
} }
TranslatableRichTextViewer(
content = event.content,
canPreview = false,
quotesLeft = 0,
modifier = Modifier.padding(padding),
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
} }
} }
} }
@@ -29,12 +29,17 @@ 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.BlurhashBackdrop
import com.vitorpamplona.amethyst.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent
@Composable @Composable
@@ -48,11 +53,13 @@ fun JustVideoDisplay(
val event = (videoEvent as? Event) ?: return val event = (videoEvent as? Event) ?: return
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
val content by val content by
remember(note) { remember(note) {
val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
mutableStateOf<BaseMediaContent>( mutableStateOf<BaseMediaContent>(
if (isImage) { if (isImage) {
@@ -80,7 +87,16 @@ fun JustVideoDisplay(
) )
} }
SensitivityWarning(note = note, accountViewModel = accountViewModel) { val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url)
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
) {
ZoomableContentView( ZoomableContentView(
content = content, content = content,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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
@@ -39,14 +40,20 @@ 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.BlurhashBackdrop
import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop
import com.vitorpamplona.amethyst.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -90,7 +97,9 @@ private fun PictureCardImage(
backgroundColor: MutableState<Color>, backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val uri = note.toNostrUri() val uri = remember(note) { note.toNostrUri() }
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val images by val images by
remember(note) { remember(note) {
@@ -111,26 +120,44 @@ private fun PictureCardImage(
) )
} }
if (images.isNotEmpty()) { if (images.isEmpty()) return
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
if (images.size == 1) { if (images.size == 1) {
val single = images.first()
val ratio = single.dim?.aspectRatio() ?: MediaAspectRatioCache.get(single.url)
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = listOf(single.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } },
) {
ZoomableContentView(
content = single,
images = images,
roundedCorner = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
} else {
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = images.map { it.url },
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
backdrop = { BlurhashGridBackdrop(images) },
) {
AutoNonlazyGrid(images.size) {
ZoomableContentView( ZoomableContentView(
content = images.first(), content = images[it],
images = images, images = images,
roundedCorner = false, roundedCorner = false,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.Crop,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} else {
AutoNonlazyGrid(images.size) {
ZoomableContentView(
content = images[it],
images = images,
roundedCorner = false,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
} }
} }
} }
@@ -42,15 +42,20 @@ 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.BlurhashBackdrop
import com.vitorpamplona.amethyst.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent
import kotlin.text.ifEmpty import kotlin.text.ifEmpty
@@ -98,11 +103,13 @@ private fun VideoCardImage(
val event = (event as? Event) ?: return val event = (event as? Event) ?: return
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
val content by val content by
remember(note) { remember(note) {
val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
mutableStateOf<BaseMediaContent>( mutableStateOf<BaseMediaContent>(
if (isImage) { if (isImage) {
@@ -130,7 +137,16 @@ private fun VideoCardImage(
) )
} }
SensitivityWarning(note = note, accountViewModel = accountViewModel) { val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url)
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
) {
ZoomableContentView( ZoomableContentView(
content = content, content = content,
roundedCorner = false, roundedCorner = false,
@@ -42,14 +42,19 @@ 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.BlurhashBackdrop
import com.vitorpamplona.amethyst.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import kotlin.text.ifEmpty import kotlin.text.ifEmpty
@@ -95,12 +100,15 @@ private fun FileHeaderCardImage(
) { ) {
val fullUrl = event.url() ?: return val fullUrl = event.url() ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
val blurHash = remember(note) { event.blurhash() }
val dimensions = remember(note) { event.dimensions() }
val content by remember(note) { val content by remember(note) {
val blurHash = event.blurhash()
val hash = event.hash() val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt() val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri() val uri = note.toNostrUri()
val mimeType = event.mimeType() val mimeType = event.mimeType()
@@ -130,7 +138,16 @@ private fun FileHeaderCardImage(
) )
} }
SensitivityWarning(note = note, accountViewModel = accountViewModel) { val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl)
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = if (isImage) listOf(fullUrl) else emptyList(),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } },
) {
ZoomableContentView( ZoomableContentView(
content = content, content = content,
roundedCorner = false, roundedCorner = false,