import Foundation import Observation @MainActor @Observable final class AppViewModel { var selectedMailbox: Mailbox = .inbox var selectedThreadID: MailThread.ID? var focusedMessageRouteID: String? var searchText = "" var showUnreadOnly = false var isComposing = false var composeDraft = ComposeDraft() var threads: [MailThread] = [] var isLoading = false var isSending = false var errorMessage: String? var mailboxNavigationToken = UUID() var threadNavigationToken = UUID() private let service: MailServicing private let controlService: AppControlServicing private var pendingNavigationCommand: AppNavigationCommand? private var isListeningForBackendCommands = false init( service: MailServicing = MockMailService(), controlService: AppControlServicing = MockBackendControlService() ) { self.service = service self.controlService = controlService if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) { apply(command: command) } } var selectedThread: MailThread? { get { threads.first(where: { $0.id == selectedThreadID }) } set { selectedThreadID = newValue?.id } } var filteredThreads: [MailThread] { threads .filter { thread in selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox } .filter { thread in !showUnreadOnly || thread.isUnread } .filter(matchesSearch) .sorted { $0.lastUpdated > $1.lastUpdated } } var totalUnreadCount: Int { threads.filter(\.isUnread).count } func threadCount(in mailbox: Mailbox) -> Int { threads.filter { thread in mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox } .count } func unreadCount(in mailbox: Mailbox) -> Int { threads.filter { thread in let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox return matchesMailbox && thread.isUnread } .count } func load() async { guard threads.isEmpty else { return } isLoading = true defer { isLoading = false } do { threads = try await service.loadThreads() if let command = pendingNavigationCommand { pendingNavigationCommand = nil apply(command: command) } else { reconcileSelectionForCurrentFilters() } } catch { errorMessage = "Unable to load mail." } } func toggleStar(for thread: MailThread) { toggleStar(forThreadID: thread.id) } func toggleStar(forThreadID threadID: MailThread.ID) { guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } var updatedThread = threads[index] updatedThread.isStarred.toggle() threads[index] = updatedThread reconcileSelectionForCurrentFilters() } func toggleRead(for thread: MailThread) { toggleRead(forThreadID: thread.id) } func toggleRead(forThreadID threadID: MailThread.ID) { guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } var updatedThread = threads[index] updatedThread.isUnread.toggle() threads[index] = updatedThread reconcileSelectionForCurrentFilters() } func selectMailbox(_ mailbox: Mailbox) { selectedMailbox = mailbox clearThreadSelection() mailboxNavigationToken = UUID() } func setUnreadOnly(_ unreadOnly: Bool) { showUnreadOnly = unreadOnly clearThreadSelection() mailboxNavigationToken = UUID() } func setSearchText(_ text: String) { searchText = text reconcileSelectionForCurrentFilters() } func startCompose() { composeDraft = ComposeDraft() focusedMessageRouteID = nil isComposing = true } func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) { guard let thread = thread(withID: threadID) else { return } selectedThreadID = threadID if let focusedMessageRouteID, thread.messages.contains(where: { $0.routeID == focusedMessageRouteID }) { self.focusedMessageRouteID = focusedMessageRouteID } else { self.focusedMessageRouteID = nil } threadNavigationToken = UUID() } func dismissThreadSelection() { clearThreadSelection() } func beginBackendControl() async { guard !isListeningForBackendCommands else { return } isListeningForBackendCommands = true defer { isListeningForBackendCommands = false } for await command in controlService.commands() { apply(command: command) } } func apply(url: URL) { guard let command = AppNavigationCommand.from(url: url) else { errorMessage = "Unable to open requested destination." return } apply(command: command) } func apply(command: AppNavigationCommand) { switch command { case let .mailbox(mailbox, search, unreadOnly): isComposing = false searchText = search ?? "" showUnreadOnly = unreadOnly ?? false selectMailbox(mailbox) case let .thread(threadRouteID, mailbox, messageRouteID, search, unreadOnly): guard !threads.isEmpty else { pendingNavigationCommand = command return } searchText = search ?? "" showUnreadOnly = unreadOnly ?? false isComposing = false guard let thread = threads.first(where: { $0.routeID == threadRouteID }) else { errorMessage = "Unable to open requested conversation." return } selectedMailbox = mailbox ?? thread.mailbox openThread(withID: thread.id, focusedMessageRouteID: messageRouteID) case let .compose(draft): focusedMessageRouteID = nil composeDraft = draft isComposing = true } } func sendCurrentDraft() async -> Bool { guard !isSending else { return false } let draft = composeDraft isSending = true defer { isSending = false } do { let sentThread = try await service.send(draft: draft) threads.insert(sentThread, at: 0) selectedMailbox = .sent openThread(withID: sentThread.id) isComposing = false return true } catch { errorMessage = "Unable to send message." return false } } private func matchesSearch(thread: MailThread) -> Bool { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return true } let haystack = [ thread.subject, thread.previewText, thread.participants.map(\.name).joined(separator: " "), thread.tags.joined(separator: " ") ] .joined(separator: " ") .localizedLowercase return haystack.contains(query.localizedLowercase) } func thread(withID threadID: MailThread.ID) -> MailThread? { threads.first(where: { $0.id == threadID }) } private func clearThreadSelection() { selectedThreadID = nil focusedMessageRouteID = nil } private func reconcileSelectionForCurrentFilters() { if let selectedThreadID, filteredThreads.contains(where: { $0.id == selectedThreadID }) { return } clearThreadSelection() } }