add attachment chips to message view with download, Quick Look preview

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 11:36:05 +01:00
parent 5c547f6faa
commit 016c163b75
3 changed files with 130 additions and 1 deletions

View File

@@ -58,6 +58,7 @@ struct ContentView: View {
thread: viewModel.selectedThread,
messages: viewModel.messages,
linkedTasks: viewModel.selectedThread.map { viewModel.tasksLinkedToThread(threadId: $0.id) } ?? [],
viewModel: viewModel,
onComposeRequest: { mode in
requestCompose(mode)
}

View File

@@ -661,6 +661,28 @@ final class MailViewModel {
return message
}
// MARK: - Attachments
func attachments(messageId: String) -> [AttachmentRecord] {
guard let store else { return [] }
return (try? store.attachments(messageId: messageId)) ?? []
}
func downloadAttachment(attachmentId: String, messageId: String) async throws -> URL {
guard let store, let coordinator else {
throw URLError(.badURL)
}
guard let record = try store.message(id: messageId) else {
throw URLError(.fileDoesNotExist)
}
let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX"
return try await coordinator.downloadAttachment(
attachmentId: attachmentId,
messageUid: record.uid,
mailboxName: mailboxName
)
}
// MARK: - Helpers
private func mailboxName(for mailboxId: String) -> String? {

View File

@@ -1,10 +1,13 @@
import SwiftUI
import QuickLook
import Models
import MailStore
struct ThreadDetailView: View {
let thread: ThreadSummary?
let messages: [MessageSummary]
let linkedTasks: [TaskSummary]
let viewModel: MailViewModel
var onComposeRequest: (ComposeMode) -> Void
var body: some View {
@@ -23,7 +26,7 @@ struct ThreadDetailView: View {
ForEach(sortedEntries) { entry in
switch entry {
case .message(let message):
MessageView(message: message)
MessageView(message: message, viewModel: viewModel)
Divider()
case .task(let task):
LinkedTaskView(task: task)
@@ -187,7 +190,12 @@ struct StatusBadge: View {
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 failedIds: Set<String> = []
@State private var previewURL: URL?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -235,8 +243,58 @@ struct MessageView: View {
.font(.body)
.foregroundStyle(.tertiary)
}
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),
hasFailed: failedIds.contains(attachment.id)
) {
downloadAndPreview(attachment)
}
}
}
}
}
}
}
.padding()
.onAppear {
if message.hasAttachments {
attachments = viewModel.attachments(messageId: message.id)
}
}
.quickLookPreview($previewURL)
}
private func downloadAndPreview(_ attachment: AttachmentRecord) {
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)
}
}
}
private func plainTextToHTML(_ text: String) -> String {
@@ -248,3 +306,51 @@ struct MessageView: View {
return "<pre style=\"white-space: pre-wrap; word-wrap: break-word; font-family: -apple-system, system-ui; font-size: 14px;\">\(escaped)</pre>"
}
}
struct ReceivedAttachmentChip: View {
let attachment: AttachmentRecord
let isDownloading: Bool
let hasFailed: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 4) {
if isDownloading {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: iconName)
.foregroundStyle(hasFailed ? .red : .blue)
}
Text(attachment.filename ?? "attachment")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150)
if attachment.size > 0 {
Text("\u{00B7}")
.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"
}
}
}