From cf17dea53f90c5d13a708996530f7f011954aefa Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Mar 2026 14:37:48 +0200 Subject: [PATCH] feat(media): add Note/Picture post type selector in compose dialog When images are attached, a Note/Picture toggle appears letting the user publish as kind 20 (PictureEvent) instead of kind 1. Text input is disabled in picture mode. Selector only shows for image file types. Updates testing plan: Phase 1 all pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 147 ++++++-- ...8-desktop-dm-encrypted-media-brainstorm.md | 85 +++++ ...03-16-desktop-media-manual-testing-plan.md | 12 +- ...18-feat-desktop-dm-encrypted-media-plan.md | 319 ++++++++++++++++++ 4 files changed, 536 insertions(+), 27 deletions(-) create mode 100644 docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md create mode 100644 docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md 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