feat(chats): right-click context menu on encrypted files — download + forward
Right-click on encrypted file attachments in DMs now shows a context menu: - "Save to disk" — decrypts and saves via native file dialog - "Forward" — placeholder callback for forwarding to another conversation Also caches decrypted bytes (not just image bitmap) so save doesn't re-download. Non-image files also get decrypted on load for save support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+193
-74
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -31,11 +33,15 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Forward
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -44,23 +50,31 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService
|
import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
import org.jetbrains.skia.Image as SkiaImage
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatFileAttachment(
|
fun ChatFileAttachment(
|
||||||
event: ChatMessageEncryptedFileHeaderEvent,
|
event: ChatMessageEncryptedFileHeaderEvent,
|
||||||
|
onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val url = event.url()
|
val url = event.url()
|
||||||
@@ -68,20 +82,26 @@ fun ChatFileAttachment(
|
|||||||
val keyBytes = event.key()
|
val keyBytes = event.key()
|
||||||
val nonce = event.nonce()
|
val nonce = event.nonce()
|
||||||
val isImage = mimeType?.startsWith("image/") == true
|
val isImage = mimeType?.startsWith("image/") == true
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Auto-decrypt images
|
// Auto-decrypt images
|
||||||
LaunchedEffect(url) {
|
LaunchedEffect(url) {
|
||||||
if (isImage && keyBytes != null && nonce != null) {
|
if (keyBytes != null && nonce != null && url != null) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
try {
|
try {
|
||||||
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
|
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
|
||||||
withContext(Dispatchers.Default) {
|
decryptedBytes = bytes
|
||||||
val skImage = SkiaImage.makeFromEncoded(bytes)
|
if (isImage) {
|
||||||
decryptedImage = skImage.toComposeImageBitmap()
|
withContext(Dispatchers.Default) {
|
||||||
|
val skImage = SkiaImage.makeFromEncoded(bytes)
|
||||||
|
decryptedImage = skImage.toComposeImageBitmap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
@@ -91,85 +111,184 @@ fun ChatFileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Box {
|
||||||
modifier = modifier.fillMaxWidth(),
|
Card(
|
||||||
colors =
|
modifier =
|
||||||
CardDefaults.cardColors(
|
modifier
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
.fillMaxWidth()
|
||||||
),
|
.pointerInput(Unit) {
|
||||||
) {
|
detectTapGestures(
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
onPress = {
|
||||||
// Encryption indicator
|
// Right-click detection handled via awaiting press
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// Context menu is shown on secondary button via the other modifier
|
||||||
Icon(
|
},
|
||||||
Icons.Default.Lock,
|
|
||||||
contentDescription = "Encrypted",
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
"Encrypted file",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
|
|
||||||
when {
|
|
||||||
isLoading -> {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptedImage != null -> {
|
|
||||||
androidx.compose.foundation.Image(
|
|
||||||
bitmap = decryptedImage!!,
|
|
||||||
contentDescription = "Encrypted image",
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 300.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
error != null -> {
|
|
||||||
Text(
|
|
||||||
"Failed to decrypt: $error",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Non-image file
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.InsertDriveFile,
|
|
||||||
contentDescription = "File",
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
}.pointerInput(Unit) {
|
||||||
Column {
|
awaitPointerEventScope {
|
||||||
Text(
|
while (true) {
|
||||||
mimeType ?: "Unknown file",
|
val event = awaitPointerEvent()
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (event.buttons.isSecondaryPressed &&
|
||||||
|
event.changes.any { it.pressed && !it.previousPressed }
|
||||||
|
) {
|
||||||
|
showContextMenu = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
|
// Encryption indicator
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Lock,
|
||||||
|
contentDescription = "Encrypted",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
"Encrypted file",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp).align(Alignment.CenterHorizontally),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptedImage != null -> {
|
||||||
|
androidx.compose.foundation.Image(
|
||||||
|
bitmap = decryptedImage!!,
|
||||||
|
contentDescription = "Encrypted image",
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
error != null -> {
|
||||||
|
Text(
|
||||||
|
"Failed to decrypt: $error",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Non-image file
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.InsertDriveFile,
|
||||||
|
contentDescription = "File",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
event.size()?.let { size ->
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"${size / 1024}KB",
|
mimeType ?: "Unknown file",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
|
event.size()?.let { size ->
|
||||||
|
Text(
|
||||||
|
"${size / 1024}KB",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right-click context menu
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showContextMenu,
|
||||||
|
onDismissRequest = { showContextMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Save to disk") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
},
|
||||||
|
enabled = decryptedBytes != null,
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
scope.launch {
|
||||||
|
saveDecryptedFile(decryptedBytes!!, mimeType, event.hash())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (onForward != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Forward") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
onForward(event)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save decrypted bytes to disk via a native save dialog.
|
||||||
|
*/
|
||||||
|
private suspend fun saveDecryptedFile(
|
||||||
|
bytes: ByteArray,
|
||||||
|
mimeType: String?,
|
||||||
|
hash: String?,
|
||||||
|
) {
|
||||||
|
val extension = mimeTypeToExtension(mimeType)
|
||||||
|
val suggestedName = "${hash?.take(12) ?: "file"}.$extension"
|
||||||
|
|
||||||
|
val file =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val dialog =
|
||||||
|
FileDialog(null as Frame?, "Save Decrypted File", FileDialog.SAVE).apply {
|
||||||
|
this.file = suggestedName
|
||||||
|
}
|
||||||
|
dialog.isVisible = true
|
||||||
|
val dir = dialog.directory ?: return@withContext null
|
||||||
|
File(dir, dialog.file ?: return@withContext null)
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
file.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeToExtension(mimeType: String?): String =
|
||||||
|
when (mimeType) {
|
||||||
|
"image/jpeg" -> "jpg"
|
||||||
|
"image/png" -> "png"
|
||||||
|
"image/gif" -> "gif"
|
||||||
|
"image/webp" -> "webp"
|
||||||
|
"image/svg+xml" -> "svg"
|
||||||
|
"video/mp4" -> "mp4"
|
||||||
|
"video/webm" -> "webm"
|
||||||
|
"video/quicktime" -> "mov"
|
||||||
|
"audio/mpeg" -> "mp3"
|
||||||
|
"audio/ogg" -> "ogg"
|
||||||
|
"audio/wav" -> "wav"
|
||||||
|
"audio/flac" -> "flac"
|
||||||
|
else -> "bin"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user