Files
swiftapp/swift/Sources/Features/Mail/MailRootView.swift
T

1882 lines
65 KiB
Swift
Raw Normal View History

2026-04-17 20:46:27 +02:00
import SwiftUI
2026-04-19 16:26:38 +02:00
2026-04-17 20:46:27 +02:00
#if os(macOS)
import AppKit
#else
import UIKit
#endif
2026-04-19 16:26:38 +02:00
private enum CompactMailTab: Hashable {
case inbox
case search
case compose
case activity
}
enum MailLayoutMode {
case compact
case regular
2026-04-17 20:46:27 +02:00
}
struct MailRootView: View {
@Bindable var model: AppViewModel
2026-04-19 16:26:38 +02:00
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
2026-04-17 20:46:27 +02:00
@State private var preferredCompactColumn: NavigationSplitViewColumn = .content
2026-04-19 16:26:38 +02:00
@State private var compactTab: CompactMailTab = .inbox
@State private var lastCompactTab: CompactMailTab = .inbox
@State private var regularColumnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("sio.readingPane") private var readingPaneRawValue = ReadingPanePreference.right.rawValue
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
Group {
if usesCompactLayout {
compactScene
} else {
regularScene
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.overlay {
if model.isCommandPalettePresented {
CommandPaletteView(model: model)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.overlay(alignment: .topLeading) {
if !usesCompactLayout {
CommandPaletteShortcut(model: model)
}
2026-04-17 20:46:27 +02:00
}
.task {
await model.load()
}
.task {
await model.beginBackendControl()
}
2026-04-19 16:26:38 +02:00
.onAppear {
syncRegularColumns()
}
.onChange(of: readingPaneRawValue) {
syncRegularColumns()
}
2026-04-17 20:46:27 +02:00
.onChange(of: model.mailboxNavigationToken) {
2026-04-19 16:26:38 +02:00
if usesCompactLayout {
compactTab = .inbox
}
2026-04-17 20:46:27 +02:00
}
.onChange(of: model.threadNavigationToken) {
2026-04-19 16:26:38 +02:00
if usesCompactLayout {
compactTab = .inbox
2026-04-17 20:46:27 +02:00
}
}
.onChange(of: model.isComposing) {
2026-04-19 16:26:38 +02:00
syncRegularColumns()
2026-04-17 20:46:27 +02:00
}
.alert("Something went wrong", isPresented: errorPresented) {
Button("OK") {
model.errorMessage = nil
}
} message: {
Text(model.errorMessage ?? "")
}
}
2026-04-19 16:26:38 +02:00
private var compactScene: some View {
TabView(selection: $compactTab) {
NavigationStack {
ThreadListView(model: model, layoutMode: .compact)
.navigationTitle(model.selectedMailbox.title)
.compactInboxNavigation(searchText: searchTextBinding)
.navigationDestination(isPresented: compactThreadPresented) {
ThreadReadingView(model: model)
}
}
.tabItem {
Label("Inbox", systemImage: "tray.full")
}
.tag(CompactMailTab.inbox)
NavigationStack {
SearchView(model: model)
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(CompactMailTab.search)
Color.clear
.tabItem {
Label("Compose", systemImage: "square.and.pencil")
}
.tag(CompactMailTab.compose)
NavigationStack {
ActivityView(model: model)
}
.tabItem {
Label("Activity", systemImage: "bolt.horizontal")
}
.tag(CompactMailTab.activity)
}
.sheet(isPresented: compactComposePresented) {
ComposeView(model: model)
.presentationDetents([.large])
}
.onChange(of: compactTab) {
guard compactTab == .compose else {
lastCompactTab = compactTab
return
}
model.startCompose()
compactTab = lastCompactTab
}
}
private var regularScene: some View {
NavigationSplitView(
columnVisibility: $regularColumnVisibility,
preferredCompactColumn: $preferredCompactColumn
) {
SidebarView(model: model)
} content: {
ThreadListView(model: model, layoutMode: .regular)
.navigationTitle(model.selectedMailbox.title)
} detail: {
if model.isComposing {
ComposeView(model: model)
} else {
ThreadReadingView(model: model)
}
}
.readingPaneNavigationStyle(readingPanePreference)
.sheet(isPresented: regularThreadSheetPresented) {
NavigationStack {
ThreadReadingView(model: model)
}
.frame(minWidth: 760, minHeight: 620)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
model.startCompose()
} label: {
Label("Compose", systemImage: "square.and.pencil")
}
}
ToolbarItem(placement: .automatic) {
Button {
model.isCommandPalettePresented = true
} label: {
Label("Command Palette", systemImage: "command")
}
.keyboardShortcut("k", modifiers: .command)
}
}
}
private var searchTextBinding: Binding<String> {
2026-04-17 20:46:27 +02:00
Binding(
2026-04-19 16:26:38 +02:00
get: { model.searchText },
set: { model.setSearchText($0) }
)
}
private var compactThreadPresented: Binding<Bool> {
Binding(
get: { model.selectedThreadID != nil && !model.isComposing },
2026-04-17 20:46:27 +02:00
set: { isPresented in
if !isPresented {
2026-04-19 16:26:38 +02:00
model.dismissThreadSelection()
2026-04-17 20:46:27 +02:00
}
}
)
}
2026-04-19 16:26:38 +02:00
private var compactComposePresented: Binding<Bool> {
2026-04-17 20:46:27 +02:00
Binding(
2026-04-19 16:26:38 +02:00
get: { model.isComposing },
set: { isPresented in
if !isPresented {
model.dismissCompose()
}
}
2026-04-17 20:46:27 +02:00
)
}
2026-04-19 16:26:38 +02:00
private var regularThreadSheetPresented: Binding<Bool> {
Binding(
get: {
!usesCompactLayout &&
readingPanePreference == .off &&
model.selectedThreadID != nil &&
!model.isComposing
},
set: { isPresented in
if !isPresented {
model.dismissThreadSelection()
}
}
)
}
private var errorPresented: Binding<Bool> {
Binding(
get: { model.errorMessage != nil },
set: { isPresented in
if !isPresented {
model.errorMessage = nil
}
}
)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private var usesCompactLayout: Bool {
2026-04-17 20:46:27 +02:00
#if os(iOS)
2026-04-19 16:26:38 +02:00
UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact
2026-04-17 20:46:27 +02:00
#else
false
#endif
}
2026-04-19 16:26:38 +02:00
private var readingPanePreference: ReadingPanePreference {
ReadingPanePreference(rawValue: readingPaneRawValue) ?? .right
}
private func syncRegularColumns() {
guard !usesCompactLayout else { return }
regularColumnVisibility = readingPanePreference == .off && !model.isComposing ? .doubleColumn : .all
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
struct SidebarView: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
var body: some View {
List {
Section {
2026-04-19 16:26:38 +02:00
SidebarAccountHeader(model: model)
.listRowInsets(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
2026-04-17 20:46:27 +02:00
.listRowBackground(Color.clear)
}
2026-04-19 16:26:38 +02:00
Section("Inbox") {
inboxAllRow
laneRow(.feed)
laneRow(.paper)
laneRow(.people)
}
Section("Smart") {
mailboxRow(.starred)
mailboxRow(.snoozed)
mailboxRow(.screener, accessibilityID: "mailbox.screener")
}
2026-04-17 20:46:27 +02:00
Section("Mailboxes") {
2026-04-19 16:26:38 +02:00
mailboxRow(.sent)
mailboxRow(.drafts)
mailboxRow(.archive)
mailboxRow(.trash)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
if !model.folderNames.isEmpty {
Section("Folders") {
ForEach(model.folderNames, id: \.self) { folder in
HStack(spacing: 12) {
Image(systemName: "folder")
.foregroundStyle(.secondary)
Text(folder)
}
.font(.subheadline)
2026-04-17 20:46:27 +02:00
}
}
}
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
2026-04-19 16:26:38 +02:00
.background(platformBackground.ignoresSafeArea())
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private var inboxAllRow: some View {
Button {
model.selectMailbox(.inbox)
model.setLaneFilter(nil)
} label: {
SidebarMailboxLabel(
title: "All",
systemImage: Mailbox.inbox.systemImage,
isSelected: model.selectedMailbox == .inbox && model.laneFilter == nil
)
}
.buttonStyle(.plain)
.badge(model.threadCount(in: .inbox))
.accessibilityIdentifier("mailbox.inbox")
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private func mailboxRow(_ mailbox: Mailbox, accessibilityID: String? = nil) -> some View {
Button {
model.selectMailbox(mailbox)
if mailbox != .inbox {
model.setLaneFilter(nil)
}
} label: {
SidebarMailboxLabel(
title: mailbox.title,
systemImage: mailbox.systemImage,
isSelected: model.selectedMailbox == mailbox
)
}
.buttonStyle(.plain)
.badge(model.threadCount(in: mailbox))
.accessibilityIdentifier(accessibilityID ?? "mailbox.\(mailbox.id)")
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private func laneRow(_ lane: Lane) -> some View {
Button {
model.selectMailbox(.inbox)
model.setLaneFilter(lane)
} label: {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(lane.color)
.frame(width: 14, height: 14)
Text(lane.label)
.font(.subheadline.weight(model.selectedMailbox == .inbox && model.laneFilter == lane ? .semibold : .regular))
.foregroundStyle(model.selectedMailbox == .inbox && model.laneFilter == lane ? lane.color : Color.primary)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(.plain)
.badge(model.laneCount(lane, in: .inbox))
.accessibilityIdentifier("mailbox.lane.\(lane.rawValue)")
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
private struct SidebarAccountHeader: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
var body: some View {
2026-04-19 16:26:38 +02:00
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
AvatarView(name: model.currentUser.name, color: SIO.tint, size: 42)
VStack(alignment: .leading, spacing: 2) {
Text(model.currentUser.name)
.font(.headline)
Text(model.currentUser.email)
2026-04-17 20:46:27 +02:00
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
2026-04-19 16:26:38 +02:00
HStack(spacing: 12) {
compactSummary(title: "Unread", value: model.totalUnreadCount, tint: SIO.tint)
compactSummary(title: "Starred", value: model.threadCount(in: .starred), tint: .yellow)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.padding(16)
.sioCardBackground(tint: SIO.tint)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private func compactSummary(title: String, value: Int, tint: Color) -> some View {
2026-04-17 20:46:27 +02:00
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value, format: .number)
.font(.headline.weight(.semibold))
2026-04-19 16:26:38 +02:00
.foregroundStyle(tint)
2026-04-17 20:46:27 +02:00
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
2026-04-19 16:26:38 +02:00
private struct SidebarMailboxLabel: View {
let title: String
let systemImage: String
let isSelected: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.foregroundStyle(isSelected ? SIO.tint : Color.secondary)
Text(title)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
}
struct ThreadListView: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
2026-04-19 16:26:38 +02:00
let layoutMode: MailLayoutMode
@AppStorage("sio.density") private var densityRawValue = ""
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
VStack(spacing: 0) {
if layoutMode == .regular {
ThreadListSearchHeader(model: model)
}
if layoutMode == .compact && model.selectedMailbox == .inbox {
CompactLaneOverview(model: model)
.padding(.horizontal, 16)
.padding(.top, 8)
}
LaneFilterStrip(model: model)
.padding(.vertical, 10)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Divider()
Group {
if model.isLoading {
ProgressView("Loading mail...")
2026-04-17 20:46:27 +02:00
.frame(maxWidth: .infinity, maxHeight: .infinity)
2026-04-19 16:26:38 +02:00
} else if model.selectedMailbox == .screener {
ScreenerListView(model: model)
} else if model.filteredThreads.isEmpty {
ContentUnavailableView(
"No Messages",
systemImage: "tray",
description: Text("Try another mailbox or relax the filters.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(model.filteredThreads) { thread in
Button {
Haptics.selection()
model.openThread(withID: thread.id)
} label: {
ThreadRow(
thread: thread,
density: density,
isSelected: thread.id == model.selectedThreadID
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.contextMenu {
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
model.toggleRead(for: thread)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
Button(thread.isStarred ? "Remove Star" : "Star") {
model.toggleStar(for: thread)
}
Button("Archive") {
model.moveThread(withID: thread.id, to: .archive)
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
.listStyle(.plain)
.scrollContentBackground(.hidden)
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
.background(platformBackground.ignoresSafeArea())
2026-04-17 20:46:27 +02:00
.safeAreaInset(edge: .bottom) {
2026-04-19 16:26:38 +02:00
if layoutMode == .compact {
FloatingComposeButton(model: model)
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
private var density: ThreadRowDensity {
if let stored = ThreadRowDensity(rawValue: densityRawValue) {
return stored
}
#if os(macOS)
return .cozy
#else
return .comfortable
#endif
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
private struct ThreadListSearchHeader: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
var body: some View {
2026-04-19 16:26:38 +02:00
VStack(alignment: .leading, spacing: 12) {
MailSearchField(
text: Binding(
get: { model.searchText },
set: { model.setSearchText($0) }
),
placeholder: "Search mail"
)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
ScrollView(.horizontal, showsIndicators: false) {
2026-04-17 20:46:27 +02:00
HStack(spacing: 8) {
2026-04-19 16:26:38 +02:00
FilterChip(title: "From:", subtitle: model.selectedThread?.participants.first?.name ?? "Anyone")
FilterChip(title: "Has attachment", isSelected: model.filteredThreads.contains(where: \.hasAttachments))
FilterChip(title: "Last 30 days", isSelected: true)
FilterChip(title: "Lane:", subtitle: model.laneFilter?.label ?? "All")
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 10)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
private struct LaneFilterStrip: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
var body: some View {
2026-04-19 16:26:38 +02:00
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
filterButton(title: "All", lane: nil)
ForEach(Lane.allCases) { lane in
filterButton(title: lane.label, lane: lane)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
unreadButton
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.padding(.horizontal, 16)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
private func filterButton(title: String, lane: Lane?) -> some View {
Button {
model.setLaneFilter(lane)
} label: {
HStack(spacing: 8) {
if let lane {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(lane.color)
.frame(width: 10, height: 10)
}
Text(title)
if model.unreadCount(for: lane) > 0 {
Text(model.unreadCount(for: lane), format: .number)
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background((lane?.color ?? SIO.tint).opacity(0.14), in: Capsule())
}
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(model.laneFilter == lane ? (lane?.color ?? SIO.tint) : Color.primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule(style: .continuous)
.fill(model.laneFilter == lane ? (lane?.color ?? SIO.tint).opacity(0.12) : Color.secondary.opacity(0.08))
)
}
.buttonStyle(.plain)
}
private var unreadButton: some View {
Button {
model.setUnreadOnly(!model.showUnreadOnly)
} label: {
HStack(spacing: 8) {
Image(systemName: model.showUnreadOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
Text("Unread")
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(model.showUnreadOnly ? SIO.tint : Color.primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule(style: .continuous)
.fill(model.showUnreadOnly ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.08))
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("filter.unread")
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
private struct ScreenerListView: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
var body: some View {
2026-04-19 16:26:38 +02:00
if model.screenerThreads.isEmpty {
ContentUnavailableView(
"The Screener is Empty",
systemImage: "person.crop.circle.badge.checkmark",
description: Text("New first-contact mail will land here before it reaches the inbox.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(model.screenerThreads) { thread in
VStack(alignment: .leading, spacing: 14) {
ThreadRow(thread: thread, density: .comfortable, isSelected: false)
HStack(spacing: 10) {
Button("Approve") {
model.applyScreenerDecision(.approve, to: thread.id)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(PrimaryActionStyle())
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Button("Send to Paper") {
model.applyScreenerDecision(.sendToPaper, to: thread.id)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(SecondaryActionStyle())
Button("Block") {
model.applyScreenerDecision(.block, to: thread.id)
}
.buttonStyle(DestructiveStyle())
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.listStyle(.plain)
.scrollContentBackground(.hidden)
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
private struct FloatingComposeButton: View {
@Bindable var model: AppViewModel
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
HStack {
Spacer()
Button {
model.startCompose()
} label: {
HStack(spacing: 10) {
Image(systemName: "square.and.pencil")
Text("Compose")
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.sioGlassSurface(in: Capsule(), tint: SIO.tint)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
}
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private struct CompactLaneOverview: View {
@Bindable var model: AppViewModel
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
var body: some View {
HStack(spacing: 10) {
ForEach(Lane.allCases) { lane in
Button {
model.selectMailbox(.inbox)
model.setLaneFilter(lane)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(lane.color)
.frame(width: 7, height: 7)
Text(lane.label.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Text(model.unreadCount(for: lane), format: .number)
.font(.title2.weight(.bold))
.foregroundStyle(model.laneFilter == lane ? lane.color : Color.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(model.laneFilter == lane ? lane.color.opacity(0.12) : Color.secondary.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.06), lineWidth: 1)
)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(.plain)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
}
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private struct ThreadRow: View {
let thread: MailThread
let density: ThreadRowDensity
let isSelected: Bool
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
unreadDot
AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(senderName)
.font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1)
if thread.messageCount > 1 {
Text(thread.messageCount, format: .number)
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.12), in: Capsule())
}
Spacer(minLength: 0)
if thread.isStarred {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
if thread.hasAttachments {
Image(systemName: "paperclip")
.foregroundStyle(.secondary)
}
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(thread.subject)
.font(.headline)
.lineLimit(1)
Text(thread.previewText)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(density.previewLineLimit)
if density.showsMetaChips {
HStack(spacing: 8) {
LaneChip(lane: thread.lane)
if thread.summary != nil {
HStack(spacing: 6) {
Image(systemName: "sparkles")
Text("AI Summary")
}
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(SIO.tint)
}
2026-04-17 20:46:27 +02:00
}
}
}
}
}
2026-04-19 16:26:38 +02:00
.padding(density.rowPadding)
.background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(isSelected ? SIO.tint.opacity(0.18) : Color.primary.opacity(0.06), lineWidth: 1)
2026-04-17 20:46:27 +02:00
)
.accessibilityIdentifier("thread.\(thread.routeID)")
}
2026-04-19 16:26:38 +02:00
private var senderName: String {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown Sender"
}
@ViewBuilder
private var unreadDot: some View {
Circle()
.fill(thread.isUnread ? SIO.tint : Color.clear)
.frame(width: 8, height: 8)
.padding(.top, density.avatarSize > 24 ? 10 : 8)
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
struct ThreadReadingView: View {
2026-04-17 20:46:27 +02:00
@Bindable var model: AppViewModel
2026-04-19 16:26:38 +02:00
@State private var replyText = ""
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
Group {
if let thread = model.selectedThread {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ReadingHeader(thread: thread)
if let summary = thread.summary {
AISummaryCard(count: thread.messageCount, bullets: summary)
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
ForEach(thread.messages) { message in
MessageCard(
message: message,
isFocused: message.routeID == model.focusedMessageRouteID,
isLatest: message.id == thread.latestMessage?.id
)
.id(message.routeID)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.padding(20)
.frame(maxWidth: 920, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.background(platformBackground.ignoresSafeArea())
.safeAreaInset(edge: .top) {
if !isPhone {
ReadingToolbar(model: model, thread: thread)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
.safeAreaInset(edge: .bottom) {
InlineReplyComposer(
text: $replyText,
placeholder: "Reply to \(replyTargetName(for: thread))..."
) {
model.sendInlineReply(replyText, in: thread.id)
replyText = ""
Haptics.success()
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.onAppear {
scrollToFocusedMessage(using: proxy, animated: false)
}
.onChange(of: model.focusedMessageRouteID) {
scrollToFocusedMessage(using: proxy)
}
.onChange(of: thread.routeID) {
scrollToFocusedMessage(using: proxy, animated: false)
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
} else {
ContentUnavailableView(
"Select a Thread",
systemImage: "envelope.open",
description: Text("Choose a conversation to read or compose a new message.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(platformBackground.ignoresSafeArea())
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
}
private var isPhone: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .phone
#else
false
#endif
}
private func replyTargetName(for thread: MailThread) -> String {
thread.latestMessage?.sender.name ?? thread.participants.first(where: { $0.email != model.currentUser.email })?.name ?? "sender"
2026-04-17 20:46:27 +02:00
}
private func scrollToFocusedMessage(using proxy: ScrollViewProxy, animated: Bool = true) {
guard let focusedMessageRouteID = model.focusedMessageRouteID else { return }
if animated {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(focusedMessageRouteID, anchor: .center)
}
} else {
proxy.scrollTo(focusedMessageRouteID, anchor: .center)
}
}
}
2026-04-19 16:26:38 +02:00
private struct ReadingHeader: View {
let thread: MailThread
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
VStack(alignment: .leading, spacing: 14) {
Text(thread.subject)
.font(.title.weight(.bold))
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
HStack(alignment: .center, spacing: 12) {
participantStack
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
LaneChip(lane: thread.lane)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Text("\(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
.font(.subheadline)
.foregroundStyle(.secondary)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Spacer(minLength: 0)
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
.font(.subheadline)
.foregroundStyle(.secondary)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.padding(18)
.sioCardBackground(tint: thread.lane.color)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private var participantStack: some View {
HStack(spacing: -10) {
ForEach(Array(thread.participants.prefix(3))) { participant in
AvatarView(name: participant.name, color: thread.lane.color, size: 28)
2026-04-17 20:46:27 +02:00
}
}
}
}
2026-04-19 16:26:38 +02:00
private struct ReadingToolbar: View {
@Bindable var model: AppViewModel
let thread: MailThread
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
toolbarButton("Archive", systemImage: "archivebox", key: "E") {
model.moveThread(withID: thread.id, to: .archive)
}
.keyboardShortcut("e", modifiers: [])
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
toolbarButton("Move", systemImage: "tray.and.arrow.down", key: "I") {
model.moveThread(withID: thread.id, to: .inbox)
}
.keyboardShortcut("i", modifiers: [])
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
toolbarButton("Delete", systemImage: "trash", key: "", destructive: true) {
model.moveThread(withID: thread.id, to: .trash)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
toolbarButton("Reply", systemImage: "arrowshape.turn.up.left", key: "R") {
model.startReply(to: thread.id)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.keyboardShortcut("r", modifiers: [])
toolbarButton("Reply all", systemImage: "arrowshape.turn.up.left.2", key: "⇧R") {
model.startReply(to: thread.id, replyAll: true)
}
.keyboardShortcut("r", modifiers: [.shift])
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
toolbarButton("Forward", systemImage: "arrowshape.turn.up.right", key: "F") {
model.startReply(to: thread.id, forward: true)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.keyboardShortcut("f", modifiers: [])
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 6)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.background(platformBackground.opacity(0.92))
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private func toolbarButton(
_ title: String,
2026-04-17 20:46:27 +02:00
systemImage: String,
2026-04-19 16:26:38 +02:00
key: String,
destructive: Bool = false,
2026-04-17 20:46:27 +02:00
action: @escaping () -> Void
) -> some View {
Button(action: action) {
2026-04-19 16:26:38 +02:00
HStack(spacing: 10) {
2026-04-17 20:46:27 +02:00
Image(systemName: systemImage)
Text(title)
2026-04-19 16:26:38 +02:00
KeyboardHint(title: key)
2026-04-17 20:46:27 +02:00
}
.font(.subheadline.weight(.semibold))
2026-04-19 16:26:38 +02:00
.padding(.horizontal, 12)
2026-04-17 20:46:27 +02:00
.padding(.vertical, 10)
2026-04-19 16:26:38 +02:00
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.foregroundStyle(destructive ? Color.red : Color.primary)
2026-04-17 20:46:27 +02:00
}
.buttonStyle(.plain)
}
}
private struct MessageCard: View {
let message: MailMessage
let isFocused: Bool
2026-04-19 16:26:38 +02:00
let isLatest: Bool
2026-04-17 20:46:27 +02:00
var body: some View {
VStack(alignment: .leading, spacing: 14) {
2026-04-19 16:26:38 +02:00
HStack(alignment: .top, spacing: 12) {
AvatarView(name: message.sender.name, color: SIO.tint, size: 32)
VStack(alignment: .leading, spacing: 3) {
2026-04-17 20:46:27 +02:00
Text(message.sender.name)
.font(.headline)
2026-04-19 16:26:38 +02:00
Text("to \(message.recipients.map(\.email).joined(separator: ", "))")
2026-04-17 20:46:27 +02:00
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Text(message.sentAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(message.body)
2026-04-19 16:26:38 +02:00
.sioProse()
2026-04-17 20:46:27 +02:00
.textSelection(.enabled)
2026-04-19 16:26:38 +02:00
if !message.attachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(message.attachments) { attachment in
HStack(spacing: 6) {
Image(systemName: "doc")
Text(attachment.name)
Text(attachment.size)
.foregroundStyle(.secondary)
Image(systemName: "arrow.down.circle")
}
.font(.caption2.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.padding(18)
.background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isFocused ? SIO.tint.opacity(0.12) : (isLatest ? SIO.tint.opacity(0.08) : Color.secondary.opacity(0.05)))
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 1)
)
2026-04-17 20:46:27 +02:00
.accessibilityIdentifier("message.\(message.routeID)")
}
2026-04-19 16:26:38 +02:00
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private struct InlineReplyComposer: View {
@Binding var text: String
let placeholder: String
let onSend: () -> Void
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
var body: some View {
HStack(alignment: .bottom, spacing: 12) {
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeholder)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
TextEditor(text: $text)
.font(.system(size: 15.5))
.scrollContentBackground(.hidden)
.frame(minHeight: 44, maxHeight: min(140, 44 + CGFloat(max(0, text.components(separatedBy: "\n").count - 1)) * 22))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.clear)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
Button(action: onSend) {
Image(systemName: "arrow.up")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.background(SIO.tint, in: Circle())
}
.buttonStyle(.plain)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
.background(platformBackground.opacity(0.94))
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
struct ComposeView: View {
2026-04-17 20:46:27 +02:00
@Environment(\.dismiss) private var dismiss
@Bindable var model: AppViewModel
var body: some View {
2026-04-19 16:26:38 +02:00
VStack(spacing: 0) {
HStack {
Button("Cancel") {
model.dismissCompose()
dismiss()
}
.buttonStyle(SecondaryActionStyle())
Spacer(minLength: 0)
Text("New Message")
.font(.headline.weight(.semibold))
Spacer(minLength: 0)
Button(model.isSending ? "Sending..." : "Send") {
Task {
let didSend = await model.sendCurrentDraft()
if didSend {
Haptics.success()
dismiss()
} else {
Haptics.warning()
}
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(model.isSending || model.composeDraft.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.composeDraft.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.accessibilityIdentifier("compose.send")
}
.padding(16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ComposeHeaderRow(title: "To") {
RecipientTokenInput(text: $model.composeDraft.to, placeholder: "name@example.com", accessibilityID: "compose.to")
}
ComposeHeaderRow(title: "Cc") {
RecipientTokenInput(text: $model.composeDraft.cc, placeholder: "Optional")
}
ComposeHeaderRow(title: "From") {
Menu {
Button(model.currentUser.email) {
model.composeDraft.from = model.currentUser.email
}
} label: {
HStack {
Text(model.composeDraft.from.isEmpty ? model.currentUser.email : model.composeDraft.from)
Spacer(minLength: 0)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
}
.font(.body)
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
ComposeHeaderRow(title: "Subject") {
TextField("What's this about?", text: $model.composeDraft.subject)
.textFieldStyle(.plain)
.accessibilityIdentifier("compose.subject")
}
VStack(alignment: .leading, spacing: 10) {
TextEditor(text: $model.composeDraft.body)
.font(.system(size: 15.5))
.scrollContentBackground(.hidden)
.frame(minHeight: 320)
.accessibilityIdentifier("compose.body")
ComposeFormatToolbar()
}
.padding(14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
}
.padding(16)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
.background(platformBackground.ignoresSafeArea())
2026-04-17 20:46:27 +02:00
.accessibilityIdentifier("compose.view")
2026-04-19 16:26:38 +02:00
.onChange(of: model.composeDraft) {
model.queueDraftAutosave()
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private struct ComposeHeaderRow<Content: View>: View {
let title: String
let content: Content
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
HStack(alignment: .top, spacing: 14) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 56, alignment: .leading)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
content
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
}
}
private struct RecipientTokenInput: View {
@Binding var text: String
let placeholder: String
var accessibilityID: String?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !tokens.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(tokens, id: \.self) { token in
Text(token)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(SIO.tint.opacity(0.12), in: Capsule())
.foregroundStyle(SIO.tint)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
}
}
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.recipientAutocapitalization()
.accessibilityIdentifier(accessibilityID ?? "")
}
}
private var tokens: [String] {
text.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
private struct ComposeFormatToolbar: View {
var body: some View {
HStack(spacing: 10) {
formatButton("Aa")
formatButton("B")
formatButton("I")
formatButton("U")
iconButton("paperclip")
iconButton("camera")
Spacer(minLength: 0)
}
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
private func formatButton(_ title: String) -> some View {
Button(title) {}
.buttonStyle(SecondaryActionStyle())
}
private func iconButton(_ systemImage: String) -> some View {
Button {
} label: {
Image(systemName: systemImage)
}
.buttonStyle(SecondaryActionStyle())
}
}
struct SearchView: View {
@Bindable var model: AppViewModel
@State private var attachmentsOnly = false
@State private var last30DaysOnly = false
@State private var selectedLane: Lane?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
MailSearchField(
text: Binding(
get: { model.searchText },
set: { model.setSearchText($0) }
),
placeholder: "Search messages",
showsCancel: true,
onLongPress: {
model.isCommandPalettePresented = true
}
)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(title: "From:", subtitle: model.topSearchResult?.participants.first?.name ?? "Anyone")
Button {
attachmentsOnly.toggle()
} label: {
FilterChip(title: "Has attachment", isSelected: attachmentsOnly)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(.plain)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Button {
last30DaysOnly.toggle()
} label: {
FilterChip(title: "Last 30 days", isSelected: last30DaysOnly)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.buttonStyle(.plain)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
Menu {
Button("All") { selectedLane = nil }
ForEach(Lane.allCases) { lane in
Button(lane.label) { selectedLane = lane }
}
} label: {
FilterChip(title: "Lane:", subtitle: selectedLane?.label ?? "All")
}
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
if filteredResults.isEmpty {
ContentUnavailableView(
"Search mail",
systemImage: "magnifyingglass",
description: Text("Use search to find senders, subjects, summaries, and attachments.")
)
.frame(maxWidth: .infinity)
.padding(.top, 40)
} else {
if let topHit = filteredResults.first {
SearchResultSection(title: "Top hit") {
resultButton(for: topHit)
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
let remaining = Array(filteredResults.dropFirst())
if !remaining.isEmpty {
SearchResultSection(title: "Messages (\(remaining.count))") {
ForEach(remaining) { thread in
resultButton(for: thread)
}
2026-04-17 20:46:27 +02:00
}
}
}
}
2026-04-19 16:26:38 +02:00
.padding(16)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.background(platformBackground.ignoresSafeArea())
.navigationTitle("Search")
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private var filteredResults: [MailThread] {
model.searchResults.filter { thread in
let matchesAttachments = !attachmentsOnly || thread.hasAttachments
let matchesDate = !last30DaysOnly || thread.lastUpdated > .now.addingTimeInterval(-30 * 24 * 60 * 60)
let matchesLane = selectedLane == nil || thread.lane == selectedLane
return matchesAttachments && matchesDate && matchesLane
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private func resultButton(for thread: MailThread) -> some View {
Button {
model.selectMailbox(thread.mailbox)
model.openThread(withID: thread.id)
} label: {
ThreadRow(thread: thread, density: .comfortable, isSelected: false)
}
.buttonStyle(.plain)
}
}
2026-04-19 16:26:38 +02:00
private struct SearchResultSection<Content: View>: View {
2026-04-17 20:46:27 +02:00
let title: String
let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
2026-04-19 16:26:38 +02:00
VStack(spacing: 10) {
content
}
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
private struct MailSearchField: View {
@Binding var text: String
let placeholder: String
var showsCancel = false
var onLongPress: (() -> Void)?
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
if showsCancel && !text.isEmpty {
Button("Cancel") {
text = ""
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.onLongPressGesture {
onLongPress?()
2026-04-17 20:46:27 +02:00
}
}
}
2026-04-19 16:26:38 +02:00
private struct FilterChip: View {
let title: String
var subtitle: String? = nil
var isSelected = false
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
var body: some View {
HStack(spacing: 6) {
Text(title)
if let subtitle {
Text(subtitle)
.foregroundStyle(.secondary)
}
}
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.08), in: Capsule())
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
private struct ActivityView: View {
@Bindable var model: AppViewModel
2026-04-17 20:46:27 +02:00
var body: some View {
2026-04-19 16:26:38 +02:00
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Activity")
.font(.largeTitle.weight(.bold))
HStack(spacing: 12) {
activityCard(title: "Unread", value: model.totalUnreadCount, tint: SIO.tint)
activityCard(title: "Snoozed", value: model.threadCount(in: .snoozed), tint: .orange)
}
if let summaryThread = model.threads.first(where: { $0.summary != nil }) {
AISummaryCard(count: summaryThread.messageCount, bullets: summaryThread.summary ?? [])
}
AppearanceSettingsCard()
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.padding(16)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.background(platformBackground.ignoresSafeArea())
.navigationTitle("Activity")
}
private func activityCard(title: String, value: Int, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value, format: .number)
.font(.title2.weight(.bold))
.foregroundStyle(tint)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.sioCardBackground(tint: tint)
2026-04-17 20:46:27 +02:00
}
}
2026-04-19 16:26:38 +02:00
struct CommandPaletteView: View {
@Bindable var model: AppViewModel
@State private var selectedIndex = 0
var body: some View {
ZStack {
Rectangle()
.fill(Color.black.opacity(0.32))
.ignoresSafeArea()
.onTapGesture {
model.isCommandPalettePresented = false
}
VStack(alignment: .leading, spacing: 18) {
ForEach(Array(sectionedItems.enumerated()), id: \.offset) { _, section in
VStack(alignment: .leading, spacing: 8) {
Text(section.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
VStack(spacing: 6) {
ForEach(Array(section.items.enumerated()), id: \.element.id) { _, item in
paletteRow(for: item)
}
}
}
}
}
.padding(20)
.frame(maxWidth: 640)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
2026-04-17 20:46:27 +02:00
)
2026-04-19 16:26:38 +02:00
.sioGlassChrome()
.focusable()
.onAppear {
selectedIndex = 0
}
.onKeyPress(.upArrow) {
selectedIndex = max(0, selectedIndex - 1)
return .handled
}
.onKeyPress(.downArrow) {
selectedIndex = min(items.count - 1, selectedIndex + 1)
return .handled
}
.onKeyPress(.escape) {
model.isCommandPalettePresented = false
return .handled
}
.onKeyPress(.return) {
guard items.indices.contains(selectedIndex) else { return .ignored }
items[selectedIndex].action()
model.isCommandPalettePresented = false
return .handled
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.accessibilityIdentifier("commandPalette")
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private func paletteRow(for item: CommandPaletteItem) -> some View {
let index = items.firstIndex(where: { $0.id == item.id }) ?? 0
return Button {
selectedIndex = index
item.action()
model.isCommandPalettePresented = false
} label: {
HStack(spacing: 12) {
Image(systemName: item.systemImage)
.frame(width: 18)
Text(item.title)
.frame(maxWidth: .infinity, alignment: .leading)
KeyboardHint(title: item.keyHint)
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 11)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(index == selectedIndex ? SIO.tint.opacity(0.12) : Color.clear)
2026-04-17 20:46:27 +02:00
)
2026-04-19 16:26:38 +02:00
}
.buttonStyle(.plain)
.accessibilityIdentifier("commandPalette.action.\(item.id)")
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
private var items: [CommandPaletteItem] {
let selectedThreadID = model.selectedThreadID
return [
CommandPaletteItem(id: "reply", section: "Actions", title: "Reply", systemImage: "arrowshape.turn.up.left", keyHint: "R") {
if let selectedThreadID {
model.startReply(to: selectedThreadID)
}
},
CommandPaletteItem(id: "replyAll", section: "Actions", title: "Reply all", systemImage: "arrowshape.turn.up.left.2", keyHint: "⇧R") {
if let selectedThreadID {
model.startReply(to: selectedThreadID, replyAll: true)
}
},
CommandPaletteItem(id: "forward", section: "Actions", title: "Forward", systemImage: "arrowshape.turn.up.right", keyHint: "F") {
if let selectedThreadID {
model.startReply(to: selectedThreadID, forward: true)
}
},
CommandPaletteItem(id: "archive", section: "Actions", title: "Archive", systemImage: "archivebox", keyHint: "E") {
if let selectedThreadID {
model.moveThread(withID: selectedThreadID, to: .archive)
}
},
CommandPaletteItem(id: "jumpPeople", section: "Jump to", title: "Inbox - People", systemImage: "person.2", keyHint: "G I") {
model.selectMailbox(.inbox)
model.setLaneFilter(.people)
},
CommandPaletteItem(id: "jumpStarred", section: "Jump to", title: "Starred", systemImage: "star", keyHint: "G S") {
model.selectMailbox(.starred)
},
CommandPaletteItem(id: "jumpSent", section: "Jump to", title: "Sent", systemImage: "paperplane", keyHint: "G T") {
model.selectMailbox(.sent)
},
CommandPaletteItem(id: "snoozeTomorrow", section: "Snooze", title: "Tomorrow morning", systemImage: "clock.badge", keyHint: "H 1") {
if let selectedThreadID {
model.snoozeThread(withID: selectedThreadID)
}
},
CommandPaletteItem(id: "snoozeEvening", section: "Snooze", title: "This evening", systemImage: "moon.stars", keyHint: "H 2") {
if let selectedThreadID {
model.snoozeThread(withID: selectedThreadID)
}
},
CommandPaletteItem(id: "snoozeNextWeek", section: "Snooze", title: "Next week", systemImage: "calendar", keyHint: "H 3") {
if let selectedThreadID {
model.snoozeThread(withID: selectedThreadID)
}
}
]
}
private var sectionedItems: [CommandPaletteSection] {
["Actions", "Jump to", "Snooze"].compactMap { title in
let matchingItems = items.filter { $0.section == title }
guard !matchingItems.isEmpty else { return nil }
return CommandPaletteSection(title: title, items: matchingItems)
}
}
}
private struct CommandPaletteShortcut: View {
@Bindable var model: AppViewModel
var body: some View {
Button("Command Palette") {
model.isCommandPalettePresented = true
}
.keyboardShortcut("k", modifiers: .command)
.opacity(0.01)
.frame(width: 1, height: 1)
.accessibilityHidden(true)
}
}
private struct CommandPaletteItem: Identifiable {
let id: String
let section: String
let title: String
let systemImage: String
let keyHint: String
let action: () -> Void
}
private struct CommandPaletteSection {
let title: String
let items: [CommandPaletteItem]
}
struct AppearanceSettingsView: View {
var body: some View {
Form {
AppearanceSettingsContent()
}
.formStyle(.grouped)
}
}
private struct AppearanceSettingsCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Appearance")
.font(.headline)
AppearanceSettingsContent()
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
.padding(16)
.sioCardBackground(tint: SIO.tint)
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
}
private struct AppearanceSettingsContent: View {
@AppStorage("sio.density") private var densityRawValue = ""
@AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
@AppStorage("sio.readingPane") private var readingPaneRawValue = ReadingPanePreference.right.rawValue
2026-04-17 20:46:27 +02:00
2026-04-19 16:26:38 +02:00
var body: some View {
VStack(alignment: .leading, spacing: 12) {
preferenceRow(title: "Theme") {
Picker("Theme", selection: $themeRawValue) {
ForEach(ThemePreference.allCases) { preference in
Text(preference.label).tag(preference.rawValue)
}
}
.pickerStyle(.segmented)
}
preferenceRow(title: "Density") {
Picker("Density", selection: densityBinding) {
ForEach(ThreadRowDensity.allCases) { density in
Text(density.rawValue.capitalized).tag(density.rawValue)
}
}
.pickerStyle(.segmented)
}
preferenceRow(title: "Reading Pane") {
Picker("Reading Pane", selection: $readingPaneRawValue) {
ForEach(ReadingPanePreference.allCases) { preference in
Text(preference.label).tag(preference.rawValue)
}
}
.pickerStyle(.segmented)
}
}
}
private var densityBinding: Binding<String> {
Binding(
get: {
if let stored = ThreadRowDensity(rawValue: densityRawValue) {
return stored.rawValue
}
#if os(macOS)
return ThreadRowDensity.cozy.rawValue
#else
return ThreadRowDensity.comfortable.rawValue
#endif
},
set: { densityRawValue = $0 }
)
}
private func preferenceRow<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
content()
}
}
}
#if os(macOS)
private let platformBackground = Color(nsColor: .windowBackgroundColor)
#else
private let platformBackground = Color(uiColor: .systemBackground)
#endif
private extension View {
2026-04-17 20:46:27 +02:00
@ViewBuilder
2026-04-19 16:26:38 +02:00
func compactInboxNavigation(searchText: Binding<String>) -> some View {
2026-04-17 20:46:27 +02:00
#if os(iOS)
2026-04-19 16:26:38 +02:00
navigationBarTitleDisplayMode(.inline)
.searchable(text: searchText, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search mail")
2026-04-17 20:46:27 +02:00
#else
self
#endif
}
@ViewBuilder
2026-04-19 16:26:38 +02:00
func recipientAutocapitalization() -> some View {
2026-04-17 20:46:27 +02:00
#if os(iOS)
2026-04-19 16:26:38 +02:00
textInputAutocapitalization(.never)
2026-04-17 20:46:27 +02:00
#else
self
#endif
}
2026-04-19 16:26:38 +02:00
@ViewBuilder
func readingPaneNavigationStyle(_ preference: ReadingPanePreference) -> some View {
if preference == .bottom {
navigationSplitViewStyle(.prominentDetail)
} else {
navigationSplitViewStyle(.balanced)
}
}
}
#Preview("Thread List Light") {
NavigationStack {
ThreadListView(model: previewModel(), layoutMode: .regular)
}
2026-04-17 20:46:27 +02:00
}
2026-04-19 16:26:38 +02:00
#Preview("Thread List Dark") {
NavigationStack {
ThreadListView(model: previewModel(), layoutMode: .regular)
.preferredColorScheme(.dark)
}
}
#Preview("Thread List XL") {
NavigationStack {
ThreadListView(model: previewModel(), layoutMode: .regular)
.dynamicTypeSize(.xxxLarge)
}
}
#Preview("Thread Reading") {
NavigationStack {
ThreadReadingView(model: previewSelectedModel())
}
}
#Preview("Compose") {
ComposeView(model: previewModel())
}
#Preview("Sidebar") {
SidebarView(model: previewModel())
.frame(width: 320)
}
#Preview("Command Palette") {
ZStack {
platformBackground
CommandPaletteView(model: previewSelectedModel())
}
}
@MainActor
private func previewModel() -> AppViewModel {
let model = AppViewModel(service: MockMailService(), controlService: StubPreviewControlService())
model.threads = MockMailService().previewThreads()
model.selectMailbox(.inbox)
return model
}
@MainActor
private func previewSelectedModel() -> AppViewModel {
let model = previewModel()
if let firstThread = model.threads.first {
model.openThread(withID: firstThread.id)
}
return model
}
private struct StubPreviewControlService: AppControlServicing {
func commands() -> AsyncStream<AppNavigationCommand> {
AsyncStream { continuation in
continuation.finish()
}
}
2026-04-17 20:46:27 +02:00
}