add triage UI: archive, delete, flag, read/unread, move actions with auto-advance, compose triggers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 05:55:19 +01:00
parent 5c0c5a5bda
commit 0bfcf2d610
5 changed files with 377 additions and 16 deletions

View File

@@ -1,11 +1,13 @@
import SwiftUI
import Models
import MailStore
import SyncEngine
struct ContentView: View {
@State private var viewModel = MailViewModel()
@State private var accountSetup = AccountSetupViewModel()
@State private var showingAccountSetup = false
@State private var composeMode: ComposeMode?
var body: some View {
Group {
@@ -27,23 +29,60 @@ struct ContentView: View {
private var mailView: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel)
.toolbar {
ToolbarItem(placement: .automatic) {
Button {
composeMode = .new
} label: {
Label("New Message", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
.help("New Message (⌘N)")
}
}
} content: {
ThreadListView(viewModel: viewModel)
} detail: {
ThreadDetailView(
thread: viewModel.selectedThread,
messages: viewModel.messages
messages: viewModel.messages,
composeMode: $composeMode
)
}
.safeAreaInset(edge: .bottom) {
statusBanner
}
.sheet(item: composeModeBinding) { mode in
if let config = viewModel.accountConfig,
let store = viewModel.store,
let actionQueue = viewModel.actionQueue {
ComposeView(
viewModel: ComposeViewModel(
mode: mode,
accountConfig: config,
store: store,
actionQueue: actionQueue
)
)
}
}
.task {
await viewModel.syncNow()
viewModel.startPeriodicSync()
}
}
private var composeModeBinding: Binding<ComposeModeWrapper?> {
Binding(
get: {
composeMode.map { ComposeModeWrapper(mode: $0) }
},
set: { wrapper in
composeMode = wrapper?.mode
}
)
}
@ViewBuilder
private var statusBanner: some View {
switch viewModel.syncState {
@@ -112,3 +151,9 @@ struct ContentView: View {
}
}
}
// Wrapper to make ComposeMode Identifiable for .sheet(item:)
struct ComposeModeWrapper: Identifiable {
let id = UUID()
let mode: ComposeMode
}

View File

@@ -8,8 +8,10 @@ import IMAPClient
@Observable
@MainActor
final class MailViewModel {
private var store: MailStore?
private(set) var store: MailStore?
private var coordinator: SyncCoordinator?
var actionQueue: ActionQueue?
var accountConfig: AccountConfig?
var threads: [ThreadSummary] = []
var selectedThread: ThreadSummary?
@@ -36,11 +38,27 @@ final class MailViewModel {
credentials: credentials
)
store = mailStore
accountConfig = config
coordinator = SyncCoordinator(
accountConfig: config,
imapClient: imapClient,
store: mailStore
)
let capturedHost = config.imapHost
let capturedPort = config.imapPort
let capturedCredentials = credentials
actionQueue = ActionQueue(
store: mailStore,
accountId: config.id,
imapClientProvider: {
IMAPClient(
host: capturedHost,
port: capturedPort,
credentials: capturedCredentials
)
}
)
}
func loadMailboxes(accountId: String) async {
@@ -115,6 +133,159 @@ final class MailViewModel {
messageObservation?.cancel()
}
// MARK: - Triage Actions
func archiveSelectedThread() {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else {
errorMessage = "No archive mailbox found"
return
}
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
for message in messages {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: archiveMailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: archiveMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
func deleteSelectedThread() {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
guard let trashMailbox = try store.mailboxWithRole("trash", accountId: accountConfig.id) else {
errorMessage = "No trash mailbox found"
return
}
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
let isAlreadyInTrash = selectedMailbox.id == trashMailbox.id
for message in messages {
if isAlreadyInTrash {
try store.deleteMessage(id: message.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .delete,
payload: .delete(uid: message.uid, mailbox: trashMailbox.name, trashMailbox: trashMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
} else {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: trashMailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: trashMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
func toggleFlagSelectedThread() {
guard let store, let actionQueue, let selectedThread, let accountConfig else { return }
do {
let messages = try store.messagesForThread(threadId: selectedThread.id)
guard let first = messages.first else { return }
let newFlagged = !first.isFlagged
for message in messages {
try store.updateFlags(messageId: message.id, isRead: message.isRead, isFlagged: newFlagged)
let add = newFlagged ? ["\\Flagged"] : [String]()
let remove = newFlagged ? [String]() : ["\\Flagged"]
let action = PendingAction(
accountId: accountConfig.id,
actionType: .setFlags,
payload: .setFlags(uid: message.uid, mailbox: mailboxName(for: message.mailboxId) ?? "INBOX", add: add, remove: remove)
)
Task { try await actionQueue.enqueue(action) }
}
} catch {
errorMessage = error.localizedDescription
}
}
func toggleReadSelectedThread() {
guard let store, let actionQueue, let selectedThread, let accountConfig else { return }
do {
let messages = try store.messagesForThread(threadId: selectedThread.id)
guard let first = messages.first else { return }
let newRead = !first.isRead
for message in messages {
try store.updateFlags(messageId: message.id, isRead: newRead, isFlagged: message.isFlagged)
let add = newRead ? ["\\Seen"] : [String]()
let remove = newRead ? [String]() : ["\\Seen"]
let action = PendingAction(
accountId: accountConfig.id,
actionType: .setFlags,
payload: .setFlags(uid: message.uid, mailbox: mailboxName(for: message.mailboxId) ?? "INBOX", add: add, remove: remove)
)
Task { try await actionQueue.enqueue(action) }
}
} catch {
errorMessage = error.localizedDescription
}
}
func moveSelectedThread(to mailbox: MailboxInfo) {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
for message in messages {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: mailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: mailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Auto Advance
private func autoAdvance() {
guard let current = selectedThread else { return }
guard let currentIndex = threads.firstIndex(where: { $0.id == current.id }) else {
selectedThread = nil
messages = []
return
}
let nextIndex = threads.index(after: currentIndex)
if nextIndex < threads.endIndex {
selectThread(threads[nextIndex])
} else if currentIndex > threads.startIndex {
let prevIndex = threads.index(before: currentIndex)
selectThread(threads[prevIndex])
} else {
selectedThread = nil
messages = []
messageObservation?.cancel()
}
}
// MARK: - Helpers
private func mailboxName(for mailboxId: String) -> String? {
mailboxes.first(where: { $0.id == mailboxId })?.name
}
static func databasePath(for accountId: String) -> String {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MagnumOpus", isDirectory: true)

View File

@@ -0,0 +1,45 @@
import SwiftUI
import Models
struct MoveToSheet: View {
let mailboxes: [MailboxInfo]
let onSelect: (MailboxInfo) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
private var filteredMailboxes: [MailboxInfo] {
if searchText.isEmpty {
return mailboxes
}
return mailboxes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List(filteredMailboxes) { mailbox in
Button {
onSelect(mailbox)
dismiss()
} label: {
Label(mailbox.name, systemImage: mailbox.systemImage)
}
}
.searchable(text: $searchText, prompt: "Search mailboxes")
.navigationTitle("Move to…")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
#if os(macOS)
.frame(minWidth: 300, minHeight: 400)
#endif
}
}

View File

@@ -4,6 +4,7 @@ import Models
struct ThreadDetailView: View {
let thread: ThreadSummary?
let messages: [MessageSummary]
@Binding var composeMode: ComposeMode?
var body: some View {
Group {
@@ -23,6 +24,39 @@ struct ThreadDetailView: View {
}
}
}
.toolbar {
ToolbarItemGroup(placement: .automatic) {
Button {
if let last = messages.last {
composeMode = .reply(to: last)
}
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
.keyboardShortcut("r", modifiers: .command)
.help("Reply (⌘R)")
Button {
if let last = messages.last {
composeMode = .replyAll(to: last)
}
} label: {
Label("Reply All", systemImage: "arrowshape.turn.up.left.2")
}
.keyboardShortcut("r", modifiers: [.shift, .command])
.help("Reply All (⇧⌘R)")
Button {
if let last = messages.last {
composeMode = .forward(of: last)
}
} label: {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
.keyboardShortcut("f", modifiers: .command)
.help("Forward (⌘F)")
}
}
} else {
ContentUnavailableView(
"No Thread Selected",

View File

@@ -3,6 +3,7 @@ import Models
struct ThreadListView: View {
@Bindable var viewModel: MailViewModel
@State var showMoveSheet = false
var body: some View {
List(viewModel.threads, selection: Binding(
@@ -15,6 +16,23 @@ struct ThreadListView: View {
)) { thread in
ThreadRow(thread: thread)
.tag(thread.id)
.swipeActions(edge: .leading) {
Button {
viewModel.selectThread(thread)
viewModel.archiveSelectedThread()
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.green)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
viewModel.selectThread(thread)
viewModel.deleteSelectedThread()
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.listStyle(.inset)
.navigationTitle(viewModel.selectedMailbox?.name ?? "Mail")
@@ -27,6 +45,56 @@ struct ThreadListView: View {
)
}
}
.toolbar {
ToolbarItemGroup(placement: .automatic) {
if viewModel.selectedThread != nil {
Button {
viewModel.archiveSelectedThread()
} label: {
Label("Archive", systemImage: "archivebox")
}
.keyboardShortcut("e", modifiers: [])
.help("Archive (e)")
Button {
viewModel.deleteSelectedThread()
} label: {
Label("Delete", systemImage: "trash")
}
.keyboardShortcut(.delete, modifiers: [])
.help("Delete (⌫)")
Button {
viewModel.toggleFlagSelectedThread()
} label: {
Label("Flag", systemImage: "flag")
}
.keyboardShortcut("s", modifiers: [])
.help("Toggle Flag (s)")
Button {
viewModel.toggleReadSelectedThread()
} label: {
Label("Read/Unread", systemImage: "envelope.badge")
}
.keyboardShortcut("u", modifiers: [.shift, .command])
.help("Toggle Read/Unread (⇧⌘U)")
Button {
showMoveSheet = true
} label: {
Label("Move", systemImage: "folder")
}
.keyboardShortcut("m", modifiers: [.shift, .command])
.help("Move to… (⇧⌘M)")
}
}
}
.sheet(isPresented: $showMoveSheet) {
MoveToSheet(mailboxes: viewModel.mailboxes) { mailbox in
viewModel.moveSelectedThread(to: mailbox)
}
}
}
}
@@ -40,6 +108,11 @@ struct ThreadRow: View {
.fontWeight(thread.unreadCount > 0 ? .bold : .regular)
.lineLimit(1)
Spacer()
if thread.unreadCount > 0 {
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
}
Text(thread.lastDate, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
@@ -53,20 +126,13 @@ struct ThreadRow: View {
.foregroundStyle(.tertiary)
.lineLimit(1)
}
HStack(spacing: 8) {
if thread.messageCount > 1 {
Text("\(thread.messageCount)")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
if thread.unreadCount > 0 {
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
}
if thread.messageCount > 1 {
Text("\(thread.messageCount)")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
.padding(.vertical, 2)