From 35b58e489b68df34b0cf8b86f48e9ff34b3edf93 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 19 Mar 2026 09:06:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(chats):=20right-click=20context=20menu=20o?= =?UTF-8?q?n=20encrypted=20files=20=E2=80=94=20download=20+=20forward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../desktop/ui/chats/ChatFileAttachment.kt | 267 +++++++++++++----- 1 file changed, 193 insertions(+), 74 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt index 35db63f89..8c9993d67 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -20,6 +20,8 @@ */ 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.Row 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.shape.RoundedCornerShape 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.Lock import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -44,23 +50,31 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap 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.unit.dp import com.vitorpamplona.amethyst.desktop.service.media.EncryptedMediaService import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.awt.Frame +import java.io.File import org.jetbrains.skia.Image as SkiaImage @Composable fun ChatFileAttachment( event: ChatMessageEncryptedFileHeaderEvent, + onForward: ((ChatMessageEncryptedFileHeaderEvent) -> Unit)? = null, modifier: Modifier = Modifier, ) { val url = event.url() @@ -68,20 +82,26 @@ fun ChatFileAttachment( val keyBytes = event.key() val nonce = event.nonce() val isImage = mimeType?.startsWith("image/") == true + val scope = rememberCoroutineScope() var decryptedImage by remember { mutableStateOf(null) } + var decryptedBytes by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(false) } // Auto-decrypt images LaunchedEffect(url) { - if (isImage && keyBytes != null && nonce != null) { + if (keyBytes != null && nonce != null && url != null) { isLoading = true try { val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) - withContext(Dispatchers.Default) { - val skImage = SkiaImage.makeFromEncoded(bytes) - decryptedImage = skImage.toComposeImageBitmap() + decryptedBytes = bytes + if (isImage) { + withContext(Dispatchers.Default) { + val skImage = SkiaImage.makeFromEncoded(bytes) + decryptedImage = skImage.toComposeImageBitmap() + } } } catch (e: Exception) { error = e.message @@ -91,85 +111,184 @@ fun ChatFileAttachment( } } - Card( - modifier = modifier.fillMaxWidth(), - 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), + Box { + Card( + modifier = + modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + // Right-click detection handled via awaiting press + // Context menu is shown on secondary button via the other modifier + }, ) - Spacer(Modifier.width(8.dp)) - Column { - Text( - mimeType ?: "Unknown file", - style = MaterialTheme.typography.bodySmall, + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + 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( - "${size / 1024}KB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + mimeType ?: "Unknown file", + style = MaterialTheme.typography.bodySmall, ) + 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" + }