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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user