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) <noreply@anthropic.com>
13 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Desktop DM Encrypted Media (NIP-17) | feat | active | 2026-03-18 | 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— adduploadEncrypted()desktopApp/.../service/upload/DesktopBlossomClient.kt— may need raw bytes upload variantdesktopApp/.../service/upload/DesktopBlossomAuth.kt— verify auth uses encrypted blob hash
Implementation:
// 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— modifyMessageInput()composable
Implementation:
Add to MessageInput() (currently at line ~527):
- State:
attachedFiles: MutableList<File>tracked inChatNewMessageStateor local state - Paperclip button: Icon button left of text field, opens
JFileChooser(same pattern asComposeNoteDialog.kt) - Thumbnail row: Above the text input, shows attached file thumbnails with X remove button
- Drag-and-drop:
Modifier.onExternalDragon the chat pane area (same pattern asComposeNoteDialog.kt) - 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 inMessageInput()desktopApp/.../model/DesktopIAccount.kt— addsendNip17EncryptedFile()
Implementation:
// In ChatPane.kt send handler — after upload completes
fun sendEncryptedFiles(
uploads: List<Pair<UploadResult, AESGCM>>,
recipients: List<PTag>,
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)
}
}
// 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.ktif shared rendering needed
Implementation:
ChatFileAttachment() already exists at line 442 of ChatPane.kt. Enhance it:
- Extract cipher params: Parse
key(),nonce(),mimeType(),url()fromChatMessageEncryptedFileHeaderEvent - Download & decrypt: Call
EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) - Display: Render decrypted bytes as image/video/audio based on MIME type
- Lock overlay:
BoxwithIcon(Icons.Outlined.Lock)in corner, semi-transparent background - Loading state: Blurhash placeholder while downloading/decrypting
- Error state: If decryption fails, show "Could not decrypt file" with retry option
@Composable
fun ChatFileAttachment(
event: ChatMessageEncryptedFileHeaderEvent,
account: DesktopIAccount
) {
val decryptedBytes by produceState<ByteArray?>(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
ChatMessageEncryptedFileHeaderEventsent 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 — 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
xtag andX-SHA-256header - 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