Local work on the social.io handoff before merging the claude worktree branch. Includes the full per-spec Sources/Core/Design module (8 files), watchOS target under WatchApp/, Live Activity + widget extension, entitlements, scheme, and asset catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
513 lines
16 KiB
Swift
513 lines
16 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AppViewModel {
|
|
var selectedMailbox: Mailbox = .inbox
|
|
var selectedThreadID: MailThread.ID?
|
|
var focusedMessageRouteID: String?
|
|
var searchText = ""
|
|
var showUnreadOnly = false
|
|
var laneFilter: Lane?
|
|
var isComposing = false
|
|
var isCommandPalettePresented = false
|
|
var composeDraft = ComposeDraft(from: "phil@social.io")
|
|
var threads: [MailThread] = []
|
|
var isLoading = false
|
|
var isSending = false
|
|
var errorMessage: String?
|
|
var mailboxNavigationToken = UUID()
|
|
var threadNavigationToken = UUID()
|
|
let currentUser = MailPerson(name: "Phil Kunz", email: "phil@social.io")
|
|
|
|
private let service: MailServicing
|
|
private let controlService: AppControlServicing
|
|
private var pendingNavigationCommand: AppNavigationCommand?
|
|
private var isListeningForBackendCommands = false
|
|
@ObservationIgnored private var composeAutosaveTask: Task<Void, Never>?
|
|
@ObservationIgnored private let autosaveKey = "sio.compose.autosave"
|
|
|
|
init(
|
|
service: MailServicing = MockMailService(),
|
|
controlService: AppControlServicing = MockBackendControlService()
|
|
) {
|
|
self.service = service
|
|
self.controlService = controlService
|
|
restoreAutosavedDraft()
|
|
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] {
|
|
baseThreads(for: selectedMailbox)
|
|
.filter { thread in
|
|
laneFilter == nil || thread.lane == laneFilter
|
|
}
|
|
.filter { thread in
|
|
!showUnreadOnly || thread.isUnread
|
|
}
|
|
.filter(matchesSearch)
|
|
.sorted { $0.lastUpdated > $1.lastUpdated }
|
|
}
|
|
|
|
var totalUnreadCount: Int {
|
|
threads.filter(\.isUnread).count
|
|
}
|
|
|
|
func threadCount(in mailbox: Mailbox) -> Int {
|
|
baseThreads(for: mailbox).count
|
|
}
|
|
|
|
func unreadCount(in mailbox: Mailbox) -> Int {
|
|
baseThreads(for: mailbox)
|
|
.filter { $0.isUnread }
|
|
.count
|
|
}
|
|
|
|
func laneCount(_ lane: Lane?, in mailbox: Mailbox? = nil) -> Int {
|
|
baseThreads(for: mailbox ?? selectedMailbox)
|
|
.filter { lane == nil || $0.lane == lane }
|
|
.count
|
|
}
|
|
|
|
func unreadCount(for lane: Lane?) -> Int {
|
|
baseThreads(for: selectedMailbox)
|
|
.filter { thread in
|
|
thread.isUnread && (lane == nil || thread.lane == lane)
|
|
}
|
|
.count
|
|
}
|
|
|
|
var screenerThreads: [MailThread] {
|
|
baseThreads(for: .screener)
|
|
.sorted { $0.lastUpdated > $1.lastUpdated }
|
|
}
|
|
|
|
var searchResults: [MailThread] {
|
|
threads
|
|
.filter(matchesSearch)
|
|
.sorted { lhs, rhs in
|
|
let lhsScore = score(for: lhs)
|
|
let rhsScore = score(for: rhs)
|
|
if lhsScore == rhsScore {
|
|
return lhs.lastUpdated > rhs.lastUpdated
|
|
}
|
|
|
|
return lhsScore > rhsScore
|
|
}
|
|
}
|
|
|
|
var folderNames: [String] {
|
|
Array(Set(threads.flatMap(\.tags)))
|
|
.filter { !$0.isEmpty && !["Draft", "Launch", "Search", "System", "External", "Sent"].contains($0) }
|
|
.sorted()
|
|
}
|
|
|
|
var topSearchResult: MailThread? {
|
|
searchResults.first
|
|
}
|
|
|
|
var remainingSearchResults: [MailThread] {
|
|
Array(searchResults.dropFirst())
|
|
}
|
|
|
|
var isSearchActive: Bool {
|
|
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
func setLaneFilter(_ lane: Lane?) {
|
|
laneFilter = lane
|
|
reconcileSelectionForCurrentFilters()
|
|
}
|
|
|
|
func cycleToNextLane() {
|
|
let allFilters: [Lane?] = [nil] + Lane.allCases
|
|
guard let currentIndex = allFilters.firstIndex(where: { $0 == laneFilter }) else {
|
|
laneFilter = nil
|
|
return
|
|
}
|
|
|
|
laneFilter = allFilters[(currentIndex + 1) % allFilters.count]
|
|
reconcileSelectionForCurrentFilters()
|
|
}
|
|
|
|
func queueDraftAutosave() {
|
|
let snapshot = composeDraft
|
|
composeAutosaveTask?.cancel()
|
|
composeAutosaveTask = Task { [autosaveKey] in
|
|
try? await Task.sleep(for: .milliseconds(800))
|
|
guard !Task.isCancelled else { return }
|
|
if let data = try? JSONEncoder().encode(snapshot) {
|
|
UserDefaults.standard.set(data, forKey: autosaveKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
await refreshLiveActivity()
|
|
} 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() {
|
|
if composeDraft.isEmpty {
|
|
restoreAutosavedDraft()
|
|
}
|
|
|
|
if composeDraft.isEmpty {
|
|
composeDraft = ComposeDraft(from: currentUser.email)
|
|
} else if composeDraft.from.isEmpty {
|
|
composeDraft.from = currentUser.email
|
|
}
|
|
|
|
focusedMessageRouteID = nil
|
|
isComposing = true
|
|
}
|
|
|
|
func dismissCompose() {
|
|
isComposing = false
|
|
}
|
|
|
|
func discardCompose() {
|
|
composeAutosaveTask?.cancel()
|
|
UserDefaults.standard.removeObject(forKey: autosaveKey)
|
|
composeDraft = ComposeDraft(from: currentUser.email)
|
|
isComposing = false
|
|
}
|
|
|
|
func startReply(to threadID: MailThread.ID, replyAll: Bool = false, forward: Bool = false) {
|
|
guard let thread = thread(withID: threadID) else { return }
|
|
|
|
let latestMessage = thread.latestMessage
|
|
let recipients: [MailPerson]
|
|
if forward {
|
|
recipients = []
|
|
} else if replyAll {
|
|
recipients = thread.participants.filter { $0.email != currentUser.email }
|
|
} else if let latestMessage {
|
|
recipients = [latestMessage.sender]
|
|
} else {
|
|
recipients = thread.participants.filter { $0.email != currentUser.email }
|
|
}
|
|
|
|
let replyPrefix = forward ? "Fwd:" : "Re:"
|
|
let quotedBody = latestMessage?.body
|
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
.prefix(4)
|
|
.map { "> \($0)" }
|
|
.joined(separator: "\n") ?? ""
|
|
|
|
composeDraft = ComposeDraft(
|
|
to: recipients.map(\.email).joined(separator: ", "),
|
|
cc: "",
|
|
from: currentUser.email,
|
|
subject: thread.subject.hasPrefix(replyPrefix) ? thread.subject : "\(replyPrefix) \(thread.subject)",
|
|
body: forward || quotedBody.isEmpty ? "" : "\n\n\(quotedBody)"
|
|
)
|
|
focusedMessageRouteID = nil
|
|
isComposing = true
|
|
}
|
|
|
|
func moveThread(withID threadID: MailThread.ID, to mailbox: Mailbox) {
|
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
|
threads[index].mailbox = mailbox
|
|
if mailbox == .trash {
|
|
threads[index].isUnread = false
|
|
}
|
|
reconcileSelectionForCurrentFilters()
|
|
}
|
|
|
|
func sendInlineReply(_ body: String, in threadID: MailThread.ID) {
|
|
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedBody.isEmpty,
|
|
let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
|
|
|
let recipients = threads[index].participants.filter { $0.email != currentUser.email }
|
|
threads[index].messages.append(
|
|
MailMessage(
|
|
routeID: "\(threads[index].routeID)-reply-\(threads[index].messages.count + 1)",
|
|
sender: currentUser,
|
|
recipients: recipients,
|
|
sentAt: .now,
|
|
body: trimmedBody
|
|
)
|
|
)
|
|
threads[index].isUnread = false
|
|
openThread(withID: threadID)
|
|
}
|
|
|
|
func snoozeThread(withID threadID: MailThread.ID) {
|
|
moveThread(withID: threadID, to: .snoozed)
|
|
}
|
|
|
|
func applyScreenerDecision(_ decision: ScreenDecision, to threadID: MailThread.ID) {
|
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
|
|
|
switch decision {
|
|
case .approve:
|
|
threads[index].mailbox = .inbox
|
|
threads[index].isScreeningCandidate = false
|
|
case .block:
|
|
threads[index].mailbox = .trash
|
|
threads[index].isUnread = false
|
|
threads[index].isScreeningCandidate = false
|
|
case .sendToPaper:
|
|
threads[index].mailbox = .inbox
|
|
threads[index].lane = .paper
|
|
threads[index].isScreeningCandidate = false
|
|
}
|
|
|
|
reconcileSelectionForCurrentFilters()
|
|
|
|
Task {
|
|
try? await service.screen(threadID: threadID, as: decision)
|
|
}
|
|
}
|
|
|
|
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
|
|
defer { isListeningForBackendCommands = false }
|
|
|
|
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 = normalizedDraft(draft)
|
|
isComposing = true
|
|
}
|
|
}
|
|
|
|
func sendCurrentDraft() async -> Bool {
|
|
guard !isSending else { return false }
|
|
|
|
let draft = composeDraft
|
|
isSending = true
|
|
defer { isSending = false }
|
|
|
|
do {
|
|
let sentThread = try await service.send(draft: draft)
|
|
threads.insert(sentThread, at: 0)
|
|
selectedMailbox = .sent
|
|
openThread(withID: sentThread.id)
|
|
isComposing = false
|
|
UserDefaults.standard.removeObject(forKey: autosaveKey)
|
|
composeDraft = ComposeDraft(from: currentUser.email)
|
|
return true
|
|
} catch {
|
|
errorMessage = "Unable to send message."
|
|
return false
|
|
}
|
|
}
|
|
|
|
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: " "),
|
|
thread.lane.label,
|
|
thread.summary?.joined(separator: " ") ?? "",
|
|
thread.messages.map(\.body).joined(separator: " "),
|
|
thread.messages.flatMap(\.attachments).map(\.name).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 baseThreads(for mailbox: Mailbox) -> [MailThread] {
|
|
switch mailbox {
|
|
case .starred:
|
|
return threads.filter(\.isStarred)
|
|
case .screener:
|
|
return threads.filter(\.isScreeningCandidate)
|
|
default:
|
|
return threads.filter { $0.mailbox == mailbox }
|
|
}
|
|
}
|
|
|
|
private func score(for thread: MailThread) -> Int {
|
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase
|
|
guard !query.isEmpty else { return 0 }
|
|
|
|
var score = 0
|
|
if thread.subject.localizedLowercase.contains(query) { score += 4 }
|
|
if thread.participants.map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 3 }
|
|
if thread.previewText.localizedLowercase.contains(query) { score += 2 }
|
|
if thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 1 }
|
|
return score
|
|
}
|
|
|
|
private func normalizedDraft(_ draft: ComposeDraft) -> ComposeDraft {
|
|
var normalized = draft
|
|
if normalized.from.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
normalized.from = currentUser.email
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private func restoreAutosavedDraft() {
|
|
guard let data = UserDefaults.standard.data(forKey: autosaveKey),
|
|
let draft = try? JSONDecoder().decode(ComposeDraft.self, from: data) else {
|
|
if composeDraft.from.isEmpty {
|
|
composeDraft = ComposeDraft(from: currentUser.email)
|
|
}
|
|
return
|
|
}
|
|
|
|
composeDraft = normalizedDraft(draft)
|
|
}
|
|
|
|
private func refreshLiveActivity() async {
|
|
#if os(iOS)
|
|
if let thread = threads.first(where: { $0.mailbox == .inbox && $0.isUnread }) {
|
|
await MailNotificationActivityController.startIfNeeded(with: thread)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func reconcileSelectionForCurrentFilters() {
|
|
if let selectedThreadID,
|
|
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
|
|
return
|
|
}
|
|
|
|
clearThreadSelection()
|
|
}
|
|
}
|