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:
2026-04-19 01:00:32 +02:00
parent 6b665c666f
commit ad059e9b8d
15 changed files with 19 additions and 19 deletions
+66
View File
@@ -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
}
}
+264
View 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()
}
}
+19
View File
@@ -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
}
}