From 5a878012b7c8aafa6fc25018620ab8bbd5637b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 10 Mar 2026 12:21:49 +0100 Subject: [PATCH] add three-column layout with sidebar, thread list, detail view Co-Authored-By: Claude Opus 4.6 --- .../MagnumOpus/ViewModels/MailViewModel.swift | 67 +++++++++++++++++++ .../macos/MagnumOpus/Views/SidebarView.swift | 18 +++++ .../MagnumOpus/Views/ThreadDetailView.swift | 60 +++++++++++++++++ .../MagnumOpus/Views/ThreadListView.swift | 59 ++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 clients/macos/MagnumOpus/ViewModels/MailViewModel.swift create mode 100644 clients/macos/MagnumOpus/Views/SidebarView.swift create mode 100644 clients/macos/MagnumOpus/Views/ThreadDetailView.swift create mode 100644 clients/macos/MagnumOpus/Views/ThreadListView.swift diff --git a/clients/macos/MagnumOpus/ViewModels/MailViewModel.swift b/clients/macos/MagnumOpus/ViewModels/MailViewModel.swift new file mode 100644 index 0000000..9cd2cf8 --- /dev/null +++ b/clients/macos/MagnumOpus/ViewModels/MailViewModel.swift @@ -0,0 +1,67 @@ +import Foundation + +@Observable +final class MailViewModel { + let apiClient: APIClient + + var threads: [ThreadSummary] = [] + var selectedThread: ThreadSummary? + var selectedMessages: [EmailMessage] = [] + var selectedPerspective: Perspective = .inbox + var isLoading = false + var errorMessage: String? + + enum Perspective: String, CaseIterable, Identifiable { + case inbox = "Inbox" + case today = "Today" + case archive = "Archive" + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .inbox: "tray" + case .today: "sun.max" + case .archive: "archivebox" + } + } + } + + init(apiClient: APIClient) { + self.apiClient = apiClient + } + + func loadThreads(accountId: String) async { + isLoading = true + errorMessage = nil + do { + threads = try await apiClient.fetchThreads(accountId: accountId) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func loadMessages(for thread: ThreadSummary) async { + selectedThread = thread + do { + selectedMessages = try await apiClient.fetchMessages(threadId: thread.threadId) + } catch { + errorMessage = error.localizedDescription + } + } + + func connectToEvents(baseURL: URL, accountId: String) { + let sseURL = baseURL.appendingPathComponent("api/events") + let sseClient = SSEClient(url: sseURL) + + sseClient.connect { [weak self] event in + guard let self else { return } + if event.event == "threads_updated" { + Task { @MainActor in + await self.loadThreads(accountId: accountId) + } + } + } + } +} diff --git a/clients/macos/MagnumOpus/Views/SidebarView.swift b/clients/macos/MagnumOpus/Views/SidebarView.swift new file mode 100644 index 0000000..85f66d5 --- /dev/null +++ b/clients/macos/MagnumOpus/Views/SidebarView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct SidebarView: View { + @Bindable var viewModel: MailViewModel + + var body: some View { + List(selection: $viewModel.selectedPerspective) { + Section("Views") { + ForEach(MailViewModel.Perspective.allCases) { perspective in + Label(perspective.rawValue, systemImage: perspective.systemImage) + .tag(perspective) + } + } + } + .navigationTitle("Magnum Opus") + .listStyle(.sidebar) + } +} diff --git a/clients/macos/MagnumOpus/Views/ThreadDetailView.swift b/clients/macos/MagnumOpus/Views/ThreadDetailView.swift new file mode 100644 index 0000000..63bc90e --- /dev/null +++ b/clients/macos/MagnumOpus/Views/ThreadDetailView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct ThreadDetailView: View { + let thread: ThreadSummary? + let messages: [EmailMessage] + + var body: some View { + Group { + if let thread { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(thread.subject) + .font(.title2) + .fontWeight(.semibold) + .padding() + + Divider() + + ForEach(messages) { message in + MessageView(message: message) + Divider() + } + } + } + } else { + ContentUnavailableView( + "No Thread Selected", + systemImage: "envelope", + description: Text("Select a thread to read") + ) + } + } + } +} + +struct MessageView: View { + let message: EmailMessage + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(message.senderName) + .fontWeight(.semibold) + Spacer() + Text(message.date) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("To: \(message.toHeader)") + .font(.caption) + .foregroundStyle(.secondary) + + Text(message.body) + .font(.body) + .textSelection(.enabled) + } + .padding() + } +} diff --git a/clients/macos/MagnumOpus/Views/ThreadListView.swift b/clients/macos/MagnumOpus/Views/ThreadListView.swift new file mode 100644 index 0000000..f9954c4 --- /dev/null +++ b/clients/macos/MagnumOpus/Views/ThreadListView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ThreadListView: View { + @Bindable var viewModel: MailViewModel + + var body: some View { + List(viewModel.threads, selection: Binding( + get: { viewModel.selectedThread?.threadId }, + set: { newId in + if let thread = viewModel.threads.first(where: { $0.threadId == newId }) { + Task { await viewModel.loadMessages(for: thread) } + } + } + )) { thread in + ThreadRow(thread: thread) + .tag(thread.threadId) + } + .listStyle(.inset) + .navigationTitle(viewModel.selectedPerspective.rawValue) + .overlay { + if viewModel.isLoading { + ProgressView() + } else if viewModel.threads.isEmpty { + ContentUnavailableView( + "No Messages", + systemImage: "tray", + description: Text("Inbox is empty") + ) + } + } + } +} + +struct ThreadRow: View { + let thread: ThreadSummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(thread.authors) + .fontWeight(thread.isUnread ? .bold : .regular) + .lineLimit(1) + Spacer() + Text(thread.date, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(thread.subject) + .font(.subheadline) + .lineLimit(1) + if thread.totalMessages > 1 { + Text("\(thread.totalMessages) messages") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +}