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:
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
protocol AppControlServicing {
|
||||
func commands() -> AsyncStream<AppNavigationCommand>
|
||||
}
|
||||
|
||||
struct MockBackendControlService: AppControlServicing {
|
||||
static let controlFileEnvironmentKey = "SOCIALIO_CONTROL_FILE"
|
||||
static let pollingIntervalEnvironmentKey = "SOCIALIO_CONTROL_POLL_MS"
|
||||
|
||||
private let environment: [String: String]
|
||||
|
||||
init(environment: [String: String] = ProcessInfo.processInfo.environment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func commands() -> AsyncStream<AppNavigationCommand> {
|
||||
guard let controlFilePath = environment[Self.controlFileEnvironmentKey]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!controlFilePath.isEmpty else {
|
||||
return AsyncStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let controlFileURL = URL(fileURLWithPath: controlFilePath)
|
||||
let pollingInterval = pollingIntervalDuration
|
||||
|
||||
return AsyncStream { continuation in
|
||||
let task = Task.detached(priority: .background) {
|
||||
var lastAppliedPayload: String?
|
||||
|
||||
while !Task.isCancelled {
|
||||
if let payload = try? String(contentsOf: controlFileURL, encoding: .utf8) {
|
||||
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !trimmedPayload.isEmpty,
|
||||
trimmedPayload != lastAppliedPayload,
|
||||
let command = AppNavigationCommand.parse(trimmedPayload) {
|
||||
lastAppliedPayload = trimmedPayload
|
||||
continuation.yield(command)
|
||||
}
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: pollingInterval)
|
||||
}
|
||||
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pollingIntervalDuration: Duration {
|
||||
guard let rawValue = environment[Self.pollingIntervalEnvironmentKey],
|
||||
let milliseconds = Int(rawValue),
|
||||
milliseconds > 0 else {
|
||||
return .milliseconds(600)
|
||||
}
|
||||
|
||||
return .milliseconds(milliseconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import Foundation
|
||||
|
||||
enum AppNavigationCommand: Equatable {
|
||||
case mailbox(mailbox: Mailbox, search: String?, unreadOnly: Bool?)
|
||||
case thread(
|
||||
threadRouteID: String,
|
||||
mailbox: Mailbox?,
|
||||
messageRouteID: String?,
|
||||
search: String?,
|
||||
unreadOnly: Bool?
|
||||
)
|
||||
case compose(draft: ComposeDraft)
|
||||
|
||||
static let routeEnvironmentKey = "SOCIALIO_ROUTE"
|
||||
static let jsonEnvironmentKey = "SOCIALIO_COMMAND_JSON"
|
||||
|
||||
static func from(environment: [String: String]) -> AppNavigationCommand? {
|
||||
if let json = environment[jsonEnvironmentKey] {
|
||||
return from(json: json)
|
||||
}
|
||||
|
||||
if let route = environment[routeEnvironmentKey] {
|
||||
return parse(route)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parse(_ rawValue: String) -> AppNavigationCommand? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("{") {
|
||||
return from(json: trimmed)
|
||||
}
|
||||
|
||||
guard let url = URL(string: trimmed) else { return nil }
|
||||
return from(url: url)
|
||||
}
|
||||
|
||||
static func from(url: URL) -> AppNavigationCommand? {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let host = (components.host ?? "").lowercased()
|
||||
let pathComponents = url.path
|
||||
.split(separator: "/")
|
||||
.map(String.init)
|
||||
|
||||
let queryItems = components.queryItems ?? []
|
||||
let mailbox = queryItems.value(named: "mailbox").flatMap(Mailbox.init(rawValue:))
|
||||
let threadRouteID = queryItems.value(named: "thread")
|
||||
let messageRouteID = queryItems.value(named: "message")
|
||||
let search = queryItems.value(named: "search")
|
||||
let unreadOnly = queryItems.value(named: "unreadOnly").flatMap(Bool.init)
|
||||
|
||||
switch host {
|
||||
case "mailbox":
|
||||
guard let mailboxID = pathComponents.first, let mailbox = Mailbox(rawValue: mailboxID) else {
|
||||
return nil
|
||||
}
|
||||
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||
|
||||
case "thread":
|
||||
guard let routeID = pathComponents.first else { return nil }
|
||||
return .thread(
|
||||
threadRouteID: routeID,
|
||||
mailbox: mailbox,
|
||||
messageRouteID: messageRouteID,
|
||||
search: search,
|
||||
unreadOnly: unreadOnly
|
||||
)
|
||||
|
||||
case "compose":
|
||||
return .compose(
|
||||
draft: ComposeDraft(
|
||||
to: queryItems.value(named: "to") ?? "",
|
||||
subject: queryItems.value(named: "subject") ?? "",
|
||||
body: queryItems.value(named: "body") ?? ""
|
||||
)
|
||||
)
|
||||
|
||||
case "open", "":
|
||||
if let threadRouteID {
|
||||
return .thread(
|
||||
threadRouteID: threadRouteID,
|
||||
mailbox: mailbox,
|
||||
messageRouteID: messageRouteID,
|
||||
search: search,
|
||||
unreadOnly: unreadOnly
|
||||
)
|
||||
}
|
||||
|
||||
if let mailbox {
|
||||
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||
}
|
||||
|
||||
if queryItems.value(named: "to") != nil ||
|
||||
queryItems.value(named: "subject") != nil ||
|
||||
queryItems.value(named: "body") != nil {
|
||||
return .compose(
|
||||
draft: ComposeDraft(
|
||||
to: queryItems.value(named: "to") ?? "",
|
||||
subject: queryItems.value(named: "subject") ?? "",
|
||||
body: queryItems.value(named: "body") ?? ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func from(json: String) -> AppNavigationCommand? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
do {
|
||||
let payload = try decoder.decode(AppNavigationPayload.self, from: data)
|
||||
return payload.command
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppNavigationPayload: Decodable {
|
||||
enum Kind: String, Decodable {
|
||||
case mailbox
|
||||
case thread
|
||||
case compose
|
||||
}
|
||||
|
||||
let kind: Kind?
|
||||
let mailbox: Mailbox?
|
||||
let threadID: String?
|
||||
let messageID: String?
|
||||
let search: String?
|
||||
let unreadOnly: Bool?
|
||||
let to: String?
|
||||
let subject: String?
|
||||
let body: String?
|
||||
|
||||
var command: AppNavigationCommand? {
|
||||
switch kind {
|
||||
case .mailbox:
|
||||
guard let mailbox else { return nil }
|
||||
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||
|
||||
case .thread:
|
||||
guard let threadID else { return nil }
|
||||
return .thread(
|
||||
threadRouteID: threadID,
|
||||
mailbox: mailbox,
|
||||
messageRouteID: messageID,
|
||||
search: search,
|
||||
unreadOnly: unreadOnly
|
||||
)
|
||||
|
||||
case .compose:
|
||||
return .compose(
|
||||
draft: ComposeDraft(
|
||||
to: to ?? "",
|
||||
subject: subject ?? "",
|
||||
body: body ?? ""
|
||||
)
|
||||
)
|
||||
|
||||
case nil:
|
||||
if let threadID {
|
||||
return .thread(
|
||||
threadRouteID: threadID,
|
||||
mailbox: mailbox,
|
||||
messageRouteID: messageID,
|
||||
search: search,
|
||||
unreadOnly: unreadOnly
|
||||
)
|
||||
}
|
||||
|
||||
if let mailbox {
|
||||
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||
}
|
||||
|
||||
if to != nil || subject != nil || body != nil {
|
||||
return .compose(
|
||||
draft: ComposeDraft(
|
||||
to: to ?? "",
|
||||
subject: subject ?? "",
|
||||
body: body ?? ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension [URLQueryItem] {
|
||||
func value(named name: String) -> String? {
|
||||
first(where: { $0.name == name })?.value
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct SocialIOApp: App {
|
||||
@State private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MailRootView(model: model)
|
||||
.tint(MailTheme.accent)
|
||||
.onOpenURL { url in
|
||||
model.apply(url: url)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 1440, height: 900)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user