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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user