diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 860dbb4e4..0c955558e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -82,6 +82,9 @@ import java.io.File private val MEDIA_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") +private val IMAGE_EXTENSIONS = + setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif") + @OptIn(ExperimentalComposeUiApi::class) @Composable fun ComposeNoteDialog( @@ -99,6 +102,7 @@ fun ComposeNoteDialog( val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { DesktopUploadOrchestrator() } var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + var postAsPicture by remember { mutableStateOf(false) } // Drag-and-drop state var isDragOver by remember { mutableStateOf(false) } @@ -165,16 +169,20 @@ fun ComposeNoteDialog( Spacer(Modifier.height(16.dp)) OutlinedTextField( - value = content, + value = if (postAsPicture) "" else content, onValueChange = { content = it errorMessage = null }, - modifier = Modifier.fillMaxWidth().height(200.dp), - label = { Text("What's on your mind?") }, - placeholder = { Text("Write your note...") }, - enabled = !isPosting, - maxLines = 10, + modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp), + label = { + Text( + if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?", + ) + }, + placeholder = { Text(if (postAsPicture) "" else "Write your note...") }, + enabled = !isPosting && !postAsPicture, + maxLines = if (postAsPicture) 1 else 10, ) Spacer(Modifier.height(8.dp)) @@ -193,13 +201,31 @@ fun ComposeNoteDialog( onRemove = { attachedFiles.remove(it) }, ) - // Server selector — shown when files are attached + // Server selector + post type — shown when files are attached if (attachedFiles.isNotEmpty()) { Spacer(Modifier.height(4.dp)) - ServerSelector( - selectedServer = selectedServer, - onServerSelected = { selectedServer = it }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + ServerSelector( + selectedServer = selectedServer, + onServerSelected = { selectedServer = it }, + ) + + // Post type toggle — only when images are attached + val hasImages = + attachedFiles.any { + it.extension.lowercase() in IMAGE_EXTENSIONS + } + if (hasImages) { + PostTypeSelector( + isPicture = postAsPicture, + onToggle = { postAsPicture = it }, + ) + } + } } Spacer(Modifier.height(4.dp)) @@ -283,16 +309,24 @@ fun ComposeNoteDialog( } } - // Build imeta tags from upload metadata - val imetaTags = buildIMetaTags(uploadResults) - - publishNote( - content = finalContent, - account = account, - relayManager = relayManager, - replyTo = replyTo, - imetaTags = imetaTags, - ) + if (postAsPicture) { + val pictureMetas = buildPictureMetas(uploadResults) + publishPicture( + description = content, + images = pictureMetas, + account = account, + relayManager = relayManager, + ) + } else { + val imetaTags = buildIMetaTags(uploadResults) + publishNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + imetaTags = imetaTags, + ) + } onDismiss() } catch (e: Exception) { errorMessage = "Failed: ${e.message}" @@ -382,6 +416,77 @@ private fun ServerSelector( } } +@Composable +private fun PostTypeSelector( + isPicture: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Post as:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.FilterChip( + selected = !isPicture, + onClick = { onToggle(false) }, + label = { Text("Note", style = MaterialTheme.typography.labelSmall) }, + ) + androidx.compose.material3.FilterChip( + selected = isPicture, + onClick = { onToggle(true) }, + label = { Text("Picture", style = MaterialTheme.typography.labelSmall) }, + ) + } +} + +private fun buildPictureMetas(results: List): List = + results.mapNotNull { result -> + val url = result.blossom.url ?: return@mapNotNull null + val meta = result.metadata + com.vitorpamplona.quartz.nip68Picture.PictureMeta( + url = url, + mimeType = meta.mimeType, + blurhash = meta.blurhash, + dimension = + if (meta.width != null && meta.height != null) { + com.vitorpamplona.quartz.nip94FileMetadata.tags + .DimensionTag(meta.width, meta.height) + } else { + null + }, + hash = meta.sha256, + size = meta.size.toInt(), + ) + } + +private suspend fun publishPicture( + description: String, + images: List, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + val template = + com.vitorpamplona.quartz.nip68Picture.PictureEvent.build( + images = images, + description = description, + ) { + hashtags(findHashtags(description)) + } + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } +} + private suspend fun publishNote( content: String, account: AccountState.LoggedIn, diff --git a/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md new file mode 100644 index 000000000..543857c56 --- /dev/null +++ b/docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md @@ -0,0 +1,85 @@ +# Brainstorm: Desktop DM Encrypted Media (Phase 6) + +**Date:** 2026-03-18 +**Status:** Ready for planning +**Branch:** `feat/desktop-media` + +## What We're Building + +Full send + receive encrypted media support in desktop DM chat (NIP-17). Users can attach files in DMs, which get encrypted (AES-GCM) before upload to Blossom, sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap. Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +## Why This Approach + +The protocol layer is complete in quartz (NIP-17, NIP-44, AESGCM, ChatMessageEncryptedFileHeaderEvent). Android has the full flow implemented. Desktop already has `EncryptedMediaService.downloadAndDecrypt()` and `DesktopUploadOrchestrator` (unencrypted only). The work is essentially wiring up the existing pieces with a desktop-native UX. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Scope | Full send + receive | Both sides needed for complete DM file sharing | +| Attach UX | Inline paperclip button | Matches desktop chat conventions; thumbnails above text input | +| Drag & drop | Yes, in addition to button | Desktop-native interaction; ComposeNoteDialog already has this pattern | +| Encryption indicator | Lock icon overlay | Small lock on corner of encrypted media in chat bubbles | +| Upload encryption | AES-GCM via AESGCM class | Matches Android's ChatFileUploader.justUploadNIP17() pattern | +| Event type | Kind 15 (ChatMessageEncryptedFileHeaderEvent) | NIP-17 standard for encrypted file metadata | + +## Architecture Overview + +### Send Flow (Desktop) +``` +User attaches file → thumbnail preview above input + → Click send + → Generate AES-GCM cipher (key + nonce) + → DesktopUploadOrchestrator.uploadEncrypted(cipher, file) + → Encrypt file bytes with cipher + → Upload encrypted blob to Blossom server + → Build ChatMessageEncryptedFileHeaderEvent (kind 15) + → URL, encryption algo/key/nonce, file metadata + → Wrap in GiftWrap (NIP-59) for each recipient + → Send to relays +``` + +### Receive Flow (Desktop) +``` +Receive GiftWrap → unwrap → ChatMessageEncryptedFileHeaderEvent + → Extract URL, encryption key, nonce from tags + → EncryptedMediaService.downloadAndDecrypt(url, key, nonce) + → Display decrypted media inline in chat bubble + → Lock icon overlay on media thumbnail +``` + +## Existing Code to Reuse + +| Component | Location | Action | +|-----------|----------|--------| +| AESGCM cipher | `quartz/utils/ciphers/AESGCM.kt` | Reuse as-is | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Reuse as-is | +| NIP17Factory (GiftWrap) | `quartz/nip17Dm/NIP17Factory.kt` | Reuse as-is | +| EncryptedMediaService | `desktopApp/service/media/EncryptedMediaService.kt` | Extend for UI integration | +| DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Add `uploadEncrypted()` | +| ChatPane | `desktopApp/ui/chats/ChatPane.kt` | Add attach button, thumbnails, drag-drop | +| ChatMessageCompose | `commons/ui/chat/ChatMessageCompose.kt` | Add encrypted media display | +| Android ChatFileUploader | `amethyst/chats/privateDM/send/upload/` | Reference pattern | + +## New Code Needed + +| Component | Location | Purpose | +|-----------|----------|---------| +| `uploadEncrypted()` | DesktopUploadOrchestrator | Encrypt file with AESGCM before Blossom upload | +| DM file attach UI | ChatPane.kt | Paperclip button, thumbnail row, drag-drop zone | +| DM file send logic | ChatPane.kt or new helper | Build kind 15 event from upload result + cipher | +| Encrypted media renderer | ChatMessageCompose or new composable | Download, decrypt, display with lock overlay | +| `sendNip17EncryptedFile()` | DesktopIAccount.kt | Bridge to relay manager for sending | + +## Open Questions + +None — all key decisions resolved through brainstorm dialogue. + +## Test Cases (from testing plan) + +| # | Test | Expected | +|---|------|----------| +| 6.1 | DM file attach | Attach button visible, encryption indicator shown | +| 6.2 | Send encrypted | File uploads encrypted to Blossom, kind 15 event sent | +| 6.3 | Receive encrypted | Encrypted file downloads, decrypts, displays in bubble | +| 6.4 | Wrong key | Decryption fails gracefully (no crash, error state) | diff --git a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md index a803a49a7..cfa1facf6 100644 --- a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md +++ b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md @@ -20,13 +20,13 @@ Branch has 13 commits implementing Phases 0-9 of desktop media: image display, u | # | Test | Steps | Expected | Status | |---|------|-------|----------|--------| -| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ⬜ TODO | -| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ⬜ TODO | +| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ✅ PASS | +| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ✅ PASS | | 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) | -| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ⬜ TODO | -| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ⬜ TODO | -| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/amethyst/`, Linux: `~/.cache/amethyst/` | ⬜ TODO | -| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ⬜ TODO | +| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ✅ PASS | +| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ✅ PASS | +| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/AmethystDesktop/image_cache/` | ✅ PASS | +| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ✅ PASS | --- diff --git a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md new file mode 100644 index 000000000..441ea1ec2 --- /dev/null +++ b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md @@ -0,0 +1,319 @@ +--- +title: "feat: Desktop DM Encrypted Media (NIP-17)" +type: feat +status: active +date: 2026-03-18 +origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md +--- + +# feat: Desktop DM Encrypted Media (NIP-17) + +## Overview + +Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. + +This is Phase 6 of the desktop media parity plan. + +## Problem Statement / Motivation + +Desktop DM chat (`ChatPane.kt`) supports text messages but has no file attachment capability. Android has a complete implementation (`ChatFileUploader` + `ChatFileSender`). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX. + +## Proposed Solution + +Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display. + +(see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`) + +## Implementation Phases + +### Phase A: Encrypted Upload Pipeline + +**Goal:** `DesktopUploadOrchestrator` gains `uploadEncrypted()` method. + +**Files:** +- `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()` +- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — may need raw bytes upload variant +- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — verify auth uses encrypted blob hash + +**Implementation:** + +```kotlin +// DesktopUploadOrchestrator.kt — new method +suspend fun uploadEncrypted( + file: File, + cipher: AESGCM, + server: String, + signer: NostrSigner, + tracker: DesktopUploadTracker? = null +): UploadResult { + // 1. Read file bytes + val plaintext = file.readBytes() + + // 2. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + val metadata = DesktopMediaMetadata.compute(file) + + // 3. Encrypt + val encrypted = cipher.encrypt(plaintext) + + // 4. Compute SHA256 of ENCRYPTED blob (critical: not plaintext) + val encryptedHash = sha256Hex(encrypted) + + // 5. Create Blossom auth with encrypted hash + val auth = DesktopBlossomAuth.createUploadAuth( + hash = encryptedHash, + signer = signer + ) + + // 6. Upload encrypted blob + val result = DesktopBlossomClient.upload( + bytes = encrypted, + hash = encryptedHash, + auth = auth, + server = server, + tracker = tracker + ) + + // 7. Return result with both hashes + return UploadResult(result, metadata, originalHash = metadata.sha256) +} +``` + +**Reference:** Android's `ChatFileUploader.justUploadNIP17()` at `amethyst/.../upload/ChatFileUploader.kt:39-80` + +**Critical gotcha:** Hash the encrypted blob, not plaintext. The `X-SHA-256` header and kind 24242 `x` tag must match the encrypted bytes. + +--- + +### Phase B: DM File Attach UI in ChatPane + +**Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable + +**Implementation:** + +Add to `MessageInput()` (currently at line ~527): + +1. **State:** `attachedFiles: MutableList` tracked in `ChatNewMessageState` or local state +2. **Paperclip button:** Icon button left of text field, opens `JFileChooser` (same pattern as `ComposeNoteDialog.kt`) +3. **Thumbnail row:** Above the text input, shows attached file thumbnails with X remove button +4. **Drag-and-drop:** `Modifier.onExternalDrag` on the chat pane area (same pattern as `ComposeNoteDialog.kt`) +5. **Encryption indicator:** Small lock icon badge on attachment thumbnails when in NIP-17 mode + +**UI Layout (from brainstorm):** +``` +┌─────────────────────────────────┐ +│ Chat messages... │ +│ │ +│ ┌─────┐ ┌─────┐ │ +│ │thumb│ │thumb│ (attachments) │ +│ └──x──┘ └──x──┘ │ +│ 📎 [Type a message... ] [→] │ +└─────────────────────────────────┘ +``` + +**Reference:** `ComposeNoteDialog.kt` drag-drop pattern (line ~182-194 for `MediaAttachmentRow`, line ~254-303 for upload pipeline) + +--- + +### Phase C: Send Encrypted File Event + +**Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `MessageInput()` +- `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()` + +**Implementation:** + +```kotlin +// In ChatPane.kt send handler — after upload completes +fun sendEncryptedFiles( + uploads: List>, + recipients: List, + account: DesktopIAccount +) { + for ((result, cipher) in uploads) { + val eventTemplate = ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = result.blossom.url, + cipher = cipher, + mimeType = result.metadata.mimeType, + hash = result.metadata.encryptedHash, + size = result.metadata.encryptedSize, + dimension = result.metadata.dimension, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.originalHash + ) + account.sendNip17EncryptedFile(eventTemplate) + } +} +``` + +```kotlin +// DesktopIAccount.kt — new method (mirrors sendNip17PrivateMessage pattern) +suspend fun sendNip17EncryptedFile( + eventTemplate: ChatMessageEncryptedFileHeaderEvent +) { + // Same GiftWrap flow as sendNip17PrivateMessage() + val wraps = NIP17Factory().createFileNIP17( + event = eventTemplate, + signer = signer + ) + // Optimistically add to local chatroom + // Send wraps to recipient DM inbox relays + sendGiftWraps(wraps) +} +``` + +**Reference:** Android's `ChatFileSender.sendNIP17()` at `amethyst/.../upload/ChatFileSender.kt:46-71` + +--- + +### Phase D: Receive & Display Encrypted Media + +**Goal:** Encrypted media in received DMs renders inline with lock icon. + +**Files:** +- `desktopApp/.../ui/chats/ChatPane.kt` — `ChatFileAttachment()` composable (line ~442) +- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists +- Potentially `commons/.../ui/chat/ChatMessageCompose.kt` if shared rendering needed + +**Implementation:** + +`ChatFileAttachment()` already exists at line 442 of ChatPane.kt. Enhance it: + +1. **Extract cipher params:** Parse `key()`, `nonce()`, `mimeType()`, `url()` from `ChatMessageEncryptedFileHeaderEvent` +2. **Download & decrypt:** Call `EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)` +3. **Display:** Render decrypted bytes as image/video/audio based on MIME type +4. **Lock overlay:** `Box` with `Icon(Icons.Outlined.Lock)` in corner, semi-transparent background +5. **Loading state:** Blurhash placeholder while downloading/decrypting +6. **Error state:** If decryption fails, show "Could not decrypt file" with retry option + +```kotlin +@Composable +fun ChatFileAttachment( + event: ChatMessageEncryptedFileHeaderEvent, + account: DesktopIAccount +) { + val decryptedBytes by produceState(null) { + val keyHex = event.key() ?: return@produceState + val nonceHex = event.nonce() ?: return@produceState + val url = event.url() ?: return@produceState + value = try { + EncryptedMediaService.downloadAndDecrypt(url, keyHex, nonceHex) + } catch (e: Exception) { + null // error state + } + } + + Box { + when { + decryptedBytes != null -> { + // Render based on mimeType + DecryptedMediaContent(decryptedBytes!!, event.mimeType()) + } + else -> { + // Blurhash placeholder or error + EncryptedFilePlaceholder(event.blurhash()) + } + } + // Lock icon overlay + Icon( + Icons.Outlined.Lock, + modifier = Modifier.align(Alignment.BottomEnd).size(20.dp) + ) + } +} +``` + +--- + +### Phase E: Error Handling & Edge Cases + +**Files:** Across all modified files above. + +| Scenario | Handling | +|----------|----------| +| Upload fails mid-way | Show error toast, keep file in attachment row for retry | +| Network disconnect during upload | Catch IOException, show "Upload failed — check connection" | +| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | +| Blossom server unreachable | Fallback to next server in user's kind 10063 list | +| Large file (>10MB) | Show progress indicator during encrypt + upload | +| Unsupported MIME type | Show generic file icon with filename + size | +| No Blossom servers configured | Disable attach button, show tooltip "Configure media servers in Settings" | +| NIP-04 mode active | Attach button hidden or disabled (encrypted files are NIP-17 only) | + +## Technical Considerations + +### Security +- New `AESGCM()` cipher per file (random key + nonce) — never reuse +- Encrypt before hashing — Blossom auth scoped to encrypted blob +- Plaintext never leaves device unencrypted +- GiftWrap ensures only recipients can see the kind 15 event + +### Performance +- Encryption runs on `Dispatchers.IO` (non-blocking) +- Large files: stream encrypt if needed (current AESGCM works on byte arrays — fine for <50MB) +- Decrypted media cached in memory (LRU) to avoid re-downloading + +### Architecture +- No new modules — extends existing desktop services +- Protocol layer (quartz) unchanged — fully reuses existing events/ciphers +- Follows Android patterns for consistency across platforms + +## Acceptance Criteria + +- [ ] Paperclip attach button visible in DM chat input (NIP-17 mode only) +- [ ] File picker opens, supports image/video/audio selection +- [ ] Selected files show as thumbnails above input with X to remove +- [ ] Drag-and-drop files onto chat area adds to attachments +- [ ] Send encrypts files with AES-GCM before upload to Blossom +- [ ] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap +- [ ] Encryption indicator (lock icon) visible on attachments before send +- [ ] Received encrypted media downloads, decrypts, displays inline +- [ ] Lock icon overlay on received encrypted media in chat bubbles +- [ ] Wrong key / failed decryption shows error state, no crash +- [ ] Upload progress indicator during encrypt + upload +- [ ] No attach button when in NIP-04 mode + +## Test Plan (from Phase 6 testing plan) + +| # | Test | Steps | Expected | +|---|------|-------|----------| +| 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator | +| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap | +| 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon | +| 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash | + +## Dependencies & Risks + +| Dependency | Risk | Mitigation | +|-----------|------|------------| +| Blossom server availability | Upload fails | Retry + fallback to alternate servers | +| VLC for video playback | Encrypted video won't play without VLC | Graceful fallback for non-VLC systems | +| Android client for cross-platform test | Can't verify interop | Use Android emulator or second account | +| `DesktopBlossomClient` raw bytes upload | May only support File, not ByteArray | Add overload if needed | + +## Sources & References + +### Origin +- **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope + +### Internal References +- Android ChatFileUploader: `amethyst/.../upload/ChatFileUploader.kt:39-80` +- Android ChatFileSender: `amethyst/.../upload/ChatFileSender.kt:46-71` +- Desktop ChatPane: `desktopApp/.../ui/chats/ChatPane.kt` +- Desktop UploadOrchestrator: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` +- Desktop EncryptedMediaService: `desktopApp/.../service/media/EncryptedMediaService.kt` +- Desktop ComposeNoteDialog (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt` +- Quartz ChatMessageEncryptedFileHeaderEvent: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt` +- Quartz AESGCM: `quartz/.../utils/ciphers/AESGCM.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### Gotchas (from learnings research) +- Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header +- New AESGCM cipher per file — never reuse key/nonce pair +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay +- NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode