add attachment UI design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
163
docs/specs/2026-03-15-attachment-ui-design.md
Normal file
163
docs/specs/2026-03-15-attachment-ui-design.md
Normal 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)
|
||||
Reference in New Issue
Block a user