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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
45
Apps/MagnumOpus/Views/MoveToSheet.swift
Normal file
45
Apps/MagnumOpus/Views/MoveToSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user