diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index df5d772..2c74711 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -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 { + 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 +} diff --git a/Apps/MagnumOpus/ViewModels/MailViewModel.swift b/Apps/MagnumOpus/ViewModels/MailViewModel.swift index 0b9aa85..67b0a32 100644 --- a/Apps/MagnumOpus/ViewModels/MailViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/MailViewModel.swift @@ -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) diff --git a/Apps/MagnumOpus/Views/MoveToSheet.swift b/Apps/MagnumOpus/Views/MoveToSheet.swift new file mode 100644 index 0000000..32c816c --- /dev/null +++ b/Apps/MagnumOpus/Views/MoveToSheet.swift @@ -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 + } +} diff --git a/Apps/MagnumOpus/Views/ThreadDetailView.swift b/Apps/MagnumOpus/Views/ThreadDetailView.swift index 9a531c3..7401970 100644 --- a/Apps/MagnumOpus/Views/ThreadDetailView.swift +++ b/Apps/MagnumOpus/Views/ThreadDetailView.swift @@ -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", diff --git a/Apps/MagnumOpus/Views/ThreadListView.swift b/Apps/MagnumOpus/Views/ThreadListView.swift index 77b4728..0f2bc06 100644 --- a/Apps/MagnumOpus/Views/ThreadListView.swift +++ b/Apps/MagnumOpus/Views/ThreadListView.swift @@ -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)