feat(desktop): blur NIP-36 content-warning notes with tap-to-reveal

Generalize SpamCheckedNoteRender to also gate sensitive content: a note tagged
content-warning / NSFW renders behind a collapsed scrim (reason + 'Show') unless
the account opted into 'always show sensitive content'. Reuses the same
per-note rememberSaveable reveal + forceReveal as the spam collapse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-23 10:42:44 +03:00
co-authored by Claude Opus 4.8
parent d571906dd0
commit f4435bfe42
@@ -20,6 +20,15 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -27,6 +36,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
@@ -34,8 +46,12 @@ import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.displayedEvent
import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Wraps a note render in the hashtag-spam check.
@@ -80,24 +96,73 @@ fun SpamCheckedNoteRender(
)
}
// NIP-36 content warning: blur sensitive content unless the account opted
// into "always show sensitive content".
val account = LocalDesktopIAccount.current
val showSensitiveFlow = remember(account) { account?.showSensitiveContentSetting ?: MutableStateFlow(null) }
val showSensitive by showSensitiveFlow.collectAsState()
val isSensitive = remember(noteIdHex, displayedEvent) { displayedEvent?.isSensitiveOrNSFW() == true }
var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) }
if (!forceReveal && isSpam && !revealed && displayedEvent != null) {
val authorPubkey = displayedEvent.pubKey
val author = localCache?.getUserIfExists(authorPubkey)
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
val avatarUrl = author?.profilePicture()
val hashtagCount = displayedEvent.tags.countHashtags()
CollapsedSpamNote(
authorPubkeyHex = authorPubkey,
authorDisplayName = displayName,
authorAvatarUrl = avatarUrl,
hashtagCount = hashtagCount,
threshold = threshold,
onReveal = { revealed = true },
)
} else {
normal()
when {
forceReveal || revealed -> normal()
isSpam && displayedEvent != null -> {
val authorPubkey = displayedEvent.pubKey
val author = localCache?.getUserIfExists(authorPubkey)
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
val avatarUrl = author?.profilePicture()
val hashtagCount = displayedEvent.tags.countHashtags()
CollapsedSpamNote(
authorPubkeyHex = authorPubkey,
authorDisplayName = displayName,
authorAvatarUrl = avatarUrl,
hashtagCount = hashtagCount,
threshold = threshold,
onReveal = { revealed = true },
)
}
isSensitive && showSensitive != true && displayedEvent != null -> {
CollapsedContentWarning(
reason = displayedEvent.contentWarningReason(),
onReveal = { revealed = true },
)
}
else -> normal()
}
}
/**
* Collapsed placeholder for a NIP-36 content-warning note. Mirrors the
* spam-collapse reveal affordance: the body stays hidden behind a scrim with the
* warning reason until the user taps "Show".
*/
@Composable
private fun CollapsedContentWarning(
reason: String?,
onReveal: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Sensitive content", style = MaterialTheme.typography.titleSmall)
if (!reason.isNullOrBlank()) {
Text(
reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onReveal) { Text("Show") }
}
}
}