import SwiftUI #if os(macOS) import AppKit #else import UIKit #endif struct MailRootView: View { @Bindable var model: AppViewModel @State private var preferredCompactColumn: NavigationSplitViewColumn = .content var body: some View { NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { MailSidebarView(model: model) } content: { ThreadListView(model: model) } detail: { ThreadReadingView(model: model) } .navigationSplitViewStyle(.balanced) .tint(SIO.tint) .searchable(text: searchTextBinding, prompt: "Search mail") .toolbar { if showsToolbarCompose { ToolbarItem(placement: .primaryAction) { Button { model.startCompose() } label: { Label("Compose", systemImage: "square.and.pencil") } } } } .sheet(isPresented: $model.isComposing) { ComposeView(model: model) } .task { await model.load() } .task { await model.beginBackendControl() } .onChange(of: model.mailboxNavigationToken) { showCompactColumn(.content) } .onChange(of: model.threadNavigationToken) { showCompactColumn(.detail) } .onChange(of: model.selectedThreadID) { if model.selectedThreadID == nil { showCompactColumn(.content) } } .onChange(of: model.isComposing) { guard model.isComposing, usesCompactSplitNavigation else { return } model.dismissThreadSelection() showCompactColumn(.content) } .onChange(of: preferredCompactColumn) { guard usesCompactSplitNavigation, preferredCompactColumn != .detail else { return } model.dismissThreadSelection() } .alert("Something went wrong", isPresented: errorPresented) { Button("OK") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") } } private var errorPresented: Binding { Binding( get: { model.errorMessage != nil }, set: { isPresented in if !isPresented { model.errorMessage = nil } } ) } private var searchTextBinding: Binding { Binding( get: { model.searchText }, set: { model.setSearchText($0) } ) } private var showsToolbarCompose: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom != .phone #else true #endif } private var usesCompactSplitNavigation: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom == .phone #else false #endif } private func showCompactColumn(_ column: NavigationSplitViewColumn) { guard usesCompactSplitNavigation else { return } preferredCompactColumn = column } } // MARK: - Sidebar private struct MailSidebarView: View { @Bindable var model: AppViewModel var body: some View { List { Section { SidebarAccountHeader() .listRowInsets(EdgeInsets(top: 14, leading: 14, bottom: 10, trailing: 14)) .listRowBackground(Color.clear) } Section("Inbox") { sidebarButton( title: "All", systemImage: "tray.full", badge: model.threadCount(in: .inbox), isSelected: model.selectedMailbox == .inbox && model.selectedLane == nil ) { model.selectMailbox(.inbox) model.selectLane(nil) } .accessibilityIdentifier("sidebar.mailbox.inbox") ForEach(Lane.allCases, id: \.self) { lane in sidebarLaneButton(lane: lane) .accessibilityIdentifier("mailbox.lane.\(lane.rawValue)") } } Section("Smart") { sidebarButton( title: "Starred", systemImage: "star", badge: model.threadCount(in: .starred), isSelected: model.selectedMailbox == .starred ) { model.selectMailbox(.starred) model.selectLane(nil) } .accessibilityIdentifier("sidebar.mailbox.starred") } Section("Mailboxes") { ForEach([Mailbox.sent, .drafts, .archive], id: \.self) { mailbox in sidebarButton( title: mailbox.title, systemImage: mailbox.systemImage, badge: model.threadCount(in: mailbox), isSelected: model.selectedMailbox == mailbox ) { model.selectMailbox(mailbox) model.selectLane(nil) } .accessibilityIdentifier("sidebar.mailbox.\(mailbox.id)") } } Section("Filters") { Toggle(isOn: unreadOnlyBinding) { HStack { Label("Unread only", systemImage: "circle.badge") Spacer() Text(model.totalUnreadCount, format: .number) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } .accessibilityIdentifier("filter.unread") } } .listStyle(.sidebar) .navigationTitle("social.io") } private var unreadOnlyBinding: Binding { Binding( get: { model.showUnreadOnly }, set: { model.setUnreadOnly($0) } ) } @ViewBuilder private func sidebarButton( title: String, systemImage: String, badge: Int, isSelected: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 10) { Image(systemName: systemImage) .frame(width: 20) .foregroundStyle(isSelected ? SIO.tint : .secondary) Text(title) .foregroundStyle(isSelected ? .primary : .primary) .fontWeight(isSelected ? .semibold : .regular) Spacer() if badge > 0 { Text(badge, format: .number) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .listRowBackground(isSelected ? SIO.tint.opacity(0.10) : Color.clear) } @ViewBuilder private func sidebarLaneButton(lane: Lane) -> some View { let isSelected = model.selectedMailbox == .inbox && model.selectedLane == lane Button { model.selectMailbox(.inbox) model.selectLane(lane) } label: { HStack(spacing: 10) { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(lane.color) .frame(width: 14, height: 14) Text(lane.label) .foregroundStyle(.primary) .fontWeight(isSelected ? .semibold : .regular) Spacer() let count = model.laneUnreadCount(lane) if count > 0 { Text(count, format: .number) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .listRowBackground(isSelected ? SIO.tint.opacity(0.10) : Color.clear) } } private struct SidebarAccountHeader: View { var body: some View { HStack(spacing: 12) { AvatarView(name: "Phil Kunz", size: 34, tint: SIO.tint) VStack(alignment: .leading, spacing: 1) { Text("Phil Kunz") .font(.subheadline.weight(.semibold)) Text("phil@social.io") .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) } } } // MARK: - Thread list private struct ThreadListView: View { @Bindable var model: AppViewModel var body: some View { VStack(spacing: 0) { if model.selectedMailbox == .inbox { LaneFilterStrip(model: model) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 12) } if model.isLoading { ProgressView("Loading mail…") .frame(maxWidth: .infinity, maxHeight: .infinity) } 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(selection: selectionBinding) { ForEach(model.filteredThreads) { thread in Button { model.openThread(withID: thread.id) } label: { ThreadRow(thread: thread, isSelected: thread.id == model.selectedThreadID) } .buttonStyle(.plain) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .listRowBackground( thread.id == model.selectedThreadID ? SIO.tint.opacity(0.10) : Color.clear ) .contextMenu { Button(thread.isUnread ? "Mark Read" : "Mark Unread") { model.toggleRead(for: thread) } Button(thread.isStarred ? "Remove Star" : "Star Thread") { model.toggleStar(for: thread) } } } } .listStyle(.plain) } } .safeAreaInset(edge: .bottom) { FloatingComposeButton(model: model) } .navigationTitle(navigationTitleText) .mailInlineNavigationTitle() } private var navigationTitleText: String { if model.selectedMailbox == .inbox, let lane = model.selectedLane { return lane.label } return model.selectedMailbox.title } private var selectionBinding: Binding { Binding( get: { model.selectedThreadID }, set: { newValue in if let id = newValue { model.openThread(withID: id) } else { model.dismissThreadSelection() } } ) } } private struct LaneFilterStrip: View { @Bindable var model: AppViewModel var body: some View { HStack(spacing: 8) { laneChip( label: "All", color: .secondary, count: model.threadCount(in: .inbox), isSelected: model.selectedLane == nil, id: "all" ) { model.selectLane(nil) } ForEach(Lane.allCases, id: \.self) { lane in laneChip( label: lane.label, color: lane.color, count: model.laneUnreadCount(lane), isSelected: model.selectedLane == lane, id: lane.rawValue ) { model.selectLane(lane) } } } .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder private func laneChip( label: String, color: Color, count: Int, isSelected: Bool, id: String, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { Circle() .fill(color) .frame(width: 6, height: 6) Text(label.uppercased()) .font(.caption2.weight(.semibold)) .tracking(0.4) .foregroundStyle(.secondary) } Text(count, format: .number) .font(.system(size: 20, weight: .bold, design: .default)) .monospacedDigit() .foregroundStyle(.primary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) .fill(isSelected ? color.opacity(0.14) : Color.secondary.opacity(0.06)) ) .overlay( RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) .stroke(isSelected ? color.opacity(0.35) : Color.clear, lineWidth: 1) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("lane.chip.\(id)") } } private struct ThreadRow: View { let thread: MailThread let isSelected: Bool var body: some View { HStack(alignment: .top, spacing: 10) { // Unread dot or spacer ZStack { if thread.isUnread { Circle().fill(SIO.tint).frame(width: 8, height: 8) } } .frame(width: 8, height: 8) .padding(.top, 6) AvatarView( name: senderName, size: 32, tint: thread.participants.first?.avatarTint ?? SIO.tint ) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(senderName) .font(.subheadline.weight(thread.isUnread ? .semibold : .regular)) .foregroundStyle(isSelected ? SIO.tint : .primary) .lineLimit(1) if thread.messages.count > 1 { Text("\(thread.messages.count)") .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() } Spacer(minLength: 4) if thread.isStarred { Image(systemName: "star.fill") .font(.caption2) .foregroundStyle(.yellow) } Text(thread.lastUpdated.formatted(.relative(presentation: .named))) .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() } Text(thread.subject) .font(.subheadline.weight(thread.isUnread ? .medium : .regular)) .foregroundStyle(thread.isUnread ? .primary : .secondary) .lineLimit(1) Text(thread.previewText) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(2) HStack(spacing: 6) { LaneChip(lane: thread.lane, compact: true) if thread.summary != nil { HStack(spacing: 3) { Image(systemName: "sparkles") Text("Summary") } .font(.caption2.weight(.semibold)) .foregroundStyle(SIO.tint) .padding(.horizontal, 6) .padding(.vertical, 2) .background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous)) } } .padding(.top, 2) } } .padding(.vertical, 6) .contentShape(Rectangle()) .accessibilityIdentifier("thread.\(thread.routeID)") } private var senderName: String { thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "—" } } // MARK: - Thread reading private struct ThreadReadingView: View { @Bindable var model: AppViewModel var body: some View { Group { if let thread = model.selectedThread { ThreadReadingContent(thread: thread, model: model) } 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, alignment: .top) .navigationTitle("Conversation") } } private struct ThreadReadingContent: View { let thread: MailThread @Bindable var model: AppViewModel var body: some View { ZStack(alignment: .bottom) { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { ThreadHeader(thread: thread) if let summary = thread.summary, !summary.isEmpty { AISummaryCard( messageCount: thread.messages.count, bullets: summary ) } ForEach(thread.messages) { message in MessageCard( message: message, isFocused: message.routeID == model.focusedMessageRouteID ) .id(message.routeID) } Color.clear.frame(height: 84) } .padding(.horizontal, 18) .padding(.top, 10) .frame(maxWidth: 820, alignment: .leading) .frame(maxWidth: .infinity, alignment: .top) } .onAppear { scrollToFocus(proxy, animated: false) } .onChange(of: model.focusedMessageRouteID) { scrollToFocus(proxy) } .onChange(of: thread.routeID) { scrollToFocus(proxy, animated: false) } } ReplyPill(thread: thread) { model.startCompose() } .padding(.horizontal, 16) .padding(.bottom, 16) } .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { model.toggleStar(for: thread) } label: { Image(systemName: thread.isStarred ? "star.fill" : "star") } Button { model.toggleRead(for: thread) } label: { Image(systemName: thread.isUnread ? "envelope.open" : "envelope.badge") } Button { model.startCompose() } label: { Image(systemName: "arrowshape.turn.up.left") } } } } private func scrollToFocus(_ proxy: ScrollViewProxy, animated: Bool = true) { guard let id = model.focusedMessageRouteID else { return } if animated { withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(id, anchor: .center) } } else { proxy.scrollTo(id, anchor: .center) } } } private struct ThreadHeader: View { let thread: MailThread var body: some View { VStack(alignment: .leading, spacing: 6) { Text(thread.subject) .font(.title2.weight(.bold)) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { HStack(spacing: -8) { ForEach(thread.participants.prefix(3)) { person in AvatarView(name: person.name, size: 22, tint: person.avatarTint) .overlay(Circle().stroke(Color.systemBackground, lineWidth: 2)) } } .padding(.leading, 4) LaneChip(lane: thread.lane, compact: true) Text("\(thread.participants.count) people · \(thread.messages.count) messages") .font(.caption) .foregroundStyle(.secondary) Spacer(minLength: 0) Text(thread.lastUpdated.formatted(.relative(presentation: .named))) .font(.caption) .foregroundStyle(.secondary) } } } } private struct MessageCard: View { let message: MailMessage let isFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 10) { AvatarView(name: message.sender.name, size: 32, tint: message.sender.avatarTint) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(message.sender.name) .font(.subheadline.weight(.semibold)) Spacer(minLength: 0) Text(message.sentAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() } Text("to \(message.recipients.map(\.name).joined(separator: ", "))") .font(.caption) .foregroundStyle(.secondary) } } Text(message.body) .font(.system(size: SIO.bodyFontSize)) .lineSpacing(5) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( Color.cardBackground, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) ) .overlay( RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) .stroke(Color.primary.opacity(0.08), lineWidth: 0.5) ) .overlay(alignment: .topTrailing) { if isFocused { RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) .stroke(SIO.tint.opacity(0.35), lineWidth: 1.5) } } .accessibilityIdentifier("message.\(message.routeID)") } } private struct ReplyPill: View { let thread: MailThread let onTap: () -> Void var body: some View { HStack(spacing: 8) { Button(action: onTap) { HStack { Text("Reply to \(senderFirstName)…") .font(.subheadline) .foregroundStyle(.secondary) Spacer(minLength: 0) } .padding(.horizontal, 16) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background( Capsule().fill(Color.cardBackground) ) .overlay(Capsule().stroke(Color.primary.opacity(0.10), lineWidth: 0.5)) .contentShape(Capsule()) } .buttonStyle(.plain) Button(action: onTap) { Image(systemName: "paperplane.fill") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white) .frame(width: 44, height: 44) .background(SIO.tint, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel("Send reply") } .sioGlassChromeContainer(spacing: 8) } private var senderFirstName: String { let other = thread.latestMessage?.sender.name ?? "—" return other.split(separator: " ").first.map(String.init) ?? other } } // MARK: - Floating compose (iPhone only) private struct FloatingComposeButton: View { @Bindable var model: AppViewModel var body: some View { if shouldShow { HStack { Spacer() Button { model.startCompose() } label: { Image(systemName: "square.and.pencil") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) .frame(width: 56, height: 56) .background(SIO.tint, in: Circle()) .shadow(color: SIO.tint.opacity(0.35), radius: 14, x: 0, y: 6) } .buttonStyle(.plain) .accessibilityIdentifier("compose.floating") .padding(.trailing, 18) .padding(.bottom, 18) } } } private var shouldShow: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom == .phone #else false #endif } } // MARK: - Compose private struct ComposeView: View { @Environment(\.dismiss) private var dismiss @Bindable var model: AppViewModel @State private var cc: String = "" var body: some View { Group { if usesCompactLayout { composeBody } else { composeBody.frame(minWidth: 560, minHeight: 520) } } .accessibilityIdentifier("compose.view") } private var composeBody: some View { NavigationStack { VStack(spacing: 0) { ScrollView { VStack(spacing: 0) { composeRow(label: "To", placeholder: "name@example.com", text: $model.composeDraft.to) .accessibilityIdentifier("compose.to") Divider() composeRow(label: "Cc", placeholder: "", text: $cc) Divider() fromRow Divider() composeRow(label: "Subject", placeholder: "What's this about?", text: $model.composeDraft.subject, accessID: "compose.subject") Divider() ZStack(alignment: .topLeading) { if model.composeDraft.body.isEmpty { Text("Write your message…") .font(.system(size: SIO.bodyFontSize)) .foregroundStyle(.secondary) .padding(.horizontal, 18) .padding(.vertical, 14) } TextEditor(text: $model.composeDraft.body) .font(.system(size: SIO.bodyFontSize)) .scrollContentBackground(.hidden) .frame(minHeight: 220) .padding(.horizontal, 14) .padding(.vertical, 8) .disabled(model.isSending) .accessibilityIdentifier("compose.body") } } } .scrollDismissesKeyboard(.interactively) FormatToolbar() } .navigationTitle("New Message") .composeTitleDisplay(compact: usesCompactLayout) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .disabled(model.isSending) .accessibilityIdentifier("compose.cancel") } ToolbarItem(placement: .confirmationAction) { Button(model.isSending ? "Sending…" : "Send") { Task { _ = await model.sendCurrentDraft() } } .buttonStyle(.borderedProminent) .tint(SIO.tint) .disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty) .accessibilityIdentifier("compose.send") } } } } @ViewBuilder private func composeRow(label: String, placeholder: String, text: Binding, accessID: String? = nil) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(label) .font(.system(size: 15)) .foregroundStyle(.secondary) .frame(width: 52, alignment: .leading) composeTextField(placeholder: placeholder, text: text) .font(.system(size: 15)) .disabled(model.isSending) .modifier(OptionalAccessibility(id: accessID)) } .padding(.horizontal, 18) .padding(.vertical, 12) } @ViewBuilder private func composeTextField(placeholder: String, text: Binding) -> some View { #if os(iOS) TextField(placeholder, text: text) .textFieldStyle(.plain) .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) #else TextField(placeholder, text: text) .textFieldStyle(.plain) .textContentType(.emailAddress) #endif } private var fromRow: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text("From") .font(.system(size: 15)) .foregroundStyle(.secondary) .frame(width: 52, alignment: .leading) Text("phil@social.io") .font(.system(size: 15)) .foregroundStyle(.primary) Image(systemName: "chevron.up.chevron.down") .font(.caption2) .foregroundStyle(.secondary) Spacer(minLength: 0) } .padding(.horizontal, 18) .padding(.vertical, 12) } private var usesCompactLayout: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom == .phone #else false #endif } } private struct OptionalAccessibility: ViewModifier { let id: String? func body(content: Content) -> some View { if let id { content.accessibilityIdentifier(id) } else { content } } } private struct FormatToolbar: View { var body: some View { HStack(spacing: 18) { Text("Aa").font(.system(size: 16)) Text("B").font(.system(size: 16, weight: .bold)) Text("I").font(.system(size: 16)).italic() Text("U").font(.system(size: 16)).underline() Image(systemName: "paperclip") Image(systemName: "camera") Spacer(minLength: 0) } .font(.system(size: 16)) .foregroundStyle(.secondary) .padding(.horizontal, 18) .padding(.vertical, 10) .background(.bar) } } // MARK: - Platform helpers private extension View { @ViewBuilder func mailInlineNavigationTitle() -> some View { #if os(iOS) self.navigationBarTitleDisplayMode(.inline) #else self #endif } @ViewBuilder func composeTitleDisplay(compact: Bool) -> some View { #if os(iOS) self.navigationBarTitleDisplayMode(compact ? .inline : .automatic) #else self #endif } } private extension Color { static var systemBackground: Color { #if os(iOS) Color(uiColor: .systemBackground) #else Color(nsColor: .windowBackgroundColor) #endif } static var cardBackground: Color { #if os(iOS) Color(uiColor: .secondarySystemBackground) #else Color(nsColor: .textBackgroundColor) #endif } }