2026-04-17 20:46:27 +02:00
|
|
|
import Foundation
|
|
|
|
|
import Observation
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
@Observable
|
|
|
|
|
final class AppViewModel {
|
|
|
|
|
var selectedMailbox: Mailbox = .inbox
|
Align Mail UI with social.io design handoff
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>
2026-04-19 16:22:10 +02:00
|
|
|
var selectedLane: Lane?
|
2026-04-17 20:46:27 +02:00
|
|
|
var selectedThreadID: MailThread.ID?
|
|
|
|
|
var focusedMessageRouteID: String?
|
|
|
|
|
var searchText = ""
|
|
|
|
|
var showUnreadOnly = false
|
|
|
|
|
var isComposing = false
|
|
|
|
|
var composeDraft = ComposeDraft()
|
|
|
|
|
var threads: [MailThread] = []
|
|
|
|
|
var isLoading = false
|
2026-04-19 00:46:00 +02:00
|
|
|
var isSending = false
|
2026-04-17 20:46:27 +02:00
|
|
|
var errorMessage: String?
|
|
|
|
|
var mailboxNavigationToken = UUID()
|
|
|
|
|
var threadNavigationToken = UUID()
|
|
|
|
|
|
|
|
|
|
private let service: MailServicing
|
|
|
|
|
private let controlService: AppControlServicing
|
|
|
|
|
private var pendingNavigationCommand: AppNavigationCommand?
|
|
|
|
|
private var isListeningForBackendCommands = false
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
service: MailServicing = MockMailService(),
|
|
|
|
|
controlService: AppControlServicing = MockBackendControlService()
|
|
|
|
|
) {
|
|
|
|
|
self.service = service
|
|
|
|
|
self.controlService = controlService
|
|
|
|
|
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
|
|
|
|
|
apply(command: command)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var selectedThread: MailThread? {
|
|
|
|
|
get { threads.first(where: { $0.id == selectedThreadID }) }
|
|
|
|
|
set { selectedThreadID = newValue?.id }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var filteredThreads: [MailThread] {
|
|
|
|
|
threads
|
|
|
|
|
.filter { thread in
|
|
|
|
|
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
|
|
|
|
|
}
|
Align Mail UI with social.io design handoff
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>
2026-04-19 16:22:10 +02:00
|
|
|
.filter { thread in
|
|
|
|
|
guard let lane = selectedLane else { return true }
|
|
|
|
|
return thread.lane == lane
|
|
|
|
|
}
|
2026-04-17 20:46:27 +02:00
|
|
|
.filter { thread in
|
|
|
|
|
!showUnreadOnly || thread.isUnread
|
|
|
|
|
}
|
|
|
|
|
.filter(matchesSearch)
|
|
|
|
|
.sorted { $0.lastUpdated > $1.lastUpdated }
|
|
|
|
|
}
|
|
|
|
|
|
Align Mail UI with social.io design handoff
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>
2026-04-19 16:22:10 +02:00
|
|
|
func laneUnreadCount(_ lane: Lane) -> Int {
|
|
|
|
|
threads.filter { thread in
|
|
|
|
|
let inCurrentMailbox = selectedMailbox == .starred
|
|
|
|
|
? thread.isStarred
|
|
|
|
|
: thread.mailbox == selectedMailbox
|
|
|
|
|
return inCurrentMailbox && thread.lane == lane && thread.isUnread
|
|
|
|
|
}.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func laneThreadCount(_ lane: Lane) -> Int {
|
|
|
|
|
threads.filter { thread in
|
|
|
|
|
thread.mailbox == .inbox && thread.lane == lane
|
|
|
|
|
}.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectLane(_ lane: Lane?) {
|
|
|
|
|
selectedLane = lane
|
|
|
|
|
clearThreadSelection()
|
|
|
|
|
mailboxNavigationToken = UUID()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 20:46:27 +02:00
|
|
|
var totalUnreadCount: Int {
|
|
|
|
|
threads.filter(\.isUnread).count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func threadCount(in mailbox: Mailbox) -> Int {
|
|
|
|
|
threads.filter { thread in
|
|
|
|
|
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
|
|
|
|
}
|
|
|
|
|
.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func unreadCount(in mailbox: Mailbox) -> Int {
|
|
|
|
|
threads.filter { thread in
|
|
|
|
|
let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
|
|
|
|
return matchesMailbox && thread.isUnread
|
|
|
|
|
}
|
|
|
|
|
.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func load() async {
|
|
|
|
|
guard threads.isEmpty else { return }
|
|
|
|
|
|
|
|
|
|
isLoading = true
|
|
|
|
|
defer { isLoading = false }
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
threads = try await service.loadThreads()
|
|
|
|
|
|
|
|
|
|
if let command = pendingNavigationCommand {
|
|
|
|
|
pendingNavigationCommand = nil
|
|
|
|
|
apply(command: command)
|
|
|
|
|
} else {
|
|
|
|
|
reconcileSelectionForCurrentFilters()
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to load mail."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toggleStar(for thread: MailThread) {
|
|
|
|
|
toggleStar(forThreadID: thread.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toggleStar(forThreadID threadID: MailThread.ID) {
|
|
|
|
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
|
|
|
|
var updatedThread = threads[index]
|
|
|
|
|
updatedThread.isStarred.toggle()
|
|
|
|
|
threads[index] = updatedThread
|
|
|
|
|
reconcileSelectionForCurrentFilters()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toggleRead(for thread: MailThread) {
|
|
|
|
|
toggleRead(forThreadID: thread.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toggleRead(forThreadID threadID: MailThread.ID) {
|
|
|
|
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
|
|
|
|
var updatedThread = threads[index]
|
|
|
|
|
updatedThread.isUnread.toggle()
|
|
|
|
|
threads[index] = updatedThread
|
|
|
|
|
reconcileSelectionForCurrentFilters()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectMailbox(_ mailbox: Mailbox) {
|
|
|
|
|
selectedMailbox = mailbox
|
|
|
|
|
clearThreadSelection()
|
|
|
|
|
mailboxNavigationToken = UUID()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setUnreadOnly(_ unreadOnly: Bool) {
|
|
|
|
|
showUnreadOnly = unreadOnly
|
|
|
|
|
clearThreadSelection()
|
|
|
|
|
mailboxNavigationToken = UUID()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setSearchText(_ text: String) {
|
|
|
|
|
searchText = text
|
|
|
|
|
reconcileSelectionForCurrentFilters()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func startCompose() {
|
|
|
|
|
composeDraft = ComposeDraft()
|
|
|
|
|
focusedMessageRouteID = nil
|
|
|
|
|
isComposing = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
|
|
|
|
|
guard let thread = thread(withID: threadID) else { return }
|
|
|
|
|
|
|
|
|
|
selectedThreadID = threadID
|
|
|
|
|
|
|
|
|
|
if let focusedMessageRouteID,
|
|
|
|
|
thread.messages.contains(where: { $0.routeID == focusedMessageRouteID }) {
|
|
|
|
|
self.focusedMessageRouteID = focusedMessageRouteID
|
|
|
|
|
} else {
|
|
|
|
|
self.focusedMessageRouteID = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
threadNavigationToken = UUID()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dismissThreadSelection() {
|
|
|
|
|
clearThreadSelection()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func beginBackendControl() async {
|
|
|
|
|
guard !isListeningForBackendCommands else { return }
|
|
|
|
|
isListeningForBackendCommands = true
|
2026-04-19 00:46:00 +02:00
|
|
|
defer { isListeningForBackendCommands = false }
|
2026-04-17 20:46:27 +02:00
|
|
|
|
|
|
|
|
for await command in controlService.commands() {
|
|
|
|
|
apply(command: command)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func apply(url: URL) {
|
|
|
|
|
guard let command = AppNavigationCommand.from(url: url) else {
|
|
|
|
|
errorMessage = "Unable to open requested destination."
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
apply(command: command)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func apply(command: AppNavigationCommand) {
|
|
|
|
|
switch command {
|
|
|
|
|
case let .mailbox(mailbox, search, unreadOnly):
|
|
|
|
|
isComposing = false
|
|
|
|
|
searchText = search ?? ""
|
|
|
|
|
showUnreadOnly = unreadOnly ?? false
|
|
|
|
|
selectMailbox(mailbox)
|
|
|
|
|
|
|
|
|
|
case let .thread(threadRouteID, mailbox, messageRouteID, search, unreadOnly):
|
|
|
|
|
guard !threads.isEmpty else {
|
|
|
|
|
pendingNavigationCommand = command
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchText = search ?? ""
|
|
|
|
|
showUnreadOnly = unreadOnly ?? false
|
|
|
|
|
isComposing = false
|
|
|
|
|
|
|
|
|
|
guard let thread = threads.first(where: { $0.routeID == threadRouteID }) else {
|
|
|
|
|
errorMessage = "Unable to open requested conversation."
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
selectedMailbox = mailbox ?? thread.mailbox
|
|
|
|
|
openThread(withID: thread.id, focusedMessageRouteID: messageRouteID)
|
|
|
|
|
|
|
|
|
|
case let .compose(draft):
|
|
|
|
|
focusedMessageRouteID = nil
|
|
|
|
|
composeDraft = draft
|
|
|
|
|
isComposing = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 00:46:00 +02:00
|
|
|
func sendCurrentDraft() async -> Bool {
|
|
|
|
|
guard !isSending else { return false }
|
|
|
|
|
|
2026-04-17 20:46:27 +02:00
|
|
|
let draft = composeDraft
|
2026-04-19 00:46:00 +02:00
|
|
|
isSending = true
|
|
|
|
|
defer { isSending = false }
|
2026-04-17 20:46:27 +02:00
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let sentThread = try await service.send(draft: draft)
|
|
|
|
|
threads.insert(sentThread, at: 0)
|
|
|
|
|
selectedMailbox = .sent
|
|
|
|
|
openThread(withID: sentThread.id)
|
2026-04-19 00:46:00 +02:00
|
|
|
isComposing = false
|
|
|
|
|
return true
|
2026-04-17 20:46:27 +02:00
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to send message."
|
2026-04-19 00:46:00 +02:00
|
|
|
return false
|
2026-04-17 20:46:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func matchesSearch(thread: MailThread) -> Bool {
|
|
|
|
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !query.isEmpty else { return true }
|
|
|
|
|
|
|
|
|
|
let haystack = [
|
|
|
|
|
thread.subject,
|
|
|
|
|
thread.previewText,
|
|
|
|
|
thread.participants.map(\.name).joined(separator: " "),
|
|
|
|
|
thread.tags.joined(separator: " ")
|
|
|
|
|
]
|
|
|
|
|
.joined(separator: " ")
|
|
|
|
|
.localizedLowercase
|
|
|
|
|
|
|
|
|
|
return haystack.contains(query.localizedLowercase)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func thread(withID threadID: MailThread.ID) -> MailThread? {
|
|
|
|
|
threads.first(where: { $0.id == threadID })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func clearThreadSelection() {
|
|
|
|
|
selectedThreadID = nil
|
|
|
|
|
focusedMessageRouteID = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func reconcileSelectionForCurrentFilters() {
|
|
|
|
|
if let selectedThreadID,
|
|
|
|
|
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearThreadSelection()
|
|
|
|
|
}
|
|
|
|
|
}
|