add three-column layout with sidebar, thread list, detail view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
67
clients/macos/MagnumOpus/ViewModels/MailViewModel.swift
Normal file
67
clients/macos/MagnumOpus/ViewModels/MailViewModel.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
clients/macos/MagnumOpus/Views/SidebarView.swift
Normal file
18
clients/macos/MagnumOpus/Views/SidebarView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
60
clients/macos/MagnumOpus/Views/ThreadDetailView.swift
Normal file
60
clients/macos/MagnumOpus/Views/ThreadDetailView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
59
clients/macos/MagnumOpus/Views/ThreadListView.swift
Normal file
59
clients/macos/MagnumOpus/Views/ThreadListView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user