add compose attachment UI: file picker, drag-drop, paste, chip strip, send path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 11:33:25 +01:00
parent 868d99e60e
commit 5c547f6faa
2 changed files with 235 additions and 2 deletions

View File

@@ -1,9 +1,19 @@
import SwiftUI
import UniformTypeIdentifiers
import Models
import MailStore
import SyncEngine
import SMTPClient
struct ComposeAttachment: Identifiable {
let id = UUID()
let url: URL
let filename: String
let mimeType: String
let size: Int
let data: Data
}
enum ComposeMode: Sendable {
case new
case reply(to: MessageSummary)
@@ -21,6 +31,9 @@ final class ComposeViewModel {
var subject: String = ""
var bodyText: String = ""
var attachments: [ComposeAttachment] = []
var sizeWarningMessage: String?
var isSending: Bool = false
var errorMessage: String?
@@ -59,6 +72,35 @@ final class ComposeViewModel {
// since it holds a weak reference to self
}
// MARK: - Attachments
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) exceeds 25 MB limit (\(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)))."
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 }
}
// MARK: - Send
func send() async throws {
@@ -104,10 +146,19 @@ final class ComposeViewModel {
bodyText: bodyText,
inReplyTo: inReplyTo,
references: references,
messageId: messageId
messageId: messageId,
attachments: attachments.map { OutgoingAttachment(filename: $0.filename, mimeType: $0.mimeType, data: $0.data) }
)
let formatted = MessageFormatter.format(outgoing)
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) }
)
}
let sendAction = PendingAction(
accountId: accountConfig.id,

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
import Models
struct ComposeView: View {
@@ -7,6 +8,7 @@ struct ComposeView: View {
@State private var showBcc = false
@State private var showDiscardConfirmation = false
@State private var showFileImporter = false
var body: some View {
#if os(macOS)
@@ -49,8 +51,50 @@ struct ComposeView: View {
}
Section {
#if os(macOS)
PastableTextEditor(text: $viewModel.bodyText) { url in
viewModel.addAttachment(url: url)
}
.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
}
#else
TextEditor(text: $viewModel.bodyText)
.frame(minHeight: 200)
#endif
}
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)
}
}
if let error = viewModel.errorMessage {
@@ -60,6 +104,18 @@ struct ComposeView: View {
}
}
}
.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
}
}
.formStyle(.grouped)
.navigationTitle(navigationTitle)
#if os(macOS)
@@ -86,6 +142,16 @@ struct ComposeView: View {
.help("Toggle BCC field")
}
ToolbarItem(placement: .automatic) {
Button {
showFileImporter = true
} label: {
Label("Attach", systemImage: "paperclip")
}
.keyboardShortcut("a", modifiers: [.shift, .command])
.help("Attach File (⇧⌘A)")
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
@@ -136,3 +202,119 @@ struct ComposeView: View {
}
}
}
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"
}
}
}
#if os(macOS)
import AppKit
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
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSText.paste(_:)) {
let pasteboard = NSPasteboard.general
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
for url in urls where url.isFileURL {
onPasteFile(url)
}
if !urls.isEmpty { return true }
}
if let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage],
let image = images.first,
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) {
let tempFile = FileManager.default.temporaryDirectory
.appendingPathComponent("pasted-image-\(UUID().uuidString).png")
try? pngData.write(to: tempFile)
onPasteFile(tempFile)
return true
}
}
return false
}
}
}
#endif