import SwiftUI #if os(macOS) import AppKit #else import UIKit #endif private enum CompactMailTab: Hashable { case inbox case search case compose case activity } enum MailLayoutMode { case compact case regular } struct MailRootView: View { @Bindable var model: AppViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var preferredCompactColumn: NavigationSplitViewColumn = .content @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 var body: some View { Group { if usesCompactLayout { compactScene } else { regularScene } } .overlay { if model.isCommandPalettePresented { CommandPaletteView(model: model) } } .overlay(alignment: .topLeading) { if !usesCompactLayout { CommandPaletteShortcut(model: model) } } .task { await model.load() } .task { await model.beginBackendControl() } .onAppear { syncRegularColumns() } .onChange(of: readingPaneRawValue) { syncRegularColumns() } .onChange(of: model.mailboxNavigationToken) { if usesCompactLayout { compactTab = .inbox } } .onChange(of: model.threadNavigationToken) { if usesCompactLayout { compactTab = .inbox } } .onChange(of: model.isComposing) { syncRegularColumns() } .alert("Something went wrong", isPresented: errorPresented) { Button("OK") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") } } 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 { Binding( get: { model.searchText }, set: { model.setSearchText($0) } ) } private var compactThreadPresented: Binding { Binding( get: { model.selectedThreadID != nil && !model.isComposing }, set: { isPresented in if !isPresented { model.dismissThreadSelection() } } ) } private var compactComposePresented: Binding { Binding( get: { model.isComposing }, set: { isPresented in if !isPresented { model.dismissCompose() } } ) } private var regularThreadSheetPresented: Binding { Binding( get: { !usesCompactLayout && readingPanePreference == .off && model.selectedThreadID != nil && !model.isComposing }, set: { isPresented in if !isPresented { model.dismissThreadSelection() } } ) } private var errorPresented: Binding { Binding( get: { model.errorMessage != nil }, set: { isPresented in if !isPresented { model.errorMessage = nil } } ) } private var usesCompactLayout: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact #else false #endif } private var readingPanePreference: ReadingPanePreference { ReadingPanePreference(rawValue: readingPaneRawValue) ?? .right } private func syncRegularColumns() { guard !usesCompactLayout else { return } regularColumnVisibility = readingPanePreference == .off && !model.isComposing ? .doubleColumn : .all } } struct SidebarView: View { @Bindable var model: AppViewModel var body: some View { List { Section { SidebarAccountHeader(model: model) .listRowInsets(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10)) .listRowBackground(Color.clear) } Section("Inbox") { inboxAllRow laneRow(.feed) laneRow(.paper) laneRow(.people) } Section("Smart") { mailboxRow(.starred) mailboxRow(.snoozed) mailboxRow(.screener, accessibilityID: "mailbox.screener") } Section("Mailboxes") { mailboxRow(.sent) mailboxRow(.drafts) mailboxRow(.archive) mailboxRow(.trash) } 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) } } } } .listStyle(.sidebar) .scrollContentBackground(.hidden) .background(platformBackground.ignoresSafeArea()) } 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") } 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)") } 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) } .buttonStyle(.plain) .badge(model.laneCount(lane, in: .inbox)) .accessibilityIdentifier("mailbox.lane.\(lane.rawValue)") } } private struct SidebarAccountHeader: View { @Bindable var model: AppViewModel var body: some View { 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) .font(.subheadline) .foregroundStyle(.secondary) } } HStack(spacing: 12) { compactSummary(title: "Unread", value: model.totalUnreadCount, tint: SIO.tint) compactSummary(title: "Starred", value: model.threadCount(in: .starred), tint: .yellow) } } .padding(16) .sioCardBackground(tint: SIO.tint) } private func compactSummary(title: String, value: Int, tint: Color) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption) .foregroundStyle(.secondary) Text(value, format: .number) .font(.headline.weight(.semibold)) .foregroundStyle(tint) } .frame(maxWidth: .infinity, alignment: .leading) } } 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 { @Bindable var model: AppViewModel let layoutMode: MailLayoutMode @AppStorage("sio.density") private var densityRawValue = "" var body: some View { 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) Divider() Group { if model.isLoading { ProgressView("Loading mail...") .frame(maxWidth: .infinity, maxHeight: .infinity) } 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) } Button(thread.isStarred ? "Remove Star" : "Star") { model.toggleStar(for: thread) } Button("Archive") { model.moveThread(withID: thread.id, to: .archive) } } } .listStyle(.plain) .scrollContentBackground(.hidden) } } } .background(platformBackground.ignoresSafeArea()) .safeAreaInset(edge: .bottom) { if layoutMode == .compact { FloatingComposeButton(model: model) } } } private var density: ThreadRowDensity { if let stored = ThreadRowDensity(rawValue: densityRawValue) { return stored } #if os(macOS) return .cozy #else return .comfortable #endif } } private struct ThreadListSearchHeader: View { @Bindable var model: AppViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { MailSearchField( text: Binding( get: { model.searchText }, set: { model.setSearchText($0) } ), placeholder: "Search mail" ) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { 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") } } } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 10) } } private struct LaneFilterStrip: View { @Bindable var model: AppViewModel var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { filterButton(title: "All", lane: nil) ForEach(Lane.allCases) { lane in filterButton(title: lane.label, lane: lane) } unreadButton } .padding(.horizontal, 16) } } 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") } } private struct ScreenerListView: View { @Bindable var model: AppViewModel var body: some View { 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) } .buttonStyle(PrimaryActionStyle()) Button("Send to Paper") { model.applyScreenerDecision(.sendToPaper, to: thread.id) } .buttonStyle(SecondaryActionStyle()) Button("Block") { model.applyScreenerDecision(.block, to: thread.id) } .buttonStyle(DestructiveStyle()) } } .listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } .listStyle(.plain) .scrollContentBackground(.hidden) } } } private struct FloatingComposeButton: View { @Bindable var model: AppViewModel var body: some View { HStack { Spacer() Button { model.startCompose() } label: { HStack(spacing: 10) { Image(systemName: "square.and.pencil") Text("Compose") } .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) } } private struct CompactLaneOverview: View { @Bindable var model: AppViewModel 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) } 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) ) } .buttonStyle(.plain) } } } } private struct ThreadRow: View { let thread: MailThread let density: ThreadRowDensity let isSelected: Bool 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) } } } } } } .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) ) .accessibilityIdentifier("thread.\(thread.routeID)") } 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) } } struct ThreadReadingView: View { @Bindable var model: AppViewModel @State private var replyText = "" var body: some View { 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) } ForEach(thread.messages) { message in MessageCard( message: message, isFocused: message.routeID == model.focusedMessageRouteID, isLatest: message.id == thread.latestMessage?.id ) .id(message.routeID) } } .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) } } .safeAreaInset(edge: .bottom) { InlineReplyComposer( text: $replyText, placeholder: "Reply to \(replyTargetName(for: thread))..." ) { model.sendInlineReply(replyText, in: thread.id) replyText = "" Haptics.success() } } .onAppear { scrollToFocusedMessage(using: proxy, animated: false) } .onChange(of: model.focusedMessageRouteID) { scrollToFocusedMessage(using: proxy) } .onChange(of: thread.routeID) { scrollToFocusedMessage(using: proxy, animated: false) } } } 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()) } } } 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" } 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) } } } private struct ReadingHeader: View { let thread: MailThread var body: some View { VStack(alignment: .leading, spacing: 14) { Text(thread.subject) .font(.title.weight(.bold)) HStack(alignment: .center, spacing: 12) { participantStack LaneChip(lane: thread.lane) Text("\(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")") .font(.subheadline) .foregroundStyle(.secondary) Spacer(minLength: 0) Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened)) .font(.subheadline) .foregroundStyle(.secondary) } } .padding(18) .sioCardBackground(tint: thread.lane.color) } 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) } } } } private struct ReadingToolbar: View { @Bindable var model: AppViewModel let thread: MailThread var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { toolbarButton("Archive", systemImage: "archivebox", key: "E") { model.moveThread(withID: thread.id, to: .archive) } .keyboardShortcut("e", modifiers: []) toolbarButton("Move", systemImage: "tray.and.arrow.down", key: "I") { model.moveThread(withID: thread.id, to: .inbox) } .keyboardShortcut("i", modifiers: []) toolbarButton("Delete", systemImage: "trash", key: "⌫", destructive: true) { model.moveThread(withID: thread.id, to: .trash) } toolbarButton("Reply", systemImage: "arrowshape.turn.up.left", key: "R") { model.startReply(to: thread.id) } .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]) toolbarButton("Forward", systemImage: "arrowshape.turn.up.right", key: "F") { model.startReply(to: thread.id, forward: true) } .keyboardShortcut("f", modifiers: []) } .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 6) } .background(platformBackground.opacity(0.92)) } private func toolbarButton( _ title: String, systemImage: String, key: String, destructive: Bool = false, action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 10) { Image(systemName: systemImage) Text(title) KeyboardHint(title: key) } .font(.subheadline.weight(.semibold)) .padding(.horizontal, 12) .padding(.vertical, 10) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)) .foregroundStyle(destructive ? Color.red : Color.primary) } .buttonStyle(.plain) } } private struct MessageCard: View { let message: MailMessage let isFocused: Bool let isLatest: Bool var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 12) { AvatarView(name: message.sender.name, color: SIO.tint, size: 32) VStack(alignment: .leading, spacing: 3) { Text(message.sender.name) .font(.headline) Text("to \(message.recipients.map(\.email).joined(separator: ", "))") .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) Text(message.sentAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) } Text(message.body) .sioProse() .textSelection(.enabled) 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)) } } } } } .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) ) .accessibilityIdentifier("message.\(message.routeID)") } } private struct InlineReplyComposer: View { @Binding var text: String let placeholder: String let onSend: () -> Void 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) } 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)) } } struct ComposeView: View { @Environment(\.dismiss) private var dismiss @Bindable var model: AppViewModel var body: some View { 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) } } .background(platformBackground.ignoresSafeArea()) .accessibilityIdentifier("compose.view") .onChange(of: model.composeDraft) { model.queueDraftAutosave() } } } private struct ComposeHeaderRow: View { let title: String let content: Content 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) 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) } } } } 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) } } 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) } .buttonStyle(.plain) Button { last30DaysOnly.toggle() } label: { FilterChip(title: "Last 30 days", isSelected: last30DaysOnly) } .buttonStyle(.plain) Menu { Button("All") { selectedLane = nil } ForEach(Lane.allCases) { lane in Button(lane.label) { selectedLane = lane } } } label: { FilterChip(title: "Lane:", subtitle: selectedLane?.label ?? "All") } } } 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) } } let remaining = Array(filteredResults.dropFirst()) if !remaining.isEmpty { SearchResultSection(title: "Messages (\(remaining.count))") { ForEach(remaining) { thread in resultButton(for: thread) } } } } } .padding(16) } .background(platformBackground.ignoresSafeArea()) .navigationTitle("Search") } 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 } } 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) } } private struct SearchResultSection: View { 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) VStack(spacing: 10) { content } } } } private struct MailSearchField: View { @Binding var text: String let placeholder: String var showsCancel = false var onLongPress: (() -> Void)? var body: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField(placeholder, text: $text) .textFieldStyle(.plain) if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) } 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?() } } } private struct FilterChip: View { let title: String var subtitle: String? = nil var isSelected = false 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) } } private struct ActivityView: View { @Bindable var model: AppViewModel var body: some View { 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() } .padding(16) } .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) } } 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) ) .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 } } .accessibilityIdentifier("commandPalette") } 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) ) } .buttonStyle(.plain) .accessibilityIdentifier("commandPalette.action.\(item.id)") } 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() } .padding(16) .sioCardBackground(tint: SIO.tint) } } 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 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 { 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(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 { @ViewBuilder func compactInboxNavigation(searchText: Binding) -> some View { #if os(iOS) navigationBarTitleDisplayMode(.inline) .searchable(text: searchText, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search mail") #else self #endif } @ViewBuilder func recipientAutocapitalization() -> some View { #if os(iOS) textInputAutocapitalization(.never) #else self #endif } @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) } } #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 { AsyncStream { continuation in continuation.finish() } } }