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 SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import Models
|
import Models
|
||||||
import MailStore
|
import MailStore
|
||||||
import SyncEngine
|
import SyncEngine
|
||||||
import SMTPClient
|
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 {
|
enum ComposeMode: Sendable {
|
||||||
case new
|
case new
|
||||||
case reply(to: MessageSummary)
|
case reply(to: MessageSummary)
|
||||||
@@ -21,6 +31,9 @@ final class ComposeViewModel {
|
|||||||
var subject: String = ""
|
var subject: String = ""
|
||||||
var bodyText: String = ""
|
var bodyText: String = ""
|
||||||
|
|
||||||
|
var attachments: [ComposeAttachment] = []
|
||||||
|
var sizeWarningMessage: String?
|
||||||
|
|
||||||
var isSending: Bool = false
|
var isSending: Bool = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -59,6 +72,35 @@ final class ComposeViewModel {
|
|||||||
// since it holds a weak reference to self
|
// 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
|
// MARK: - Send
|
||||||
|
|
||||||
func send() async throws {
|
func send() async throws {
|
||||||
@@ -104,10 +146,19 @@ final class ComposeViewModel {
|
|||||||
bodyText: bodyText,
|
bodyText: bodyText,
|
||||||
inReplyTo: inReplyTo,
|
inReplyTo: inReplyTo,
|
||||||
references: references,
|
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(
|
let sendAction = PendingAction(
|
||||||
accountId: accountConfig.id,
|
accountId: accountConfig.id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import Models
|
import Models
|
||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@@ -7,6 +8,7 @@ struct ComposeView: View {
|
|||||||
|
|
||||||
@State private var showBcc = false
|
@State private var showBcc = false
|
||||||
@State private var showDiscardConfirmation = false
|
@State private var showDiscardConfirmation = false
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -49,8 +51,50 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
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)
|
TextEditor(text: $viewModel.bodyText)
|
||||||
.frame(minHeight: 200)
|
.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 {
|
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)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(navigationTitle)
|
.navigationTitle(navigationTitle)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -86,6 +142,16 @@ struct ComposeView: View {
|
|||||||
.help("Toggle BCC field")
|
.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) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button {
|
Button {
|
||||||
Task {
|
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