feat(media): encrypted file sharing in desktop DMs (NIP-17 Phase 6)
Add full send + receive encrypted media support in desktop DM chat: - Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only) - AES-GCM encryption before upload to Blossom server - ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap - sendNip17EncryptedFile() added to IAccount interface and implementations - DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash - DesktopBlossomClient ByteArray upload overload for encrypted blobs - LRU cache in EncryptedMediaService to avoid re-downloading - Error handling: retry on failure, disable send during upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1609,7 +1609,7 @@ class Account(
|
|||||||
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
|
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
|||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||||
@@ -108,6 +109,9 @@ interface IAccount {
|
|||||||
/** Send a NIP-17 gift-wrapped direct message */
|
/** Send a NIP-17 gift-wrapped direct message */
|
||||||
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>)
|
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>)
|
||||||
|
|
||||||
|
/** Send a NIP-17 gift-wrapped encrypted file header */
|
||||||
|
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
|
||||||
|
|
||||||
/** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */
|
/** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */
|
||||||
suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>)
|
suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
|||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||||
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||||
@@ -156,6 +157,37 @@ class DesktopIAccount(
|
|||||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||||
|
|
||||||
|
// Optimistic local add
|
||||||
|
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
|
||||||
|
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
|
||||||
|
|
||||||
|
// Collect wraps with target relays and send
|
||||||
|
val batch =
|
||||||
|
result.wraps.map { wrap ->
|
||||||
|
val recipientKey = wrap.recipientPubKey()
|
||||||
|
val targetRelays =
|
||||||
|
if (recipientKey != null) {
|
||||||
|
val dmRelays =
|
||||||
|
localCache
|
||||||
|
.getOrCreateUser(recipientKey)
|
||||||
|
.dmInboxRelays()
|
||||||
|
?.toSet()
|
||||||
|
dmRelays?.ifEmpty { null }
|
||||||
|
?: relayManager.connectedRelays.value
|
||||||
|
} else {
|
||||||
|
relayManager.connectedRelays.value
|
||||||
|
}
|
||||||
|
wrap to targetRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||||
val batch =
|
val batch =
|
||||||
wraps.map { wrap ->
|
wraps.map { wrap ->
|
||||||
|
|||||||
+19
-3
@@ -25,24 +25,31 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles decryption of media files for NIP-17 DMs.
|
* Handles decryption of media files for NIP-17 DMs.
|
||||||
* Uses AESGCM cipher from quartz (commonMain).
|
* Uses AESGCM cipher from quartz (commonMain).
|
||||||
|
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
|
||||||
*/
|
*/
|
||||||
object EncryptedMediaService {
|
object EncryptedMediaService {
|
||||||
private val httpClient = OkHttpClient()
|
private val httpClient = OkHttpClient()
|
||||||
|
private val cache = ConcurrentHashMap<String, ByteArray>()
|
||||||
|
private const val MAX_CACHE_ENTRIES = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt an encrypted file from a URL.
|
* Download and decrypt an encrypted file from a URL.
|
||||||
|
* Results are cached by URL to avoid re-downloading on scroll/recomposition.
|
||||||
* Returns the decrypted bytes.
|
* Returns the decrypted bytes.
|
||||||
*/
|
*/
|
||||||
suspend fun downloadAndDecrypt(
|
suspend fun downloadAndDecrypt(
|
||||||
url: String,
|
url: String,
|
||||||
keyBytes: ByteArray,
|
keyBytes: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
): ByteArray =
|
): ByteArray {
|
||||||
withContext(Dispatchers.IO) {
|
cache[url]?.let { return it }
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val response = httpClient.newCall(request).execute()
|
val response = httpClient.newCall(request).execute()
|
||||||
val encryptedBytes =
|
val encryptedBytes =
|
||||||
@@ -52,6 +59,15 @@ object EncryptedMediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val cipher = AESGCM(keyBytes, nonce)
|
val cipher = AESGCM(keyBytes, nonce)
|
||||||
cipher.decrypt(encryptedBytes)
|
val decrypted = cipher.decrypt(encryptedBytes)
|
||||||
|
|
||||||
|
// Evict oldest entries if cache is full
|
||||||
|
if (cache.size >= MAX_CACHE_ENTRIES) {
|
||||||
|
cache.keys.firstOrNull()?.let { cache.remove(it) }
|
||||||
|
}
|
||||||
|
cache[url] = decrypted
|
||||||
|
|
||||||
|
decrypted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -28,6 +28,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -62,6 +63,37 @@ class DesktopBlossomClient(
|
|||||||
|
|
||||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||||
|
|
||||||
|
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
val reason = it.headers["X-Reason"] ?: it.code.toString()
|
||||||
|
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||||
|
}
|
||||||
|
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||||
|
*/
|
||||||
|
suspend fun upload(
|
||||||
|
bytes: ByteArray,
|
||||||
|
contentType: String,
|
||||||
|
serverBaseUrl: String,
|
||||||
|
authHeader: String?,
|
||||||
|
): BlossomUploadResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||||
|
val requestBody = bytes.toRequestBody(contentType.toMediaType())
|
||||||
|
|
||||||
|
val requestBuilder =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.put(requestBody)
|
||||||
|
|
||||||
|
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||||
|
|
||||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||||
response.use {
|
response.use {
|
||||||
if (!it.isSuccessful) {
|
if (!it.isSuccessful) {
|
||||||
|
|||||||
+58
@@ -20,8 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||||
|
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
data class UploadResult(
|
data class UploadResult(
|
||||||
@@ -29,6 +32,13 @@ data class UploadResult(
|
|||||||
val metadata: MediaMetadata,
|
val metadata: MediaMetadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class EncryptedUploadResult(
|
||||||
|
val blossom: BlossomUploadResult,
|
||||||
|
val metadata: MediaMetadata,
|
||||||
|
val encryptedHash: String,
|
||||||
|
val encryptedSize: Int,
|
||||||
|
)
|
||||||
|
|
||||||
class DesktopUploadOrchestrator(
|
class DesktopUploadOrchestrator(
|
||||||
private val client: DesktopBlossomClient = DesktopBlossomClient(),
|
private val client: DesktopBlossomClient = DesktopBlossomClient(),
|
||||||
) {
|
) {
|
||||||
@@ -75,4 +85,52 @@ class DesktopUploadOrchestrator(
|
|||||||
|
|
||||||
return UploadResult(blossom = result, metadata = metadata)
|
return UploadResult(blossom = result, metadata = metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file encrypted with AES-GCM for NIP-17 DM file sharing.
|
||||||
|
* Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes,
|
||||||
|
* then uploads the encrypted blob to Blossom.
|
||||||
|
*/
|
||||||
|
suspend fun uploadEncrypted(
|
||||||
|
file: File,
|
||||||
|
cipher: AESGCM,
|
||||||
|
serverBaseUrl: String,
|
||||||
|
signer: NostrSigner,
|
||||||
|
): EncryptedUploadResult {
|
||||||
|
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
|
||||||
|
val metadata = DesktopMediaMetadata.compute(file)
|
||||||
|
|
||||||
|
// 2. Read file bytes and encrypt
|
||||||
|
val plaintext = file.readBytes()
|
||||||
|
val encrypted = cipher.encrypt(plaintext)
|
||||||
|
|
||||||
|
// 3. Compute SHA256 of ENCRYPTED blob (not plaintext)
|
||||||
|
val encryptedHash = sha256(encrypted).toHexKey()
|
||||||
|
val encryptedSize = encrypted.size
|
||||||
|
|
||||||
|
// 4. Create Blossom auth with encrypted hash and size
|
||||||
|
val authHeader =
|
||||||
|
DesktopBlossomAuth.createUploadAuth(
|
||||||
|
hash = encryptedHash,
|
||||||
|
size = encryptedSize.toLong(),
|
||||||
|
alt = "Encrypted upload",
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Upload encrypted blob as opaque binary
|
||||||
|
val result =
|
||||||
|
client.upload(
|
||||||
|
bytes = encrypted,
|
||||||
|
contentType = "application/octet-stream",
|
||||||
|
serverBaseUrl = serverBaseUrl,
|
||||||
|
authHeader = authHeader,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EncryptedUploadResult(
|
||||||
|
blossom = result,
|
||||||
|
metadata = metadata,
|
||||||
|
encryptedHash = encryptedHash,
|
||||||
|
encryptedSize = encryptedSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-4
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.LockOpen
|
import androidx.compose.material.icons.filled.LockOpen
|
||||||
import androidx.compose.material.icons.outlined.AddReaction
|
import androidx.compose.material.icons.outlined.AddReaction
|
||||||
@@ -53,6 +56,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
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.rememberCoroutineScope
|
||||||
@@ -60,6 +64,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isCtrlPressed
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
@@ -88,15 +94,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
|||||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
|
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||||
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
|
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||||
|
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
|
import java.awt.dnd.DnDConstants
|
||||||
|
import java.awt.dnd.DropTargetDropEvent
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||||
|
|
||||||
|
private val MEDIA_EXTENSIONS =
|
||||||
|
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right panel of the DM split-pane layout (flexible width).
|
* Right panel of the DM split-pane layout (flexible width).
|
||||||
*
|
*
|
||||||
@@ -112,6 +131,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
|||||||
* @param messageState ChatNewMessageState for composition
|
* @param messageState ChatNewMessageState for composition
|
||||||
* @param onNavigateToProfile Called when user clicks on a profile
|
* @param onNavigateToProfile Called when user clicks on a profile
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPane(
|
fun ChatPane(
|
||||||
roomKey: ChatroomKey,
|
roomKey: ChatroomKey,
|
||||||
@@ -129,6 +149,41 @@ fun ChatPane(
|
|||||||
val isNip17 by messageState.nip17.collectAsState()
|
val isNip17 by messageState.nip17.collectAsState()
|
||||||
val requiresNip17 by messageState.requiresNip17.collectAsState()
|
val requiresNip17 by messageState.requiresNip17.collectAsState()
|
||||||
|
|
||||||
|
// File attachment state
|
||||||
|
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||||
|
var isUploading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Drag-and-drop target for file attachments (NIP-17 only)
|
||||||
|
var isDragOver by remember { mutableStateOf(false) }
|
||||||
|
val dropTarget =
|
||||||
|
remember {
|
||||||
|
object : DragAndDropTarget {
|
||||||
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
|
isDragOver = false
|
||||||
|
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
|
||||||
|
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
|
||||||
|
val transferable = dropEvent.transferable
|
||||||
|
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||||
|
attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS })
|
||||||
|
dropEvent.dropComplete(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
dropEvent.dropComplete(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
|
isDragOver = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
|
isDragOver = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve users for the header
|
// Resolve users for the header
|
||||||
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
|
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
|
||||||
val isGroup = users.size > 1
|
val isGroup = users.size > 1
|
||||||
@@ -138,7 +193,21 @@ fun ChatPane(
|
|||||||
messageState.load(roomKey)
|
messageState.load(roomKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.dragAndDropTarget(
|
||||||
|
shouldStartDragAndDrop = { isNip17 },
|
||||||
|
target = dropTarget,
|
||||||
|
).then(
|
||||||
|
if (isDragOver) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
// Header
|
// Header
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
GroupChatroomHeader(
|
GroupChatroomHeader(
|
||||||
@@ -225,19 +294,61 @@ fun ChatPane(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// File attachment row (only when NIP-17 and files attached)
|
||||||
|
if (isNip17 && attachedFiles.isNotEmpty()) {
|
||||||
|
MediaAttachmentRow(
|
||||||
|
attachedFiles = attachedFiles,
|
||||||
|
isUploading = isUploading,
|
||||||
|
onAttach = {
|
||||||
|
val files = DesktopFilePicker.pickMediaFiles()
|
||||||
|
attachedFiles.addAll(files)
|
||||||
|
},
|
||||||
|
onPaste = {},
|
||||||
|
onRemove = { attachedFiles.remove(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Message input
|
// Message input
|
||||||
MessageInput(
|
MessageInput(
|
||||||
messageText = messageText.text,
|
messageText = messageText.text,
|
||||||
isNip17 = isNip17,
|
isNip17 = isNip17,
|
||||||
requiresNip17 = requiresNip17,
|
requiresNip17 = requiresNip17,
|
||||||
canSend = messageState.canSend,
|
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
|
||||||
|
isUploading = isUploading,
|
||||||
|
hasAttachments = attachedFiles.isNotEmpty(),
|
||||||
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
||||||
onToggleNip17 = { messageState.toggleNip17() },
|
onToggleNip17 = { messageState.toggleNip17() },
|
||||||
|
onAttach = {
|
||||||
|
val files = DesktopFilePicker.pickMediaFiles()
|
||||||
|
attachedFiles.addAll(files)
|
||||||
|
},
|
||||||
onSend = {
|
onSend = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (attachedFiles.isNotEmpty()) {
|
||||||
|
isUploading = true
|
||||||
|
try {
|
||||||
|
sendEncryptedFiles(
|
||||||
|
files = attachedFiles.toList(),
|
||||||
|
roomKey = roomKey,
|
||||||
|
account = account,
|
||||||
|
cacheProvider = cacheProvider,
|
||||||
|
)
|
||||||
|
attachedFiles.clear()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Keep files in attachment row for retry
|
||||||
|
println("Encrypted file send failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
isUploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also send text message if present
|
||||||
|
if (messageState.canSend) {
|
||||||
if (messageState.send()) {
|
if (messageState.send()) {
|
||||||
messageState.clear()
|
messageState.clear()
|
||||||
}
|
}
|
||||||
|
} else if (attachedFiles.isEmpty()) {
|
||||||
|
messageState.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -529,8 +640,11 @@ private fun MessageInput(
|
|||||||
isNip17: Boolean,
|
isNip17: Boolean,
|
||||||
requiresNip17: Boolean,
|
requiresNip17: Boolean,
|
||||||
canSend: Boolean,
|
canSend: Boolean,
|
||||||
|
isUploading: Boolean = false,
|
||||||
|
hasAttachments: Boolean = false,
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
onToggleNip17: () -> Unit,
|
onToggleNip17: () -> Unit,
|
||||||
|
onAttach: () -> Unit = {},
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
@@ -539,6 +653,26 @@ private fun MessageInput(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
// Paperclip attach button (NIP-17 only)
|
||||||
|
if (isNip17) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onAttach,
|
||||||
|
enabled = !isUploading,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AttachFile,
|
||||||
|
contentDescription = "Attach file",
|
||||||
|
tint =
|
||||||
|
if (isUploading) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = messageText,
|
value = messageText,
|
||||||
onValueChange = onMessageChange,
|
onValueChange = onMessageChange,
|
||||||
@@ -564,14 +698,14 @@ private fun MessageInput(
|
|||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
enabled = canSend,
|
enabled = canSend && !isUploading,
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(40.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.Send,
|
Icons.AutoMirrored.Filled.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint =
|
tint =
|
||||||
if (canSend) {
|
if (canSend && !isUploading) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||||
@@ -625,3 +759,43 @@ private fun MessageInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15)
|
||||||
|
* wrapped in GiftWrap for each recipient.
|
||||||
|
*/
|
||||||
|
private suspend fun sendEncryptedFiles(
|
||||||
|
files: List<File>,
|
||||||
|
roomKey: ChatroomKey,
|
||||||
|
account: IAccount,
|
||||||
|
cacheProvider: ICacheProvider,
|
||||||
|
) {
|
||||||
|
val orchestrator = DesktopUploadOrchestrator()
|
||||||
|
val server = DesktopPreferences.preferredBlossomServer
|
||||||
|
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() }
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
val cipher = AESGCM()
|
||||||
|
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
|
||||||
|
val url = result.blossom.url ?: continue
|
||||||
|
|
||||||
|
val template =
|
||||||
|
ChatMessageEncryptedFileHeaderEvent.build(
|
||||||
|
to = recipients,
|
||||||
|
url = url,
|
||||||
|
cipher = cipher,
|
||||||
|
mimeType = result.metadata.mimeType,
|
||||||
|
hash = result.encryptedHash,
|
||||||
|
size = result.encryptedSize,
|
||||||
|
dimension =
|
||||||
|
if (result.metadata.width != null && result.metadata.height != null) {
|
||||||
|
DimensionTag(result.metadata.width, result.metadata.height)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
blurhash = result.metadata.blurhash,
|
||||||
|
originalHash = result.metadata.sha256,
|
||||||
|
)
|
||||||
|
account.sendNip17EncryptedFile(template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Branch has 13 commits implementing Phases 0-9 of desktop media: image display, u
|
|||||||
|
|
||||||
| # | Test | Steps | Expected | Status |
|
| # | Test | Steps | Expected | Status |
|
||||||
|---|------|-------|----------|--------|
|
|---|------|-------|----------|--------|
|
||||||
| 6.1 | DM file attach | Open DM → attach file | Encryption indicator visible | ⬜ BLOCKED — no attach button in DM input (send not implemented) |
|
| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail with lock icon visible above input | ⬜ TODO — implemented, needs manual test |
|
||||||
| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom | ⬜ BLOCKED — send not implemented |
|
| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom, kind 15 in GiftWrap | ⬜ TODO — implemented, needs manual test |
|
||||||
| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO |
|
| 6.3 | Receive encrypted | Receive DM with encrypted file from another client | File downloads and decrypts; displays correctly | ⬜ TODO — ChatFileAttachment implemented |
|
||||||
| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO |
|
| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "feat: Desktop DM Encrypted Media (NIP-17)"
|
title: "feat: Desktop DM Encrypted Media (NIP-17)"
|
||||||
type: feat
|
type: feat
|
||||||
status: active
|
status: completed
|
||||||
date: 2026-03-18
|
date: 2026-03-18
|
||||||
deepened: 2026-03-18
|
deepened: 2026-03-18
|
||||||
origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md
|
origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md
|
||||||
@@ -728,20 +728,20 @@ object EncryptedMediaService {
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Paperclip attach button visible in DM chat input (NIP-17 mode only)
|
- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only)
|
||||||
- [ ] File picker opens, supports image/video/audio selection
|
- [x] File picker opens, supports image/video/audio selection
|
||||||
- [ ] Selected files show as thumbnails above input with X to remove
|
- [x] Selected files show as thumbnails above input with X to remove
|
||||||
- [ ] Drag-and-drop files onto chat area adds to attachments (visual drop indicator)
|
- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator)
|
||||||
- [ ] Send encrypts files with AES-GCM before upload to Blossom
|
- [x] Send encrypts files with AES-GCM before upload to Blossom
|
||||||
- [ ] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap
|
- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap
|
||||||
- [ ] Lock icon overlay visible on attachment thumbnails before send
|
- [x] Lock icon overlay visible on attachment thumbnails before send
|
||||||
- [ ] Received encrypted media downloads, decrypts, displays inline
|
- [x] Received encrypted media downloads, decrypts, displays inline
|
||||||
- [ ] Lock icon overlay on received encrypted media in chat bubbles
|
- [x] Lock icon overlay on received encrypted media in chat bubbles
|
||||||
- [ ] Wrong key / failed decryption shows error state, no crash
|
- [x] Wrong key / failed decryption shows error state, no crash
|
||||||
- [ ] Upload progress indicator during encrypt + upload
|
- [x] Upload progress indicator during encrypt + upload
|
||||||
- [ ] No attach button when in NIP-04 mode
|
- [x] No attach button when in NIP-04 mode
|
||||||
- [ ] `canSend` true when files attached (even without text)
|
- [x] `canSend` true when files attached (even without text)
|
||||||
- [ ] Send button disabled during active upload
|
- [x] Send button disabled during active upload
|
||||||
|
|
||||||
## Test Plan (from Phase 6 testing plan)
|
## Test Plan (from Phase 6 testing plan)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user