add attachment UI design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 10:54:14 +01:00
parent 8c33d4d4a6
commit a3e3618d34

View File

@@ -0,0 +1,163 @@
# Attachment UI — Design Spec
**Date:** 2026-03-15
**Scope:** Wire attachment UI for compose (send) and thread detail (receive), add list badges.
**Builds on:** v0.5 backend — MIMEParser, MessageFormatter.formatMultipart, SyncCoordinator.downloadAttachment, AttachmentRecord, hasAttachments schema field.
---
## 1. Compose Attachments
### Data Model
```swift
// ComposeViewModel
struct ComposeAttachment: Identifiable {
let id: UUID
let url: URL
let filename: String
let mimeType: String
let size: Int
let data: Data
}
var attachments: [ComposeAttachment] = []
```
### Input Paths
All three paths converge to a single `addAttachment(url:)` method on `ComposeViewModel`.
1. **Paperclip button**`.fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:)` in SwiftUI. Allows any file type. Result URLs passed to `addAttachment`.
2. **Drag-and-drop**`.onDrop(of: [.fileURL], ...)` on the compose text area. Extract file URLs from `NSItemProvider`, pass to `addAttachment`.
3. **Paste** — Custom `NSTextView` coordinator (macOS) that intercepts paste, detects `NSImage` or file items on `NSPasteboard`. Writes pasted images to a temp file, passes URL to `addAttachment`.
### Validation
`addAttachment(url:)` logic:
1. Read file data from URL.
2. Detect MIME type via `UTType(filenameExtension:)`.
3. **Warn** if size > 10 MB (non-blocking alert, user can proceed).
4. **Reject** if size > 25 MB (error alert, file not added). This matches the existing `MessageFormatter` guard.
5. Create `ComposeAttachment`, append to `attachments`.
### UI — Horizontal Chip Strip
Below the text editor, above the send button:
- Horizontal scrolling `ScrollView(.horizontal)` of attachment chips.
- Each chip: `[icon filename · size ×]`
- Icon: SF Symbol derived from UTType (`doc`, `photo`, `film`, `paperclip` fallback).
- Filename: truncated to ~20 chars with ellipsis.
- Size: formatted (KB/MB).
- × button: removes from `attachments` array.
- Hidden when `attachments.isEmpty`.
### Send Path
`MessageFormatter.formatMultipart` takes `[(filename: String, mimeType: String, data: Data)]` (anonymous tuples) and returns the full RFC822 string. The send integration:
- If `attachments.isEmpty`: existing flow unchanged — `.send(message: outgoing)` passes the `OutgoingMessage` to SMTPClient, `.append` uses `MessageFormatter.format(outgoing)`.
- If attachments present:
1. Call `let formatted = try MessageFormatter.formatMultipart(outgoing, attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) })`.
2. `formatMultipart` is `throws` (`MessageFormatterError.attachmentTooLarge`). Catch and surface as `errorMessage`, matching the existing `send()` error-handling pattern.
3. The `.send` action changes: instead of `.send(message: outgoing)`, use `.send(message: outgoingWithAttachments)` where `OutgoingMessage` gains an optional `attachments` field. `SMTPClient.send` checks this field and calls `formatMultipart` if present, or `format` otherwise.
4. The `.append` action uses `formatted` (the multipart RFC822 string) for the Sent-copy — same as the no-attachment path but with the multipart output.
This means `OutgoingMessage` needs a new optional field:
```swift
public var attachments: [(filename: String, mimeType: String, data: Data)]?
```
`SMTPClient.send` uses this to decide between plain and multipart formatting.
### Draft Persistence
**Out of scope for this iteration.** Attachments live only in memory during the compose session. If the user closes the compose view, attachments are lost. Draft auto-save continues to save text-only fields. This matches the current "plain text compose" scope. Future rich-text work will add draft attachment persistence (temp directory keyed by draft ID, paths stored in `DraftRecord`).
---
## 2. Received Attachments Display
### Data Loading
Attachments are loaded **lazily per message** — not included in the thread observation query. When a `MessageView` appears:
- Fetch `[AttachmentRecord]` via `MailStore.attachments(messageId:)` (add if not present).
- Store in `@State` within `MessageView`.
### UI — Disclosure Chips
Below the message body in `MessageView`, shown only when attachments exist:
- Header: `"Attachments (\(count))"` in caption style, secondary color.
- Below: horizontal scrolling `ScrollView(.horizontal)` of tappable chips (same layout primitive as compose strip — no wrapping needed for v0.6).
- Each chip: `[icon filename · size]`
- Icon: SF Symbol from UTType of `mimeType`.
- Tappable → triggers download and preview.
### Download + Preview Flow
`MessageView` has access to the parent `MessageSummary` (which carries `id`). To call `SyncCoordinator.downloadAttachment(attachmentId:messageUid:mailboxName:)`, the view needs `messageUid` and `mailboxName`. These are obtained by adding a thin wrapper method on `SyncCoordinator` (or `MailViewModel`) that internally looks up `MessageRecord.uid` and `MailboxRecord.name` from the message ID, so the view only passes `attachmentId` and `messageId`.
1. User taps chip.
2. Chip shows spinner (replace icon with `ProgressView`).
3. Call wrapper: `downloadAttachment(attachmentId:messageId:)`.
4. On success: open returned `URL` via Quick Look (`QLPreviewPanel` on macOS).
5. On failure: show error in chip (red tint, retry on next tap).
Cached attachments (where `cachePath` is non-nil and file exists) skip the download step and go straight to Quick Look.
---
## 3. Email List Badges
### Thread List
Add `public var attachmentCount: Int` to `ThreadSummary` struct (default 0), update the `init`, and map it in `threadSummariesFromDB` row-mapping in `Queries.swift` (`attachmentCount: row["attachmentCount"]`). Populate via a subquery:
```sql
(SELECT COUNT(*) FROM attachment a
JOIN threadMessage tm ON tm.messageId = a.messageId
WHERE tm.threadId = t.id) as attachmentCount
```
In `ThreadRow`, when `attachmentCount > 0`, show:
```swift
HStack(spacing: 2) {
Image(systemName: "paperclip")
Text("\(thread.attachmentCount)")
}
.font(.caption2)
.foregroundStyle(.secondary)
```
Positioned next to the message count badge.
### Item List (GTD perspectives)
`MessageSummary.hasAttachments` already exists and is mapped from `MessageRecord`. For `ItemRow`, show a paperclip icon (no count) when `hasAttachments` is true. A per-message attachment count is not needed for the item list — the boolean is sufficient and avoids an extra query.
---
## 4. Files Touched
| File | Changes |
|------|---------|
| `ComposeViewModel.swift` | `ComposeAttachment` model, `addAttachment(url:)`, `removeAttachment(id:)`, size validation, integrate with send path (both `.send` and `.append`) |
| `ComposeView.swift` | Paperclip button, `.fileImporter`, `.onDrop`, paste coordinator, attachment chip strip |
| `MessageView` (in `ThreadDetailView.swift`) | Attachment chips section, lazy loading, download wrapper + Quick Look |
| `ThreadListView.swift` | Paperclip badge in `ThreadRow` and `ItemRow` |
| `Queries.swift` | Add `attachmentCount` subquery to thread summary SQL, add field to `ThreadSummary` struct + init + row-mapping |
| `Models/OutgoingMessage.swift` | Add optional `attachments` field for multipart send |
| `SMTPClient.swift` | Check `outgoing.attachments` and call `formatMultipart` when present |
| `MailStore.swift` | `attachments(messageId:)` query |
| `SyncCoordinator.swift` or `MailViewModel.swift` | Thin download wrapper that resolves `messageUid`/`mailboxName` from `messageId` |
**No new modules or files.** Everything integrates into existing code.
---
## 5. Out of Scope
- Rich text / inline image composer (future v1+)
- Draft attachment persistence (future, with rich text)
- Attachment preview thumbnails in the list
- Attachment search
- Attachment size totals per thread
- iOS-specific paste handling (macOS first, iOS later)