add attachment UI implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
903
docs/plans/2026-03-15-attachment-ui.md
Normal file
903
docs/plans/2026-03-15-attachment-ui.md
Normal file
@@ -0,0 +1,903 @@
|
||||
# Attachment UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire attachment UI for compose (send) and thread detail (receive), add list badges.
|
||||
|
||||
**Architecture:** Extends existing ComposeViewModel/ComposeView with attachment state, file picker, drag-drop, paste. MessageView gains lazy-loaded attachment chips with download + Quick Look. ThreadRow/ItemRow show paperclip badges via a new `attachmentCount` subquery on ThreadSummary.
|
||||
|
||||
**Tech Stack:** SwiftUI (macOS), GRDB, NIO-IMAP, NIO-SSL, WKWebView, Quick Look, UniformTypeIdentifiers
|
||||
|
||||
**Spec:** `docs/specs/2026-03-15-attachment-ui-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Data Model + Send Path
|
||||
|
||||
### Task 1: Add attachments field to OutgoingMessage
|
||||
|
||||
**Files:**
|
||||
- Modify: `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift`
|
||||
- Test: `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift`
|
||||
|
||||
- [ ] **Step 1: Add optional attachments field to OutgoingMessage**
|
||||
|
||||
In `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift`, add a new field. Since `OutgoingMessage` is `Codable` and attachments contain `Data`, we need a nested `Codable` struct:
|
||||
|
||||
```swift
|
||||
public struct OutgoingAttachment: Sendable, Codable, Equatable {
|
||||
public var filename: String
|
||||
public var mimeType: String
|
||||
public var data: Data
|
||||
|
||||
public init(filename: String, mimeType: String, data: Data) {
|
||||
self.filename = filename
|
||||
self.mimeType = mimeType
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add to `OutgoingMessage`:
|
||||
```swift
|
||||
public var attachments: [OutgoingAttachment]
|
||||
```
|
||||
|
||||
Update the `init` to include `attachments: [OutgoingAttachment] = []`.
|
||||
|
||||
- [ ] **Step 2: Build and verify no regressions**
|
||||
|
||||
Run: `cd Packages/MagnumOpusCore && swift build`
|
||||
Expected: Build succeeds (default `[]` means all existing call sites compile)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
git add Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift
|
||||
git commit -m "add OutgoingAttachment model, attachments field on OutgoingMessage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update SMTPClient.send to handle attachments
|
||||
|
||||
**Files:**
|
||||
- Modify: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift:51` (the `send` method)
|
||||
- Test: `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write test for multipart send formatting**
|
||||
|
||||
In `MessageFormatterTests.swift`, add a test that verifies `MessageFormatter.formatMultipart` is called when attachments are present:
|
||||
|
||||
```swift
|
||||
@Test func formatMultipartWithAttachments() throws {
|
||||
let msg = OutgoingMessage(
|
||||
from: EmailAddress(name: "Test", address: "test@example.com"),
|
||||
to: [EmailAddress(name: "To", address: "to@example.com")],
|
||||
subject: "With attachment",
|
||||
bodyText: "Hello",
|
||||
messageId: "test-123",
|
||||
attachments: [
|
||||
OutgoingAttachment(filename: "test.txt", mimeType: "text/plain", data: Data("file content".utf8))
|
||||
]
|
||||
)
|
||||
let formatted = try MessageFormatter.formatMultipart(
|
||||
msg,
|
||||
attachments: msg.attachments.map { ($0.filename, $0.mimeType, $0.data) }
|
||||
)
|
||||
#expect(formatted.contains("multipart/mixed"))
|
||||
#expect(formatted.contains("test.txt"))
|
||||
#expect(formatted.contains("Hello"))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it compiles and passes**
|
||||
|
||||
Run: `cd Packages/MagnumOpusCore && swift test --filter MessageFormatterTests/formatMultipartWithAttachments`
|
||||
|
||||
- [ ] **Step 3: Update SMTPClient.send to use formatMultipart when attachments present**
|
||||
|
||||
In `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift`, change line 51:
|
||||
|
||||
```swift
|
||||
// Before:
|
||||
let formattedMessage = MessageFormatter.format(message)
|
||||
|
||||
// After:
|
||||
let formattedMessage: String
|
||||
if message.attachments.isEmpty {
|
||||
formattedMessage = MessageFormatter.format(message)
|
||||
} else {
|
||||
formattedMessage = try MessageFormatter.formatMultipart(
|
||||
message,
|
||||
attachments: message.attachments.map { ($0.filename, $0.mimeType, $0.data) }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and run all tests**
|
||||
|
||||
Run: `swift test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift Packages/MagnumOpusCore/Tests/SMTPClientTests/
|
||||
git commit -m "update SMTPClient.send to use formatMultipart when attachments present"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update ComposeViewModel send path for attachments
|
||||
|
||||
**Files:**
|
||||
- Modify: `Apps/MagnumOpus/ViewModels/ComposeViewModel.swift`
|
||||
|
||||
- [ ] **Step 1: Add ComposeAttachment model and state**
|
||||
|
||||
Add at the top of `ComposeViewModel.swift` (below imports):
|
||||
|
||||
```swift
|
||||
struct ComposeAttachment: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let filename: String
|
||||
let mimeType: String
|
||||
let size: Int
|
||||
let data: Data
|
||||
}
|
||||
```
|
||||
|
||||
Add to `ComposeViewModel` properties:
|
||||
|
||||
```swift
|
||||
var attachments: [ComposeAttachment] = []
|
||||
var sizeWarningMessage: String?
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add addAttachment / removeAttachment methods**
|
||||
|
||||
```swift
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
func addAttachment(url: URL) {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let size = data.count
|
||||
let maxSize = 25 * 1024 * 1024
|
||||
let warnSize = 10 * 1024 * 1024
|
||||
|
||||
if size > maxSize {
|
||||
errorMessage = "\(url.lastPathComponent) is too large (\(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))). Maximum is 25 MB."
|
||||
return
|
||||
}
|
||||
|
||||
if size > warnSize {
|
||||
sizeWarningMessage = "\(url.lastPathComponent) is \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)). Large attachments may slow delivery."
|
||||
}
|
||||
|
||||
let ext = url.pathExtension
|
||||
let mimeType = UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream"
|
||||
|
||||
attachments.append(ComposeAttachment(
|
||||
url: url,
|
||||
filename: url.lastPathComponent,
|
||||
mimeType: mimeType,
|
||||
size: size,
|
||||
data: data
|
||||
))
|
||||
} catch {
|
||||
errorMessage = "Could not read file: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func removeAttachment(id: UUID) {
|
||||
attachments.removeAll { $0.id == id }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update send() to pass attachments**
|
||||
|
||||
In the `send()` method, after building `outgoing`, change:
|
||||
|
||||
```swift
|
||||
// Before:
|
||||
let outgoing = OutgoingMessage(
|
||||
from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses,
|
||||
subject: subject, bodyText: bodyText,
|
||||
inReplyTo: inReplyTo, references: references, messageId: messageId
|
||||
)
|
||||
let formatted = MessageFormatter.format(outgoing)
|
||||
|
||||
// After:
|
||||
let outgoing = OutgoingMessage(
|
||||
from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses,
|
||||
subject: subject, bodyText: bodyText,
|
||||
inReplyTo: inReplyTo, references: references, messageId: messageId,
|
||||
attachments: attachments.map { OutgoingAttachment(filename: $0.filename, mimeType: $0.mimeType, data: $0.data) }
|
||||
)
|
||||
let formatted: String
|
||||
if attachments.isEmpty {
|
||||
formatted = MessageFormatter.format(outgoing)
|
||||
} else {
|
||||
formatted = try MessageFormatter.formatMultipart(
|
||||
outgoing,
|
||||
attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The rest of `send()` stays the same — both `.send(message: outgoing)` and `.append(mailbox: "Sent", messageData: formatted, ...)` use the updated values.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git add Apps/MagnumOpus/ViewModels/ComposeViewModel.swift
|
||||
git commit -m "add ComposeAttachment model, wire attachment send path with formatMultipart"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Compose UI
|
||||
|
||||
### Task 4: Add attachment chip strip and paperclip button to ComposeView
|
||||
|
||||
**Files:**
|
||||
- Modify: `Apps/MagnumOpus/Views/ComposeView.swift`
|
||||
|
||||
- [ ] **Step 1: Add file importer state and paperclip toolbar button**
|
||||
|
||||
Add to `ComposeView`:
|
||||
```swift
|
||||
@State private var showFileImporter = false
|
||||
```
|
||||
|
||||
Add a paperclip toolbar item alongside the BCC toggle:
|
||||
```swift
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button {
|
||||
showFileImporter = true
|
||||
} label: {
|
||||
Label("Attach", systemImage: "paperclip")
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: [.shift, .command])
|
||||
.help("Attach File (⇧⌘A)")
|
||||
}
|
||||
```
|
||||
|
||||
Add the `.fileImporter` modifier to `content`:
|
||||
```swift
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
for url in urls {
|
||||
guard url.startAccessingSecurityScopedResource() else { continue }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
viewModel.addAttachment(url: url)
|
||||
}
|
||||
case .failure(let error):
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add attachment chip strip below the TextEditor section**
|
||||
|
||||
After the `TextEditor` section, add:
|
||||
```swift
|
||||
if !viewModel.attachments.isEmpty {
|
||||
Section {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.attachments) { attachment in
|
||||
AttachmentChip(attachment: attachment) {
|
||||
viewModel.removeAttachment(id: attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let warning = viewModel.sizeWarningMessage {
|
||||
Section {
|
||||
Text(warning)
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create AttachmentChip view**
|
||||
|
||||
Add at the bottom of `ComposeView.swift`:
|
||||
```swift
|
||||
struct AttachmentChip: View {
|
||||
let attachment: ComposeAttachment
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(attachment.filename)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 150)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file))
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
onRemove()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
let ext = (attachment.filename as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo"
|
||||
case "pdf": return "doc.richtext"
|
||||
case "mp4", "mov", "avi": return "film"
|
||||
case "mp3", "wav", "aac", "m4a": return "music.note"
|
||||
case "zip", "gz", "tar", "7z": return "doc.zipper"
|
||||
default: return "paperclip"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add drag-and-drop to the TextEditor**
|
||||
|
||||
Wrap the `TextEditor` section body with `.onDrop`:
|
||||
```swift
|
||||
TextEditor(text: $viewModel.bodyText)
|
||||
.frame(minHeight: 200)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url {
|
||||
Task { @MainActor in
|
||||
viewModel.addAttachment(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and test manually**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
git add Apps/MagnumOpus/Views/ComposeView.swift
|
||||
git commit -m "add attachment chip strip, paperclip button, file importer, drag-drop to compose view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add paste support for images
|
||||
|
||||
**Files:**
|
||||
- Modify: `Apps/MagnumOpus/Views/ComposeView.swift`
|
||||
|
||||
- [ ] **Step 1: Create PastableTextEditor NSViewRepresentable**
|
||||
|
||||
Add to `ComposeView.swift` (or a new helper file if preferred):
|
||||
|
||||
```swift
|
||||
#if os(macOS)
|
||||
struct PastableTextEditor: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var onPasteFile: (URL) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
let textView = scrollView.documentView as! NSTextView
|
||||
textView.delegate = context.coordinator
|
||||
textView.font = .systemFont(ofSize: 14)
|
||||
textView.isRichText = false
|
||||
textView.allowsUndo = true
|
||||
textView.string = text
|
||||
context.coordinator.textView = textView
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
||||
let textView = nsView.documentView as! NSTextView
|
||||
if textView.string != text {
|
||||
textView.string = text
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text, onPasteFile: onPasteFile)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
@Binding var text: String
|
||||
var onPasteFile: (URL) -> Void
|
||||
weak var textView: NSTextView?
|
||||
|
||||
init(text: Binding<String>, onPasteFile: @escaping (URL) -> Void) {
|
||||
_text = text
|
||||
self.onPasteFile = onPasteFile
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView else { return }
|
||||
text = textView.string
|
||||
}
|
||||
|
||||
// Override paste to detect images
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.paste(_:)) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
// Check for file URLs first
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
|
||||
for url in urls where url.isFileURL {
|
||||
onPasteFile(url)
|
||||
}
|
||||
if !urls.isEmpty { return true }
|
||||
}
|
||||
// Check for images
|
||||
if let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage], let image = images.first {
|
||||
if let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let tempFile = tempDir.appendingPathComponent("pasted-image-\(UUID().uuidString).png")
|
||||
try? pngData.write(to: tempFile)
|
||||
onPasteFile(tempFile)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace TextEditor with PastableTextEditor in ComposeView**
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
TextEditor(text: $viewModel.bodyText)
|
||||
.frame(minHeight: 200)
|
||||
.onDrop(...)
|
||||
```
|
||||
|
||||
With:
|
||||
```swift
|
||||
#if os(macOS)
|
||||
PastableTextEditor(text: $viewModel.bodyText) { url in
|
||||
viewModel.addAttachment(url: url)
|
||||
}
|
||||
.frame(minHeight: 200)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
// ... same drag-drop handler
|
||||
}
|
||||
#else
|
||||
TextEditor(text: $viewModel.bodyText)
|
||||
.frame(minHeight: 200)
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
git add Apps/MagnumOpus/Views/ComposeView.swift
|
||||
git commit -m "add PastableTextEditor with image paste support for macOS compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Received Attachments Display
|
||||
|
||||
### Task 6: Add attachment chips to MessageView
|
||||
|
||||
**Files:**
|
||||
- Modify: `Apps/MagnumOpus/Views/ThreadDetailView.swift`
|
||||
- Modify: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||||
|
||||
- [ ] **Step 1: Add download wrapper to MailViewModel**
|
||||
|
||||
In `MailViewModel.swift`, add:
|
||||
```swift
|
||||
func downloadAttachment(attachmentId: String, messageId: String) async throws -> URL {
|
||||
guard let store, let coordinator else {
|
||||
throw NSError(domain: "MagnumOpus", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not configured"])
|
||||
}
|
||||
guard let record = try store.message(id: messageId) else {
|
||||
throw NSError(domain: "MagnumOpus", code: 2, userInfo: [NSLocalizedDescriptionKey: "Message not found"])
|
||||
}
|
||||
let mailboxName = mailboxName(for: record.mailboxId) ?? "INBOX"
|
||||
return try await coordinator.downloadAttachment(
|
||||
attachmentId: attachmentId,
|
||||
messageUid: record.uid,
|
||||
mailboxName: mailboxName
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Also expose `attachments(messageId:)`:
|
||||
```swift
|
||||
func attachments(messageId: String) -> [AttachmentRecord] {
|
||||
guard let store else { return [] }
|
||||
return (try? store.attachments(messageId: messageId)) ?? []
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add attachment section to MessageView**
|
||||
|
||||
In `ThreadDetailView.swift`, update `MessageView` to accept `viewModel` and show attachments:
|
||||
|
||||
Update `MessageView` struct:
|
||||
```swift
|
||||
struct MessageView: View {
|
||||
let message: MessageSummary
|
||||
let viewModel: MailViewModel
|
||||
@State private var showSource = false
|
||||
@State private var attachments: [AttachmentRecord] = []
|
||||
@State private var downloadingIds: Set<String> = []
|
||||
@State private var previewURL: URL?
|
||||
```
|
||||
|
||||
Add `.onAppear` to load attachments:
|
||||
```swift
|
||||
.onAppear {
|
||||
if message.hasAttachments {
|
||||
attachments = viewModel.attachments(messageId: message.id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add attachment section after the body content (before the closing `VStack`'s `.padding()`):
|
||||
```swift
|
||||
if !attachments.isEmpty {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Attachments (\(attachments.count))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(attachments, id: \.id) { attachment in
|
||||
ReceivedAttachmentChip(
|
||||
attachment: attachment,
|
||||
isDownloading: downloadingIds.contains(attachment.id)
|
||||
) {
|
||||
downloadAndPreview(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add ReceivedAttachmentChip and download helper**
|
||||
|
||||
```swift
|
||||
struct ReceivedAttachmentChip: View {
|
||||
let attachment: AttachmentRecord
|
||||
let isDownloading: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 4) {
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
Text(attachment.filename ?? "attachment")
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 150)
|
||||
if attachment.size > 0 {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
let ext = ((attachment.filename ?? "") as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo"
|
||||
case "pdf": return "doc.richtext"
|
||||
case "mp4", "mov", "avi": return "film"
|
||||
case "mp3", "wav", "aac", "m4a": return "music.note"
|
||||
case "zip", "gz", "tar", "7z": return "doc.zipper"
|
||||
default: return "paperclip"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add download helper and error state tracking to `MessageView`:
|
||||
```swift
|
||||
@State private var failedIds: Set<String> = []
|
||||
|
||||
private func downloadAndPreview(_ attachment: AttachmentRecord) {
|
||||
// If cached, open directly via Quick Look
|
||||
if let cachePath = attachment.cachePath,
|
||||
FileManager.default.fileExists(atPath: cachePath) {
|
||||
previewURL = URL(fileURLWithPath: cachePath)
|
||||
return
|
||||
}
|
||||
failedIds.remove(attachment.id)
|
||||
downloadingIds.insert(attachment.id)
|
||||
Task {
|
||||
do {
|
||||
let url = try await viewModel.downloadAttachment(
|
||||
attachmentId: attachment.id,
|
||||
messageId: message.id
|
||||
)
|
||||
downloadingIds.remove(attachment.id)
|
||||
previewURL = url
|
||||
} catch {
|
||||
downloadingIds.remove(attachment.id)
|
||||
failedIds.insert(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add Quick Look sheet to the `MessageView` body (inside the VStack):
|
||||
```swift
|
||||
.quickLookPreview($previewURL)
|
||||
```
|
||||
|
||||
Note: `previewURL` is already declared as `@State private var previewURL: URL?` in Step 2.
|
||||
|
||||
Update `ReceivedAttachmentChip` to accept `hasFailed` parameter and show red tint:
|
||||
```swift
|
||||
struct ReceivedAttachmentChip: View {
|
||||
let attachment: AttachmentRecord
|
||||
let isDownloading: Bool
|
||||
let hasFailed: Bool
|
||||
let onTap: () -> Void
|
||||
// ... existing body, but add:
|
||||
// .background(hasFailed ? Color.red.opacity(0.15) : Color.quaternary, in: Capsule())
|
||||
}
|
||||
```
|
||||
|
||||
Update the chip creation to pass `hasFailed`:
|
||||
```swift
|
||||
ReceivedAttachmentChip(
|
||||
attachment: attachment,
|
||||
isDownloading: downloadingIds.contains(attachment.id),
|
||||
hasFailed: failedIds.contains(attachment.id)
|
||||
) {
|
||||
downloadAndPreview(attachment)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update MessageView call sites to pass viewModel**
|
||||
|
||||
In `ThreadDetailView`, update the `ForEach`:
|
||||
```swift
|
||||
case .message(let message):
|
||||
MessageView(message: message, viewModel: viewModel)
|
||||
```
|
||||
|
||||
This requires adding `viewModel` to `ThreadDetailView`. Update the struct:
|
||||
```swift
|
||||
struct ThreadDetailView: View {
|
||||
let thread: ThreadSummary?
|
||||
let messages: [MessageSummary]
|
||||
let linkedTasks: [TaskSummary]
|
||||
let viewModel: MailViewModel
|
||||
var onComposeRequest: (ComposeMode) -> Void
|
||||
```
|
||||
|
||||
Update the call site in `ContentView.swift`:
|
||||
```swift
|
||||
ThreadDetailView(
|
||||
thread: viewModel.selectedThread,
|
||||
messages: viewModel.messages,
|
||||
linkedTasks: ...,
|
||||
viewModel: viewModel,
|
||||
onComposeRequest: ...
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
git add Apps/MagnumOpus/Views/ThreadDetailView.swift Apps/MagnumOpus/ViewModels/MailViewModel.swift Apps/MagnumOpus/ContentView.swift
|
||||
git commit -m "add attachment chips to message view with download + open"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Email List Badges
|
||||
|
||||
### Task 7: Add attachmentCount to ThreadSummary and thread query
|
||||
|
||||
**Files:**
|
||||
- Modify: `Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift`
|
||||
- Modify: `Packages/MagnumOpusCore/Sources/MailStore/Queries.swift`
|
||||
|
||||
- [ ] **Step 1: Add attachmentCount to ThreadSummary**
|
||||
|
||||
In `ThreadSummary.swift`, add `public var attachmentCount: Int` and update `init`:
|
||||
|
||||
```swift
|
||||
public struct ThreadSummary: Sendable, Identifiable, Equatable {
|
||||
public var id: String
|
||||
public var accountId: String
|
||||
public var subject: String?
|
||||
public var lastDate: Date
|
||||
public var messageCount: Int
|
||||
public var unreadCount: Int
|
||||
public var senders: String
|
||||
public var snippet: String?
|
||||
public var attachmentCount: Int
|
||||
|
||||
public init(
|
||||
id: String, accountId: String, subject: String?, lastDate: Date,
|
||||
messageCount: Int, unreadCount: Int, senders: String, snippet: String?,
|
||||
attachmentCount: Int = 0
|
||||
) {
|
||||
// ... existing assignments ...
|
||||
self.attachmentCount = attachmentCount
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add attachmentCount subquery to Queries.swift**
|
||||
|
||||
In `threadSummariesFromDB`, add to the SQL:
|
||||
```sql
|
||||
(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount
|
||||
```
|
||||
|
||||
Update the row mapping:
|
||||
```swift
|
||||
ThreadSummary(
|
||||
// ... existing fields ...
|
||||
snippet: row["snippet"],
|
||||
attachmentCount: row["attachmentCount"]
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `cd Packages/MagnumOpusCore && swift test`
|
||||
Expected: All 142+ tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
git add Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift Packages/MagnumOpusCore/Sources/MailStore/Queries.swift
|
||||
git commit -m "add attachmentCount to ThreadSummary via subquery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add paperclip badges to ThreadRow and ItemRow
|
||||
|
||||
**Files:**
|
||||
- Modify: `Apps/MagnumOpus/Views/ThreadListView.swift`
|
||||
|
||||
- [ ] **Step 1: Add paperclip badge to ThreadRow**
|
||||
|
||||
In `ThreadRow`, after the message count badge, add:
|
||||
|
||||
```swift
|
||||
if thread.attachmentCount > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "paperclip")
|
||||
Text("\(thread.attachmentCount)")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add paperclip icon to ItemRow for emails with attachments**
|
||||
|
||||
In `ItemRow`, within the `.email(let msg)` case, after the snippet, add:
|
||||
|
||||
```swift
|
||||
if msg.hasAttachments {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
```
|
||||
|
||||
Place it in the trailing area, next to the relative date.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
git add Apps/MagnumOpus/Views/ThreadListView.swift
|
||||
git commit -m "add paperclip badges to thread list and item list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: Final Integration + Verification
|
||||
|
||||
### Task 9: Full build + test verification
|
||||
|
||||
- [ ] **Step 1: Run all package tests**
|
||||
|
||||
Run: `cd Packages/MagnumOpusCore && swift test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Build macOS app**
|
||||
|
||||
Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep -E "error:|warning:" | grep -v "/.build/" | grep -v "appintentsmetadataprocessor"`
|
||||
Expected: No errors, no new warnings
|
||||
|
||||
- [ ] **Step 3: Verify no regressions in existing functionality**
|
||||
|
||||
Check:
|
||||
- Compose without attachments still works (plain text path unchanged)
|
||||
- Thread list loads correctly
|
||||
- GTD perspectives load correctly
|
||||
- Thread detail shows messages
|
||||
|
||||
- [ ] **Step 4: Final commit if any integration fixes needed**
|
||||
|
||||
```
|
||||
git commit -m "fix attachment UI integration issues"
|
||||
```
|
||||
Reference in New Issue
Block a user