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
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<ImageBitmap?>(null) }
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(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"
}