refactor(feeds): push content warning into ZoomableContentView per media

Move the blurhash-aware sensitivity warning from feed card wrappers into
ZoomableContentView so every media item shows its own warning sized to
its own aspect ratio over its own blurhash. Grid posts now warn per
image instead of once over the whole grid.

- Add isSensitive to MediaUrl{Image,Video} (defaults to contentWarning != null)
- Populate isSensitive and contentWarning when building media in the
  picture, video, and file-header feeds plus their display variants
- Drop the outer SensitivityWarning wrappers now handled inside
  ZoomableContentView via SensitivityWarningOverBlurhash
This commit is contained in:
Claude
2026-04-18 18:51:17 +00:00
parent 75c96115cb
commit 1166654149
8 changed files with 158 additions and 155 deletions
@@ -76,31 +76,6 @@ 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,
@@ -130,6 +105,38 @@ fun SensitivityWarning(
}
}
@Composable
fun SensitivityWarningOverBlurhash(
isSensitive: Boolean,
reason: String?,
blurhash: String?,
ratio: Float?,
description: String?,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
if (!isSensitive) {
content()
return
}
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()
}
}
}
@Composable
fun ObserveSensitivityWarning(
reason: String?,
@@ -149,32 +156,6 @@ 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() {
@@ -150,7 +150,15 @@ fun ZoomableContentView(
when (content) {
is MediaUrlImage -> {
SensitivityWarning(content.contentWarning, accountViewModel) {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
SensitivityWarningOverBlurhash(
isSensitive = content.isSensitive,
reason = content.contentWarning,
blurhash = content.blurhash,
ratio = ratio,
description = content.description,
accountViewModel = accountViewModel,
) {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
Modifier
@@ -164,7 +172,15 @@ fun ZoomableContentView(
}
is MediaUrlVideo -> {
SensitivityWarning(content.contentWarning, accountViewModel) {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
SensitivityWarningOverBlurhash(
isSensitive = content.isSensitive,
reason = content.contentWarning,
blurhash = content.blurhash,
ratio = ratio,
description = content.description,
accountViewModel = accountViewModel,
) {
Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center,
@@ -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.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
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
@@ -60,6 +61,8 @@ fun PictureDisplay(
) {
val event = (note.event as? PictureEvent) ?: return
val uri = note.toNostrUri()
val isSensitive = event.isSensitiveOrNSFW()
val contentWarning = event.contentWarningReason()
val images by
remember(note) {
@@ -74,6 +77,8 @@ fun PictureDisplay(
blurhash = it.blurhash,
dim = it.dimension,
uri = uri,
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = it.mimeType,
)
}.toImmutableList(),
@@ -85,7 +90,6 @@ fun PictureDisplay(
if (first != null) {
val title = event.title()
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
Column {
if (title != null) {
Text(
@@ -134,4 +138,3 @@ fun PictureDisplay(
}
}
}
}
@@ -30,11 +30,12 @@ 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.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@Composable
@@ -53,6 +54,8 @@ fun JustVideoDisplay(
remember(note) {
val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
val isSensitive = event.isSensitiveOrNSFW()
val contentWarning = event.contentWarningReason()
mutableStateOf<BaseMediaContent>(
if (isImage) {
@@ -63,6 +66,8 @@ fun JustVideoDisplay(
blurhash = imeta.blurhash,
dim = imeta.dimension,
uri = note.toNostrUri(),
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = imeta.mimeType,
)
} else {
@@ -74,13 +79,14 @@ fun JustVideoDisplay(
dim = imeta.dimension,
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = imeta.mimeType,
)
},
)
}
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
@@ -88,4 +94,3 @@ fun JustVideoDisplay(
accountViewModel = accountViewModel,
)
}
}
@@ -39,15 +39,15 @@ 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
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
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
@@ -92,6 +92,8 @@ private fun PictureCardImage(
accountViewModel: AccountViewModel,
) {
val uri = note.toNostrUri()
val isSensitive = event.isSensitiveOrNSFW()
val contentWarning = event.contentWarningReason()
val images by
remember(note) {
@@ -106,6 +108,8 @@ private fun PictureCardImage(
blurhash = it.blurhash,
dim = it.dimension,
uri = uri,
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = it.mimeType,
)
}.toImmutableList(),
@@ -113,19 +117,9 @@ private fun PictureCardImage(
}
if (images.isNotEmpty()) {
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 = first,
content = images.first(),
images = images,
roundedCorner = false,
contentScale = ContentScale.FillWidth,
@@ -144,7 +138,6 @@ private fun PictureCardImage(
}
}
}
}
@Composable
internal fun PictureCardCaption(event: PictureEvent) {
@@ -42,9 +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
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
@@ -52,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import kotlin.text.ifEmpty
@@ -104,6 +104,8 @@ private fun VideoCardImage(
remember(note) {
val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
val isSensitive = event.isSensitiveOrNSFW()
val contentWarning = event.contentWarningReason()
mutableStateOf<BaseMediaContent>(
if (isImage) {
@@ -114,6 +116,8 @@ private fun VideoCardImage(
blurhash = imeta.blurhash,
dim = imeta.dimension,
uri = note.toNostrUri(),
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = imeta.mimeType,
)
} else {
@@ -125,21 +129,14 @@ private fun VideoCardImage(
dim = imeta.dimension,
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = imeta.mimeType,
)
},
)
}
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,
@@ -147,7 +144,6 @@ private fun VideoCardImage(
accountViewModel = accountViewModel,
)
}
}
@Composable
internal fun VideoCardCaption(videoEvent: VideoEvent) {
@@ -43,13 +43,14 @@ 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.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
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.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import kotlin.text.ifEmpty
@@ -103,6 +104,8 @@ private fun FileHeaderCardImage(
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
val isSensitive = event.isSensitiveOrNSFW()
val contentWarning = event.contentWarningReason()
mutableStateOf<BaseMediaContent>(
if (isImage) {
@@ -113,6 +116,8 @@ private fun FileHeaderCardImage(
blurhash = blurHash,
dim = dimensions,
uri = uri,
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = mimeType,
)
} else {
@@ -124,13 +129,14 @@ private fun FileHeaderCardImage(
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
contentWarning = contentWarning,
isSensitive = isSensitive,
mimeType = mimeType,
)
},
)
}
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = false,
@@ -138,7 +144,6 @@ private fun FileHeaderCardImage(
accountViewModel = accountViewModel,
)
}
}
@Composable
internal fun FileHeaderCardCaption(videoEvent: FileHeaderEvent) {
@@ -51,6 +51,7 @@ open class MediaUrlImage(
dim: DimensionTag? = null,
uri: String? = null,
val contentWarning: String? = null,
val isSensitive: Boolean = contentWarning != null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@@ -62,11 +63,12 @@ class EncryptedMediaUrlImage(
dim: DimensionTag? = null,
uri: String? = null,
contentWarning: String? = null,
isSensitive: Boolean = contentWarning != null,
mimeType: String? = null,
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, isSensitive, mimeType)
@Immutable
open class MediaUrlVideo(
@@ -79,6 +81,7 @@ open class MediaUrlVideo(
val authorName: String? = null,
blurhash: String? = null,
val contentWarning: String? = null,
val isSensitive: Boolean = contentWarning != null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@@ -93,11 +96,12 @@ class EncryptedMediaUrlVideo(
authorName: String? = null,
blurhash: String? = null,
contentWarning: String? = null,
isSensitive: Boolean = contentWarning != null,
mimeType: String? = null,
val encryptionAlgo: String,
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, isSensitive, mimeType)
@Immutable
abstract class MediaPreloadedContent(