Merge pull request #1849 from vitorpamplona/claude/file-encryption-toggle-PwA8W
Add encrypted file upload with fallback option for NIP17 chats
This commit is contained in:
+88
-2
@@ -403,7 +403,16 @@ class ChatNewMessageViewModel :
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
if (nip17) {
|
||||
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
|
||||
ChatFileUploader(account).justUploadNIP17(
|
||||
uploadState,
|
||||
onError,
|
||||
onEncryptedUploadError = { title, message ->
|
||||
encryptedUploadErrorTitle = title
|
||||
encryptedUploadErrorMessage = message
|
||||
pendingRetryMode = RetryMode.HOLD
|
||||
},
|
||||
context,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
@@ -428,7 +437,19 @@ class ChatNewMessageViewModel :
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
if (nip17) {
|
||||
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) {
|
||||
ChatFileUploader(account).justUploadNIP17(
|
||||
uploadState,
|
||||
onError,
|
||||
onEncryptedUploadError = { title, message ->
|
||||
encryptedUploadErrorTitle = title
|
||||
encryptedUploadErrorMessage = message
|
||||
pendingRetryMode = RetryMode.SEND
|
||||
pendingRetryOnError = onError
|
||||
pendingRetryContext = context
|
||||
pendingRetryOnceUploaded = onceUploaded
|
||||
},
|
||||
context,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
@@ -443,6 +464,69 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted upload error state for retry dialog
|
||||
var encryptedUploadErrorTitle by mutableStateOf<String?>(null)
|
||||
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
|
||||
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
|
||||
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
|
||||
var pendingRetryContext by mutableStateOf<Context?>(null)
|
||||
var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null)
|
||||
|
||||
enum class RetryMode { HOLD, SEND }
|
||||
|
||||
fun dismissEncryptedUploadError() {
|
||||
encryptedUploadErrorTitle = null
|
||||
encryptedUploadErrorMessage = null
|
||||
pendingRetryMode = null
|
||||
pendingRetryOnError = null
|
||||
pendingRetryContext = null
|
||||
pendingRetryOnceUploaded = null
|
||||
}
|
||||
|
||||
fun retryWithoutEncryption() {
|
||||
val mode = pendingRetryMode ?: return
|
||||
val onError = pendingRetryOnError
|
||||
val context = pendingRetryContext
|
||||
val onceUploaded = pendingRetryOnceUploaded
|
||||
val room = room
|
||||
val uploadState = uploadState
|
||||
|
||||
dismissEncryptedUploadError()
|
||||
|
||||
if (uploadState == null || context == null) return
|
||||
|
||||
uploadState.encryptFiles = false
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
when (mode) {
|
||||
RetryMode.HOLD -> {
|
||||
ChatFileUploader(account).justUploadNIP17Unencrypted(
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
RetryMode.SEND -> {
|
||||
if (room == null) return@launchSigner
|
||||
ChatFileUploader(account).justUploadNIP17Unencrypted(
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(draftTag: String?) {
|
||||
val room = room ?: return
|
||||
|
||||
@@ -563,6 +647,8 @@ class ChatNewMessageViewModel :
|
||||
uploadsWaitingToBeSent = emptyList()
|
||||
uploadState?.reset()
|
||||
|
||||
dismissEncryptedUploadError()
|
||||
|
||||
iMetaAttachments.reset()
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
+9
@@ -302,6 +302,15 @@ fun GroupDMScreenContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
title = title,
|
||||
message = postViewModel.encryptedUploadErrorMessage ?: "",
|
||||
onDismiss = postViewModel::dismissEncryptedUploadError,
|
||||
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
@@ -27,8 +27,10 @@ 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.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -116,6 +118,15 @@ fun PrivateMessageEditFieldRow(
|
||||
}
|
||||
}
|
||||
|
||||
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
title = title,
|
||||
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
|
||||
onDismiss = channelScreenModel::dismissEncryptedUploadError,
|
||||
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = EditFieldModifier,
|
||||
) {
|
||||
@@ -217,3 +228,36 @@ fun KeyboardLeadingIcon(
|
||||
ToggleNip17Button(channelScreenModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EncryptedUploadErrorDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
onRetryWithoutEncryption: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message)
|
||||
Text(
|
||||
stringRes(R.string.upload_without_encryption_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onRetryWithoutEncryption) {
|
||||
Text(stringRes(R.string.retry_without_encryption))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+30
@@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
|
||||
class ChatFileSender(
|
||||
@@ -39,6 +42,8 @@ class ChatFileSender(
|
||||
uploads.forEach {
|
||||
if (it.cipher != null) {
|
||||
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
|
||||
} else {
|
||||
sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +75,31 @@ class ChatFileSender(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendNIP17AsHiddenLink(
|
||||
result: UploadOrchestrator.OrchestratorResult.ServerResult,
|
||||
caption: String?,
|
||||
contentWarningReason: String?,
|
||||
) {
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
iMetaAttachments.add(result, caption, contentWarningReason)
|
||||
|
||||
val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() }
|
||||
|
||||
val template =
|
||||
ChatMessageEvent.build(result.url, toUsers) {
|
||||
references(listOf(result.url))
|
||||
|
||||
if (!caption.isNullOrEmpty()) {
|
||||
alt(caption)
|
||||
}
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
|
||||
imetas(iMetaAttachments.filterIsIn(setOf(result.url)))
|
||||
}
|
||||
|
||||
account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
|
||||
// ------
|
||||
// NIP 04
|
||||
// ------
|
||||
|
||||
+1
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
|
||||
onCancel,
|
||||
accountViewModel,
|
||||
nav,
|
||||
isNip17 = channelScreenModel.nip17,
|
||||
)
|
||||
}
|
||||
|
||||
+52
-4
@@ -39,20 +39,68 @@ class ChatFileUploader(
|
||||
suspend fun justUploadNIP17(
|
||||
viewState: ChatFileUploadState,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
onEncryptedUploadError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
|
||||
) {
|
||||
val orchestrator = viewState.multiOrchestrator ?: return
|
||||
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
|
||||
|
||||
val cipher = AESGCM()
|
||||
if (viewState.encryptFiles) {
|
||||
val cipher = AESGCM()
|
||||
|
||||
val results =
|
||||
orchestrator.uploadEncrypted(
|
||||
viewState.caption,
|
||||
viewState.contentWarningReason,
|
||||
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
|
||||
cipher,
|
||||
viewState.selectedServer,
|
||||
account,
|
||||
context,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
val list =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
onceUploaded(list)
|
||||
viewState.reset()
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onEncryptedUploadError(
|
||||
stringRes(context, R.string.failed_to_upload_encrypted_media_title),
|
||||
stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
justUploadNIP17Unencrypted(viewState, onError, context, onceUploaded)
|
||||
}
|
||||
|
||||
viewState.mediaUploadTracker.finishUpload()
|
||||
}
|
||||
|
||||
suspend fun justUploadNIP17Unencrypted(
|
||||
viewState: ChatFileUploadState,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
|
||||
) {
|
||||
val orchestrator = viewState.multiOrchestrator ?: return
|
||||
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
orchestrator.uploadEncrypted(
|
||||
orchestrator.upload(
|
||||
viewState.caption,
|
||||
viewState.contentWarningReason,
|
||||
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
|
||||
cipher,
|
||||
viewState.selectedServer,
|
||||
account,
|
||||
context,
|
||||
@@ -62,7 +110,7 @@ class ChatFileUploader(
|
||||
val list =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
|
||||
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
+13
-1
@@ -81,6 +81,7 @@ fun ChatFileUploadDialog(
|
||||
onCancel: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
isNip17: Boolean = false,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -132,7 +133,7 @@ fun ChatFileUploadDialog(
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
|
||||
ImageVideoPostChat(state, accountViewModel)
|
||||
ImageVideoPostChat(state, accountViewModel, isNip17)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +145,7 @@ fun ChatFileUploadDialog(
|
||||
private fun ImageVideoPostChat(
|
||||
fileUploadState: ChatFileUploadState,
|
||||
accountViewModel: AccountViewModel,
|
||||
isNip17: Boolean = false,
|
||||
) {
|
||||
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
|
||||
.collectAsState()
|
||||
@@ -190,6 +192,16 @@ private fun ImageVideoPostChat(
|
||||
onCheckedChange = fileUploadState::updateContentWarning,
|
||||
)
|
||||
|
||||
if (isNip17) {
|
||||
SettingSwitchItem(
|
||||
title = R.string.encrypt_files_label,
|
||||
description = R.string.encrypt_files_description,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
checked = fileUploadState.encryptFiles,
|
||||
onCheckedChange = { fileUploadState.encryptFiles = it },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
|
||||
+3
@@ -55,6 +55,8 @@ class ChatFileUploadState(
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
|
||||
var encryptFiles by mutableStateOf(true)
|
||||
|
||||
fun load(uris: ImmutableList<SelectedMedia>) {
|
||||
reset()
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
@@ -70,6 +72,7 @@ class ChatFileUploadState(
|
||||
mediaUploadTracker.finishUpload()
|
||||
caption = ""
|
||||
selectedServer = defaultServer
|
||||
encryptFiles = true
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
|
||||
@@ -1216,6 +1216,13 @@
|
||||
<string name="compression_cancelled">Compression Cancelled</string>
|
||||
<string name="compression_returned_null">Compression failed to return a file</string>
|
||||
|
||||
<string name="encrypt_files_label">Encrypt files</string>
|
||||
<string name="encrypt_files_description">Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts.</string>
|
||||
<string name="failed_to_upload_encrypted_media_title">Encrypted upload failed</string>
|
||||
<string name="failed_to_upload_encrypted_media_message">Many servers do not accept encrypted files on free accounts. You can retry without encryption.</string>
|
||||
<string name="retry_without_encryption">Retry without encryption</string>
|
||||
<string name="upload_without_encryption_warning">Warning: Without encryption, anyone with the file link can see the content.</string>
|
||||
|
||||
<string name="media_compression_quality_label">Media Quality</string>
|
||||
<string name="media_compression_quality_explainer">Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression.</string>
|
||||
<string name="media_compression_quality_low">Low</string>
|
||||
|
||||
Reference in New Issue
Block a user