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)
|
2026-04-19 20:16:51 +02:00
|
|
|
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 280)
|
2026-04-19 16:26:38 +02:00
|
|
|
} content: {
|
|
|
|
|
ThreadListView(model: model, layoutMode: .regular)
|
|
|
|
|
.navigationTitle(model.selectedMailbox.title)
|
2026-04-19 20:16:51 +02:00
|
|
|
.navigationSplitViewColumnWidth(min: 380, ideal: 420, max: 520)
|
2026-04-19 16:26:38 +02:00
|
|
|
} 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 {
|
2026-04-19 20:16:51 +02:00
|
|
|
ScrollView {
|
|
|
|
|
LazyVStack(alignment: .leading, spacing: 20) {
|
|
|
|
|
ForEach(groupedThreads(model.filteredThreads)) { group in
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
Text(group.title.uppercased())
|
|
|
|
|
.font(.footnote.weight(.semibold))
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.kerning(0.4)
|
|
|
|
|
.padding(.horizontal, 28)
|
|
|
|
|
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
ForEach(Array(group.threads.enumerated()), id: \.element.id) { index, 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)
|
|
|
|
|
.contextMenu {
|
|
|
|
|
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
|
|
|
|
|
model.toggleRead(for: thread)
|
|
|
|
|
}
|
|
|
|
|
Button(thread.isStarred ? "Remove Star" : "Star") {
|
|
|
|
|
model.toggleStar(for: thread)
|
|
|
|
|
}
|
|
|
|
|
Button("Archive") {
|
|
|
|
|
model.moveThread(withID: thread.id, to: .archive)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if index < group.threads.count - 1 {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(Color.primary.opacity(0.08))
|
|
|
|
|
.frame(height: 0.5)
|
|
|
|
|
.padding(.leading, 60)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
|
|
.fill(platformSurfaceColor)
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
|
|
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
|
|
|
|
|
)
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
}
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
.padding(.bottom, layoutMode == .compact ? 80 : 12)
|
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 20:16:51 +02:00
|
|
|
|
|
|
|
|
private func groupedThreads(_ threads: [MailThread]) -> [ThreadGroup] {
|
|
|
|
|
let calendar = Calendar.current
|
|
|
|
|
let now = Date()
|
|
|
|
|
let startOfToday = calendar.startOfDay(for: now)
|
|
|
|
|
let startOfWeek = calendar.date(byAdding: .day, value: -6, to: startOfToday) ?? startOfToday
|
|
|
|
|
let startOfMonth = calendar.date(byAdding: .day, value: -30, to: startOfToday) ?? startOfToday
|
|
|
|
|
|
|
|
|
|
var today: [MailThread] = []
|
|
|
|
|
var thisWeek: [MailThread] = []
|
|
|
|
|
var thisMonth: [MailThread] = []
|
|
|
|
|
var older: [MailThread] = []
|
|
|
|
|
|
|
|
|
|
for thread in threads {
|
|
|
|
|
let d = thread.lastUpdated
|
|
|
|
|
if d >= startOfToday {
|
|
|
|
|
today.append(thread)
|
|
|
|
|
} else if d >= startOfWeek {
|
|
|
|
|
thisWeek.append(thread)
|
|
|
|
|
} else if d >= startOfMonth {
|
|
|
|
|
thisMonth.append(thread)
|
|
|
|
|
} else {
|
|
|
|
|
older.append(thread)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var groups: [ThreadGroup] = []
|
|
|
|
|
if !today.isEmpty { groups.append(ThreadGroup(title: "Today", threads: today)) }
|
|
|
|
|
if !thisWeek.isEmpty { groups.append(ThreadGroup(title: "This week", threads: thisWeek)) }
|
|
|
|
|
if !thisMonth.isEmpty { groups.append(ThreadGroup(title: "Earlier this month", threads: thisMonth)) }
|
|
|
|
|
if !older.isEmpty { groups.append(ThreadGroup(title: "Older", threads: older)) }
|
|
|
|
|
return groups
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct ThreadGroup: Identifiable {
|
|
|
|
|
let title: String
|
|
|
|
|
let threads: [MailThread]
|
|
|
|
|
var id: String { title }
|
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: {
|
2026-04-19 20:16:51 +02:00
|
|
|
Image(systemName: "square.and.pencil")
|
|
|
|
|
.font(.system(size: 22, weight: .semibold))
|
|
|
|
|
.foregroundStyle(.white)
|
|
|
|
|
.frame(width: 56, height: 56)
|
|
|
|
|
.background(SIO.tint, in: Circle())
|
|
|
|
|
.shadow(color: SIO.tint.opacity(0.45), radius: 12, x: 0, y: 8)
|
|
|
|
|
.shadow(color: SIO.tint.opacity(0.30), radius: 3, x: 0, y: 2)
|
2026-04-19 16:26:38 +02:00
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
2026-04-19 20:16:51 +02:00
|
|
|
.accessibilityLabel("Compose")
|
2026-04-19 16:26:38 +02:00
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.padding(.horizontal, 18)
|
|
|
|
|
.padding(.bottom, 4)
|
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 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)
|
2026-04-19 20:16:51 +02:00
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 10)
|
2026-04-19 16:26:38 +02:00
|
|
|
.background(
|
2026-04-19 20:16:51 +02:00
|
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
.fill(model.laneFilter == lane ? lane.color.opacity(0.10) : platformSurfaceColor)
|
2026-04-19 16:26:38 +02:00
|
|
|
)
|
|
|
|
|
.overlay(
|
2026-04-19 20:16:51 +02:00
|
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
.strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.10), lineWidth: 0.5)
|
2026-04-19 16:26:38 +02:00
|
|
|
)
|
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 {
|
2026-04-19 20:16:51 +02:00
|
|
|
HStack(alignment: .top, spacing: 10) {
|
|
|
|
|
unreadDot
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
|
|
|
Text(senderName)
|
|
|
|
|
.font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
|
|
|
|
|
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
.layoutPriority(1)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
if thread.messageCount > 1 {
|
|
|
|
|
Text(thread.messageCount, format: .number)
|
|
|
|
|
.font(.caption2.weight(.bold))
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.padding(.horizontal, 5)
|
|
|
|
|
.padding(.vertical, 1)
|
|
|
|
|
.background(Color.secondary.opacity(0.12), in: Capsule())
|
|
|
|
|
.fixedSize()
|
|
|
|
|
}
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Spacer(minLength: 4)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
if thread.isStarred {
|
|
|
|
|
Image(systemName: "star.fill")
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.yellow)
|
|
|
|
|
}
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
if thread.hasAttachments {
|
|
|
|
|
Image(systemName: "paperclip")
|
2026-04-19 16:26:38 +02:00
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Text(shortTimeLabel)
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
2026-04-19 16:26:38 +02:00
|
|
|
.lineLimit(1)
|
2026-04-19 20:16:51 +02:00
|
|
|
.fixedSize()
|
|
|
|
|
}
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Text(thread.subject)
|
|
|
|
|
.font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
|
|
|
|
|
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
|
|
|
|
|
.lineLimit(1)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Text(thread.previewText)
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.lineLimit(density.previewLineLimit)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
if density.showsMetaChips {
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
LaneChip(lane: thread.lane)
|
|
|
|
|
|
|
|
|
|
if thread.summary != nil {
|
|
|
|
|
HStack(spacing: 4) {
|
|
|
|
|
Image(systemName: "sparkles")
|
|
|
|
|
Text("Summary")
|
2026-04-19 16:26:38 +02:00
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.font(.caption2.weight(.semibold))
|
|
|
|
|
.padding(.horizontal, 7)
|
|
|
|
|
.padding(.vertical, 3)
|
|
|
|
|
.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 20:16:51 +02:00
|
|
|
.padding(.top, 3)
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
.background(isSelected ? SIO.tint.opacity(0.10) : Color.clear)
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
private var shortTimeLabel: String {
|
|
|
|
|
let calendar = Calendar.current
|
|
|
|
|
let now = Date()
|
|
|
|
|
let date = thread.lastUpdated
|
|
|
|
|
if calendar.isDateInToday(date) {
|
|
|
|
|
return date.formatted(date: .omitted, time: .shortened)
|
|
|
|
|
}
|
|
|
|
|
if calendar.isDateInYesterday(date) {
|
|
|
|
|
return "Yesterday"
|
|
|
|
|
}
|
|
|
|
|
let daysAgo = calendar.dateComponents([.day], from: date, to: now).day ?? 0
|
|
|
|
|
if daysAgo < 7 {
|
|
|
|
|
return date.formatted(.dateTime.weekday(.abbreviated))
|
|
|
|
|
}
|
|
|
|
|
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
|
|
|
|
|
if sameYear {
|
|
|
|
|
return date.formatted(.dateTime.day().month(.abbreviated))
|
|
|
|
|
}
|
|
|
|
|
return date.formatted(.dateTime.day().month(.abbreviated).year(.twoDigits))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 16:26:38 +02:00
|
|
|
@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 20:16:51 +02:00
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
2026-04-19 16:26:38 +02:00
|
|
|
Text(thread.subject)
|
2026-04-19 20:16:51 +02:00
|
|
|
.font(.title2.weight(.bold))
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-04-17 20:46:27 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
HStack(alignment: .center, spacing: 10) {
|
2026-04-19 16:26:38 +02:00
|
|
|
LaneChip(lane: thread.lane)
|
2026-04-17 20:46:27 +02:00
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Text("·")
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
|
|
|
|
Text("\(thread.participants.count) people · \(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
|
2026-04-19 16:26:38 +02:00
|
|
|
.font(.subheadline)
|
|
|
|
|
.foregroundStyle(.secondary)
|
2026-04-17 20:46:27 +02:00
|
|
|
|
2026-04-19 16:26:38 +02:00
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
Text(readingTimeLabel)
|
2026-04-19 16:26:38 +02:00
|
|
|
.font(.subheadline)
|
|
|
|
|
.foregroundStyle(.secondary)
|
2026-04-19 20:16:51 +02:00
|
|
|
.lineLimit(1)
|
|
|
|
|
.fixedSize()
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.padding(.horizontal, 2)
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-19 20:16:51 +02:00
|
|
|
private var readingTimeLabel: String {
|
|
|
|
|
let calendar = Calendar.current
|
|
|
|
|
let now = Date()
|
|
|
|
|
let date = thread.lastUpdated
|
|
|
|
|
if calendar.isDateInToday(date) {
|
|
|
|
|
return "Today · " + date.formatted(date: .omitted, time: .shortened)
|
|
|
|
|
}
|
|
|
|
|
if calendar.isDateInYesterday(date) {
|
|
|
|
|
return "Yesterday · " + date.formatted(date: .omitted, time: .shortened)
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
|
|
|
|
|
let datePart = sameYear
|
|
|
|
|
? date.formatted(.dateTime.day().month(.abbreviated))
|
|
|
|
|
: date.formatted(.dateTime.day().month(.abbreviated).year())
|
|
|
|
|
return datePart + " · " + date.formatted(date: .omitted, time: .shortened)
|
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 20:16:51 +02:00
|
|
|
.padding(14)
|
2026-04-19 16:26:38 +02:00
|
|
|
.background(
|
2026-04-19 20:16:51 +02:00
|
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
.fill(isFocused ? SIO.tint.opacity(0.08) : platformSurfaceColor)
|
2026-04-19 16:26:38 +02:00
|
|
|
)
|
|
|
|
|
.overlay(
|
2026-04-19 20:16:51 +02:00
|
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
.strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.10), lineWidth: 0.5)
|
2026-04-19 16:26:38 +02:00
|
|
|
)
|
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)
|
|
|
|
|
}
|
2026-04-19 20:16:51 +02:00
|
|
|
.background(platformSurfaceColor, in: Capsule(style: .continuous))
|
|
|
|
|
.overlay(
|
|
|
|
|
Capsule(style: .continuous)
|
|
|
|
|
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)
|
|
|
|
|
)
|
|
|
|
|
.shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 6)
|
2026-04-19 16:26:38 +02:00
|
|
|
|
|
|
|
|
Button(action: onSend) {
|
2026-04-19 20:16:51 +02:00
|
|
|
Image(systemName: "paperplane.fill")
|
2026-04-19 16:26:38 +02:00
|
|
|
.font(.headline.weight(.bold))
|
|
|
|
|
.foregroundStyle(.white)
|
2026-04-19 20:16:51 +02:00
|
|
|
.frame(width: 48, height: 48)
|
2026-04-19 16:26:38 +02:00
|
|
|
.background(SIO.tint, in: Circle())
|
2026-04-19 20:16:51 +02:00
|
|
|
.shadow(color: SIO.tint.opacity(0.4), radius: 10, x: 0, y: 6)
|
2026-04-19 16:26:38 +02:00
|
|
|
}
|
|
|
|
|
.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 00:46:00 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-04-19 20:16:51 +02:00
|
|
|
private let platformSurfaceColor = Color(nsColor: .controlBackgroundColor)
|
2026-04-19 16:26:38 +02:00
|
|
|
#else
|
|
|
|
|
private let platformBackground = Color(uiColor: .systemBackground)
|
2026-04-19 20:16:51 +02:00
|
|
|
private let platformSurfaceColor = Color(uiColor: .secondarySystemGroupedBackground)
|
2026-04-19 16:26:38 +02:00
|
|
|
#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
|
|
|
}
|