Rewrite the Mail feature to match the Apple-native look from the handoff spec: lane-split inbox, AI summary card, clean ThreadRow, Cc/From + format toolbar in Compose. Drop the gradient hero surfaces and blurred canvas backgrounds the spec calls out as anti-patterns, and introduce a token-backed design layer so the lane palette and SIO tint live in the asset catalog. - Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople (light + dark variants). - Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum, LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles, and a glass-chrome helper with iOS 26 / material fallback. - Extend MailThread with lane + summary; custom Codable keeps old payloads decodable. Seed mock threads with sensible lanes and hand-write summaries on launch-copy, investor-update, roadmap-notes. - Add lane filtering to AppViewModel (selectedLane, selectLane, laneUnreadCount, laneThreadCount). - Rewrite MailRootView end to end: sidebar with Inbox/lane rows, lane filter strip, Apple-native ThreadRow (avatar, unread dot, lane chip, summary chip), ThreadReadingView with AI summary + floating reply pill, ComposeView with To/Cc/From/Subject and a format toolbar. - Wire Assets.xcassets + SIODesign.swift into project.pbxproj. Accessibility identifiers preserved byte-identical; new ones (mailbox.lane.*, lane.chip.*) added only where new. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
965 lines
33 KiB
Swift
965 lines
33 KiB
Swift
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<Bool> {
|
|
Binding(
|
|
get: { model.errorMessage != nil },
|
|
set: { isPresented in
|
|
if !isPresented { model.errorMessage = nil }
|
|
}
|
|
)
|
|
}
|
|
|
|
private var searchTextBinding: Binding<String> {
|
|
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<Bool> {
|
|
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<MailThread.ID?> {
|
|
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<String>, 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<String>) -> 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
|
|
}
|
|
}
|