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:
2026-03-14 05:50:42 +01:00
parent 7d847693d7
commit 5c0c5a5bda
2 changed files with 468 additions and 0 deletions

View 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."
}
}
}

View 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"
}
}
}