diff --git a/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift b/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift index 49fd71f..410b003 100644 --- a/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift @@ -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, diff --git a/Apps/MagnumOpus/Views/ComposeView.swift b/Apps/MagnumOpus/Views/ComposeView.swift index 6ce1a18..772d32d 100644 --- a/Apps/MagnumOpus/Views/ComposeView.swift +++ b/Apps/MagnumOpus/Views/ComposeView.swift @@ -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, 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