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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-18 14:37:48 +02:00
parent 1d1d32da0e
commit cf17dea53f
4 changed files with 536 additions and 27 deletions
@@ -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 |
---
@@ -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<File>` 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<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)
}
}
```
```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<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 `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