Adds sensitive-content tags to NIP94 and NIP95 media

This commit is contained in:
Vitor Pamplona 2023-07-09 12:09:21 -04:00
parent 5c78135f1a
commit b520a0faed
16 changed files with 168 additions and 45 deletions

View File

@ -474,6 +474,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)
@ -503,6 +504,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)

View File

@ -16,15 +16,16 @@ class FileHeader(
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
val description: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
} else {
onError()
}
@ -39,6 +40,7 @@ class FileHeader(
fileUrl: String,
mimeType: String?,
description: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
@ -79,7 +81,7 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@ -50,6 +50,7 @@ class FileHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
@ -62,7 +63,14 @@ class FileHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""

View File

@ -50,6 +50,7 @@ class FileStorageHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileStorageHeaderEvent {
@ -62,7 +63,14 @@ class FileStorageHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""

View File

@ -26,6 +26,7 @@ open class NewMediaModel : ViewModel() {
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
@ -77,7 +78,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
}
?: run {
viewModelScope.launch {
@ -97,7 +98,7 @@ open class NewMediaModel : ViewModel() {
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
},
onError = {
isUploadingImage = false
@ -137,7 +138,7 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
@ -157,6 +158,7 @@ open class NewMediaModel : ViewModel() {
imageUrl,
mimeType,
description,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
@ -189,7 +191,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
@ -199,6 +201,7 @@ open class NewMediaModel : ViewModel() {
"",
mimeType,
description,
sensitiveContent,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f

View File

@ -203,6 +203,18 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@ -41,6 +42,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -52,11 +54,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -257,8 +261,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server ->
postViewModel.upload(url, description, server, context)
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context)
account.changeDefaultFileServer(server)
},
onCancel = {
@ -830,7 +834,7 @@ enum class ServersAvailable {
fun ImageVideoDescription(
uri: Uri,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit,
onAdd: (String, ServersAvailable, Boolean) -> Unit,
onCancel: () -> Unit,
onError: (String) -> Unit
) {
@ -859,6 +863,7 @@ fun ImageVideoDescription(
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
var sensitiveContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
@ -983,6 +988,18 @@ fun ImageVideoDescription(
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = sensitiveContent,
onCheckedChange = { sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
if (isNIP94Server(selectedServer) ||
selectedServer == ServersAvailable.NIP95
) {
@ -1017,7 +1034,7 @@ fun ImageVideoDescription(
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
onAdd(message, selectedServer)
onAdd(message, selectedServer, sensitiveContent)
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
@ -1036,3 +1053,54 @@ data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@Composable
fun SettingSwitchItem(
modifier: Modifier = Modifier,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
title: Int,
description: Int,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
onValueChange = onCheckedChange
),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled
Text(
text = stringResource(id = title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(contentAlpha)
)
Text(
text = stringResource(id = description),
style = MaterialTheme.typography.caption,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(contentAlpha)
)
}
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled
)
}
}

View File

@ -179,7 +179,7 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) {
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context) {
isUploadingImage = true
contentToAddUrl = null
@ -194,7 +194,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
}
} else {
ImageUploader.uploadImage(
@ -205,7 +205,7 @@ open class NewPostViewModel() : ViewModel() {
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
@ -370,13 +370,14 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
description,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it)
@ -400,13 +401,14 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
sensitiveContent,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -75,7 +76,8 @@ fun SensitivityWarning(
mutableStateOf(accountState?.account?.showSensitiveContent != true)
}
if (showContentWarningNote) {
Crossfade(targetState = showContentWarningNote) {
if (it) {
ContentWarningNote() {
showContentWarningNote = false
}
@ -83,6 +85,7 @@ fun SensitivityWarning(
content()
}
}
}
@Composable
fun ContentWarningNote(onDismiss: () -> Unit) {

View File

@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
@ -445,7 +446,7 @@ fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotific
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
color = MaterialTheme.colors.bitcoinColor,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)

View File

@ -447,8 +447,8 @@ fun NormalNote(
nav = nav
)
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
@ -2870,7 +2870,7 @@ private fun RenderBadge(
}
@Composable
fun FileHeaderDisplay(note: Note) {
fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
@ -2901,13 +2901,15 @@ fun FileHeaderDisplay(note: Note) {
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(content = it)
}
}
}
}
@Composable
fun FileStorageHeaderDisplay(baseNote: Note) {
fun FileStorageHeaderDisplay(baseNote: Note, accountViewModel: AccountViewModel) {
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
var fileNote by remember { mutableStateOf<Note?>(null) }
@ -2925,20 +2927,21 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
Crossfade(targetState = fileNote) {
if (it != null) {
RenderNIP95(it, eventHeader, baseNote)
RenderNIP95(it, eventHeader, baseNote, accountViewModel)
}
}
}
@Composable
private fun RenderNIP95(
it: Note,
content: Note,
eventHeader: FileStorageHeaderEvent,
baseNote: Note
header: Note,
accountViewModel: AccountViewModel
) {
val appContext = LocalContext.current.applicationContext
val noteState by it.live().metadata.observeAsState()
val noteState by content.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
var content by remember { mutableStateOf<ZoomableContent?>(null) }
@ -2946,7 +2949,7 @@ private fun RenderNIP95(
if (content == null) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
launch(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val uri = "nostr:" + header.toNEvent()
val localDir =
note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
@ -2984,10 +2987,12 @@ private fun RenderNIP95(
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
ZoomableContentView(content = it)
}
}
}
}
@Composable
fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewModel, nav: (String) -> Unit) {

View File

@ -399,9 +399,9 @@ fun NoteMaster(
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && note.channelHex() != null) {
ChannelHeader(channelHex = note.channelHex()!!, showVideo = true, showBottomDiviser = false, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote)
FileHeaderDisplay(baseNote, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote)
FileStorageHeaderDisplay(baseNote, accountViewModel)
} else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {

View File

@ -421,7 +421,7 @@ fun EditFieldRow(
accountViewModel.account.defaultFileServer
}
channelScreenModel.upload(it, "", fileServer, context)
channelScreenModel.upload(it, "", false, fileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(

View File

@ -259,16 +259,16 @@ private fun RenderVideoOrPictureNote(
Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) {
val noteEvent = remember { note.event }
if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
FileHeaderDisplay(note, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
FileStorageHeaderDisplay(note, accountViewModel)
}
}
}
Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) {
Column(remember { Modifier.weight(1f) }) {
RenderVideoOrPicture(note, nav, accountViewModel)
RenderAuthorInformation(note, nav, accountViewModel)
}
Column(
@ -287,7 +287,7 @@ private fun RenderVideoOrPictureNote(
}
@Composable
private fun RenderVideoOrPicture(
private fun RenderAuthorInformation(
note: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel

View File

@ -32,16 +32,19 @@ private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200,
secondaryVariant = Color(0xFFF7931A)
secondaryVariant = Purple200
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200,
secondaryVariant = Color(0xFFB66605)
secondaryVariant = Purple500
)
private val BitcoinDark = Color(0xFFF7931A)
private val BitcoinLight = Color(0xFFB66605)
private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f)
private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f)
@ -279,6 +282,9 @@ val Colors.hashVerified: Color
val Colors.overPictureBackground: Color
get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground
val Colors.bitcoinColor: Color
get() = if (isLight) BitcoinLight else BitcoinDark
val Colors.markdownStyle: RichTextStyle
get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark

View File

@ -480,4 +480,7 @@
<string name="groups_no_descriptor">This group does not have a description or rules. Talk to the owner to add one</string>
<string name="community_no_descriptor">This community does not have a description. Talk to the owner to add one</string>
<string name="add_sensitive_content_label">Sensitive Content</string>
<string name="add_sensitive_content_description">Adds sensitive content warning before showing this content</string>
</resources>