Add MailRootView and related components for mail functionality
- Implement MailRootView with navigation and sidebar for mail management. - Create MailSidebarView, ThreadListView, and ThreadDetailView for displaying mail content. - Introduce ComposeView for composing new messages. - Add MailTheme for consistent styling across mail components. - Implement adaptive layouts for iOS and macOS. - Create unit tests for AppNavigationCommand and AppViewModel to ensure correct functionality.
This commit is contained in:
264
swift/Sources/App/AppViewModel.swift
Normal file
264
swift/Sources/App/AppViewModel.swift
Normal file
@@ -0,0 +1,264 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user