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:
nrobi144
2026-03-19 09:06:26 +02:00
parent d9848b868b
commit 35b58e489b
@@ -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"
}