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 laneFilter: Lane? var isComposing = false var isCommandPalettePresented = false var composeDraft = ComposeDraft(from: "phil@social.io") var threads: [MailThread] = [] var isLoading = false var isSending = false var errorMessage: String? var mailboxNavigationToken = UUID() var threadNavigationToken = UUID() let currentUser = MailPerson(name: "Phil Kunz", email: "phil@social.io") private let service: MailServicing private let controlService: AppControlServicing private var pendingNavigationCommand: AppNavigationCommand? private var isListeningForBackendCommands = false @ObservationIgnored private var composeAutosaveTask: Task? @ObservationIgnored private let autosaveKey = "sio.compose.autosave" init( service: MailServicing = MockMailService(), controlService: AppControlServicing = MockBackendControlService() ) { self.service = service self.controlService = controlService restoreAutosavedDraft() 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] { baseThreads(for: selectedMailbox) .filter { thread in laneFilter == nil || thread.lane == laneFilter } .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 { baseThreads(for: mailbox).count } func unreadCount(in mailbox: Mailbox) -> Int { baseThreads(for: mailbox) .filter { $0.isUnread } .count } func laneCount(_ lane: Lane?, in mailbox: Mailbox? = nil) -> Int { baseThreads(for: mailbox ?? selectedMailbox) .filter { lane == nil || $0.lane == lane } .count } func unreadCount(for lane: Lane?) -> Int { baseThreads(for: selectedMailbox) .filter { thread in thread.isUnread && (lane == nil || thread.lane == lane) } .count } var screenerThreads: [MailThread] { baseThreads(for: .screener) .sorted { $0.lastUpdated > $1.lastUpdated } } var searchResults: [MailThread] { threads .filter(matchesSearch) .sorted { lhs, rhs in let lhsScore = score(for: lhs) let rhsScore = score(for: rhs) if lhsScore == rhsScore { return lhs.lastUpdated > rhs.lastUpdated } return lhsScore > rhsScore } } var folderNames: [String] { Array(Set(threads.flatMap(\.tags))) .filter { !$0.isEmpty && !["Draft", "Launch", "Search", "System", "External", "Sent"].contains($0) } .sorted() } var topSearchResult: MailThread? { searchResults.first } var remainingSearchResults: [MailThread] { Array(searchResults.dropFirst()) } var isSearchActive: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } func setLaneFilter(_ lane: Lane?) { laneFilter = lane reconcileSelectionForCurrentFilters() } func cycleToNextLane() { let allFilters: [Lane?] = [nil] + Lane.allCases guard let currentIndex = allFilters.firstIndex(where: { $0 == laneFilter }) else { laneFilter = nil return } laneFilter = allFilters[(currentIndex + 1) % allFilters.count] reconcileSelectionForCurrentFilters() } func queueDraftAutosave() { let snapshot = composeDraft composeAutosaveTask?.cancel() composeAutosaveTask = Task { [autosaveKey] in try? await Task.sleep(for: .milliseconds(800)) guard !Task.isCancelled else { return } if let data = try? JSONEncoder().encode(snapshot) { UserDefaults.standard.set(data, forKey: autosaveKey) } } } 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() } await refreshLiveActivity() } 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() { if composeDraft.isEmpty { restoreAutosavedDraft() } if composeDraft.isEmpty { composeDraft = ComposeDraft(from: currentUser.email) } else if composeDraft.from.isEmpty { composeDraft.from = currentUser.email } focusedMessageRouteID = nil isComposing = true } func dismissCompose() { isComposing = false } func discardCompose() { composeAutosaveTask?.cancel() UserDefaults.standard.removeObject(forKey: autosaveKey) composeDraft = ComposeDraft(from: currentUser.email) isComposing = false } func startReply(to threadID: MailThread.ID, replyAll: Bool = false, forward: Bool = false) { guard let thread = thread(withID: threadID) else { return } let latestMessage = thread.latestMessage let recipients: [MailPerson] if forward { recipients = [] } else if replyAll { recipients = thread.participants.filter { $0.email != currentUser.email } } else if let latestMessage { recipients = [latestMessage.sender] } else { recipients = thread.participants.filter { $0.email != currentUser.email } } let replyPrefix = forward ? "Fwd:" : "Re:" let quotedBody = latestMessage?.body .split(separator: "\n", omittingEmptySubsequences: false) .prefix(4) .map { "> \($0)" } .joined(separator: "\n") ?? "" composeDraft = ComposeDraft( to: recipients.map(\.email).joined(separator: ", "), cc: "", from: currentUser.email, subject: thread.subject.hasPrefix(replyPrefix) ? thread.subject : "\(replyPrefix) \(thread.subject)", body: forward || quotedBody.isEmpty ? "" : "\n\n\(quotedBody)" ) focusedMessageRouteID = nil isComposing = true } func moveThread(withID threadID: MailThread.ID, to mailbox: Mailbox) { guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } threads[index].mailbox = mailbox if mailbox == .trash { threads[index].isUnread = false } reconcileSelectionForCurrentFilters() } func sendInlineReply(_ body: String, in threadID: MailThread.ID) { let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedBody.isEmpty, let index = threads.firstIndex(where: { $0.id == threadID }) else { return } let recipients = threads[index].participants.filter { $0.email != currentUser.email } threads[index].messages.append( MailMessage( routeID: "\(threads[index].routeID)-reply-\(threads[index].messages.count + 1)", sender: currentUser, recipients: recipients, sentAt: .now, body: trimmedBody ) ) threads[index].isUnread = false openThread(withID: threadID) } func snoozeThread(withID threadID: MailThread.ID) { moveThread(withID: threadID, to: .snoozed) } func applyScreenerDecision(_ decision: ScreenDecision, to threadID: MailThread.ID) { guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } switch decision { case .approve: threads[index].mailbox = .inbox threads[index].isScreeningCandidate = false case .block: threads[index].mailbox = .trash threads[index].isUnread = false threads[index].isScreeningCandidate = false case .sendToPaper: threads[index].mailbox = .inbox threads[index].lane = .paper threads[index].isScreeningCandidate = false } reconcileSelectionForCurrentFilters() Task { try? await service.screen(threadID: threadID, as: decision) } } 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 = normalizedDraft(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 UserDefaults.standard.removeObject(forKey: autosaveKey) composeDraft = ComposeDraft(from: currentUser.email) 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: " "), thread.lane.label, thread.summary?.joined(separator: " ") ?? "", thread.messages.map(\.body).joined(separator: " "), thread.messages.flatMap(\.attachments).map(\.name).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 baseThreads(for mailbox: Mailbox) -> [MailThread] { switch mailbox { case .starred: return threads.filter(\.isStarred) case .screener: return threads.filter(\.isScreeningCandidate) default: return threads.filter { $0.mailbox == mailbox } } } private func score(for thread: MailThread) -> Int { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase guard !query.isEmpty else { return 0 } var score = 0 if thread.subject.localizedLowercase.contains(query) { score += 4 } if thread.participants.map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 3 } if thread.previewText.localizedLowercase.contains(query) { score += 2 } if thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 1 } return score } private func normalizedDraft(_ draft: ComposeDraft) -> ComposeDraft { var normalized = draft if normalized.from.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { normalized.from = currentUser.email } return normalized } private func restoreAutosavedDraft() { guard let data = UserDefaults.standard.data(forKey: autosaveKey), let draft = try? JSONDecoder().decode(ComposeDraft.self, from: data) else { if composeDraft.from.isEmpty { composeDraft = ComposeDraft(from: currentUser.email) } return } composeDraft = normalizedDraft(draft) } private func refreshLiveActivity() async { #if os(iOS) if let thread = threads.first(where: { $0.mailbox == .inbox && $0.isUnread }) { await MailNotificationActivityController.startIfNeeded(with: thread) } #endif } private func reconcileSelectionForCurrentFilters() { if let selectedThreadID, filteredThreads.contains(where: { $0.id == selectedThreadID }) { return } clearThreadSelection() } }