feat: add file encryption toggle for encrypted DMs with retry on failure
Add an "Encrypt files" toggle (default: on) to the file upload dialog in NIP-17 encrypted DMs. When encrypted upload fails, show an informative dialog explaining that many servers don't accept encrypted files on free accounts, with an option to retry without encryption. The retry dialog warns that without encryption anyone with the link can see the content. https://claude.ai/code/session_012xvKzHrZPq3ZTAzBN2LvrR
This commit is contained in:
+87
-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,68 @@ 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 +646,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
|
||||
@@ -114,6 +116,15 @@ fun PrivateMessageEditFieldRow(
|
||||
}
|
||||
}
|
||||
|
||||
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
title = title,
|
||||
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
|
||||
onDismiss = channelScreenModel::dismissEncryptedUploadError,
|
||||
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = EditFieldModifier,
|
||||
) {
|
||||
@@ -215,3 +226,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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -1215,6 +1215,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