add compose flow: ComposeViewModel with draft auto-save, ComposeView with form UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
330
Apps/MagnumOpus/ViewModels/ComposeViewModel.swift
Normal file
330
Apps/MagnumOpus/ViewModels/ComposeViewModel.swift
Normal file
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
import MailStore
|
||||
import SyncEngine
|
||||
import SMTPClient
|
||||
|
||||
enum ComposeMode: Sendable {
|
||||
case new
|
||||
case reply(to: MessageSummary)
|
||||
case replyAll(to: MessageSummary)
|
||||
case forward(of: MessageSummary)
|
||||
case draft(DraftRecord)
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ComposeViewModel {
|
||||
var to: String = ""
|
||||
var cc: String = ""
|
||||
var bcc: String = ""
|
||||
var subject: String = ""
|
||||
var bodyText: String = ""
|
||||
|
||||
var isSending: Bool = false
|
||||
var errorMessage: String?
|
||||
|
||||
let mode: ComposeMode
|
||||
|
||||
private let accountConfig: AccountConfig
|
||||
private let store: MailStore
|
||||
private let actionQueue: ActionQueue
|
||||
|
||||
private var draftId: String?
|
||||
private var savedTo: String = ""
|
||||
private var savedCc: String = ""
|
||||
private var savedBcc: String = ""
|
||||
private var savedSubject: String = ""
|
||||
private var savedBodyText: String = ""
|
||||
private var autoSaveTask: Task<Void, Never>?
|
||||
|
||||
var isDirty: Bool {
|
||||
to != savedTo || cc != savedCc || bcc != savedBcc
|
||||
|| subject != savedSubject || bodyText != savedBodyText
|
||||
}
|
||||
|
||||
init(mode: ComposeMode, accountConfig: AccountConfig, store: MailStore, actionQueue: ActionQueue) {
|
||||
self.mode = mode
|
||||
self.accountConfig = accountConfig
|
||||
self.store = store
|
||||
self.actionQueue = actionQueue
|
||||
|
||||
prefill(mode: mode)
|
||||
snapshotSavedState()
|
||||
startAutoSave()
|
||||
}
|
||||
|
||||
deinit {
|
||||
autoSaveTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
func send() async throws {
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let toAddresses = parseAddressList(to)
|
||||
guard !toAddresses.isEmpty else {
|
||||
throw ComposeError.noRecipients
|
||||
}
|
||||
|
||||
let ccAddresses = parseAddressList(cc)
|
||||
let bccAddresses = parseAddressList(bcc)
|
||||
|
||||
let domain = MessageFormatter.domainFromEmail(accountConfig.email)
|
||||
let messageId = MessageFormatter.generateMessageId(domain: domain)
|
||||
let from = EmailAddress(name: accountConfig.name, address: accountConfig.email)
|
||||
|
||||
var inReplyTo: String?
|
||||
var references: String?
|
||||
|
||||
switch mode {
|
||||
case .reply(let original), .replyAll(let original):
|
||||
inReplyTo = original.messageId
|
||||
if let origId = original.messageId {
|
||||
references = "<\(origId)>"
|
||||
}
|
||||
case .forward(let original):
|
||||
if let origId = original.messageId {
|
||||
references = "<\(origId)>"
|
||||
}
|
||||
case .new, .draft:
|
||||
break
|
||||
}
|
||||
|
||||
let outgoing = OutgoingMessage(
|
||||
from: from,
|
||||
to: toAddresses,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
subject: subject,
|
||||
bodyText: bodyText,
|
||||
inReplyTo: inReplyTo,
|
||||
references: references,
|
||||
messageId: messageId
|
||||
)
|
||||
|
||||
let formatted = MessageFormatter.format(outgoing)
|
||||
|
||||
let sendAction = PendingAction(
|
||||
accountId: accountConfig.id,
|
||||
actionType: .send,
|
||||
payload: .send(message: outgoing)
|
||||
)
|
||||
let appendAction = PendingAction(
|
||||
accountId: accountConfig.id,
|
||||
actionType: .append,
|
||||
payload: .append(mailbox: "Sent", messageData: formatted, flags: ["\\Seen"])
|
||||
)
|
||||
|
||||
try await actionQueue.enqueue(sendAction)
|
||||
try await actionQueue.enqueue(appendAction)
|
||||
|
||||
if let draftId {
|
||||
try? store.deleteDraft(id: draftId)
|
||||
self.draftId = nil
|
||||
}
|
||||
|
||||
snapshotSavedState()
|
||||
isSending = false
|
||||
} catch {
|
||||
isSending = false
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Draft Management
|
||||
|
||||
func saveDraft() {
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
let id = draftId ?? UUID().uuidString
|
||||
|
||||
var inReplyTo: String?
|
||||
var forwardOf: String?
|
||||
|
||||
switch mode {
|
||||
case .reply(let msg), .replyAll(let msg):
|
||||
inReplyTo = msg.messageId
|
||||
case .forward(let msg):
|
||||
forwardOf = msg.messageId
|
||||
case .new, .draft:
|
||||
break
|
||||
}
|
||||
|
||||
let draft = DraftRecord(
|
||||
id: id,
|
||||
accountId: accountConfig.id,
|
||||
inReplyTo: inReplyTo,
|
||||
forwardOf: forwardOf,
|
||||
toAddresses: encodeAddresses(to),
|
||||
ccAddresses: encodeAddresses(cc),
|
||||
bccAddresses: encodeAddresses(bcc),
|
||||
subject: subject,
|
||||
bodyText: bodyText,
|
||||
createdAt: draftId == nil ? now : (try? store.draft(id: id))?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
do {
|
||||
if draftId != nil {
|
||||
try store.updateDraft(draft)
|
||||
} else {
|
||||
try store.insertDraft(draft)
|
||||
draftId = id
|
||||
}
|
||||
snapshotSavedState()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDraft() {
|
||||
guard let draftId else { return }
|
||||
try? store.deleteDraft(id: draftId)
|
||||
self.draftId = nil
|
||||
}
|
||||
|
||||
// MARK: - Prefill
|
||||
|
||||
private func prefill(mode: ComposeMode) {
|
||||
switch mode {
|
||||
case .new:
|
||||
break
|
||||
|
||||
case .reply(let original):
|
||||
if let sender = original.from {
|
||||
to = MessageFormatter.formatAddress(sender)
|
||||
}
|
||||
subject = prefixSubject("Re:", original.subject ?? "")
|
||||
bodyText = quoteBody(original)
|
||||
|
||||
case .replyAll(let original):
|
||||
if let sender = original.from {
|
||||
to = MessageFormatter.formatAddress(sender)
|
||||
}
|
||||
// CC = all other recipients minus self
|
||||
let selfAddress = accountConfig.email.lowercased()
|
||||
let others = (original.to + original.cc)
|
||||
.filter { $0.address.lowercased() != selfAddress }
|
||||
.filter { $0.address.lowercased() != original.from?.address.lowercased() }
|
||||
cc = others.map(MessageFormatter.formatAddress).joined(separator: ", ")
|
||||
subject = prefixSubject("Re:", original.subject ?? "")
|
||||
bodyText = quoteBody(original)
|
||||
|
||||
case .forward(let original):
|
||||
subject = prefixSubject("Fwd:", original.subject ?? "")
|
||||
bodyText = forwardBody(original)
|
||||
|
||||
case .draft(let draft):
|
||||
to = draft.toAddresses ?? ""
|
||||
cc = draft.ccAddresses ?? ""
|
||||
bcc = draft.bccAddresses ?? ""
|
||||
subject = draft.subject ?? ""
|
||||
bodyText = draft.bodyText ?? ""
|
||||
draftId = draft.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auto-Save
|
||||
|
||||
private func startAutoSave() {
|
||||
autoSaveTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
guard !Task.isCancelled else { break }
|
||||
guard let self else { break }
|
||||
if self.isDirty && !self.isSending {
|
||||
self.saveDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotSavedState() {
|
||||
savedTo = to
|
||||
savedCc = cc
|
||||
savedBcc = bcc
|
||||
savedSubject = subject
|
||||
savedBodyText = bodyText
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func prefixSubject(_ prefix: String, _ original: String) -> String {
|
||||
// Strip existing Re:/Fwd: prefixes (case-insensitive)
|
||||
var stripped = original
|
||||
while let range = stripped.range(
|
||||
of: #"^(Re|Fwd|Fw)\s*:\s*"#,
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
) {
|
||||
stripped = String(stripped[range.upperBound...])
|
||||
}
|
||||
return "\(prefix) \(stripped)"
|
||||
}
|
||||
|
||||
func quoteBody(_ msg: MessageSummary) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .short
|
||||
let dateString = dateFormatter.string(from: msg.date)
|
||||
let sender = msg.from?.displayName ?? "Unknown"
|
||||
let originalBody = msg.bodyText ?? ""
|
||||
let quoted = originalBody
|
||||
.components(separatedBy: .newlines)
|
||||
.map { "> \($0)" }
|
||||
.joined(separator: "\n")
|
||||
return "\n\nOn \(dateString), \(sender) wrote:\n\(quoted)"
|
||||
}
|
||||
|
||||
func forwardBody(_ msg: MessageSummary) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .short
|
||||
let dateString = dateFormatter.string(from: msg.date)
|
||||
let from = msg.from.map(MessageFormatter.formatAddress) ?? "Unknown"
|
||||
let toList = msg.to.map(MessageFormatter.formatAddress).joined(separator: ", ")
|
||||
let subjectLine = msg.subject ?? "(No Subject)"
|
||||
let body = msg.bodyText ?? ""
|
||||
|
||||
return """
|
||||
|
||||
|
||||
---------- Forwarded message ----------
|
||||
From: \(from)
|
||||
Date: \(dateString)
|
||||
Subject: \(subjectLine)
|
||||
To: \(toList)
|
||||
|
||||
\(body)
|
||||
"""
|
||||
}
|
||||
|
||||
func parseAddressList(_ field: String) -> [EmailAddress] {
|
||||
let trimmed = field.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
return trimmed
|
||||
.components(separatedBy: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map(EmailAddress.parse)
|
||||
}
|
||||
|
||||
private func encodeAddresses(_ field: String) -> String? {
|
||||
let trimmed = field.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
enum ComposeError: LocalizedError {
|
||||
case noRecipients
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noRecipients:
|
||||
return "At least one recipient is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Apps/MagnumOpus/Views/ComposeView.swift
Normal file
138
Apps/MagnumOpus/Views/ComposeView.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
struct ComposeView: View {
|
||||
@Bindable var viewModel: ComposeViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showBcc = false
|
||||
@State private var showDiscardConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
content
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
#else
|
||||
NavigationStack {
|
||||
content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
Form {
|
||||
Section {
|
||||
TextField("To", text: $viewModel.to)
|
||||
#if os(iOS)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
|
||||
TextField("CC", text: $viewModel.cc)
|
||||
#if os(iOS)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
|
||||
if showBcc {
|
||||
TextField("BCC", text: $viewModel.bcc)
|
||||
#if os(iOS)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
}
|
||||
|
||||
TextField("Subject", text: $viewModel.subject)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextEditor(text: $viewModel.bodyText)
|
||||
.frame(minHeight: 200)
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(navigationTitle)
|
||||
#if os(macOS)
|
||||
.navigationSubtitle(viewModel.subject)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Discard") {
|
||||
if viewModel.isDirty {
|
||||
showDiscardConfirmation = true
|
||||
} else {
|
||||
viewModel.deleteDraft()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button {
|
||||
showBcc.toggle()
|
||||
} label: {
|
||||
Label("BCC", systemImage: showBcc ? "eye.slash" : "eye")
|
||||
}
|
||||
.help("Toggle BCC field")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
Task {
|
||||
try? await viewModel.send()
|
||||
if viewModel.errorMessage == nil {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isSending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Label("Send", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(viewModel.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Discard Draft?", isPresented: $showDiscardConfirmation) {
|
||||
Button("Discard", role: .destructive) {
|
||||
viewModel.deleteDraft()
|
||||
dismiss()
|
||||
}
|
||||
Button("Save Draft") {
|
||||
viewModel.saveDraft()
|
||||
dismiss()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You have unsaved changes.")
|
||||
}
|
||||
.onDisappear {
|
||||
if viewModel.isDirty && !viewModel.isSending {
|
||||
viewModel.saveDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
switch viewModel.mode {
|
||||
case .new: return "New Message"
|
||||
case .reply: return "Reply"
|
||||
case .replyAll: return "Reply All"
|
||||
case .forward: return "Forward"
|
||||
case .draft: return "Draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user