WIP: local handoff implementation

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>
This commit is contained in:
2026-04-19 16:26:38 +02:00
parent 15af566353
commit 2fe6b8a6df
32 changed files with 3861 additions and 926 deletions
+262 -14
View File
@@ -9,19 +9,24 @@ final class AppViewModel {
var focusedMessageRouteID: String?
var searchText = ""
var showUnreadOnly = false
var laneFilter: Lane?
var isComposing = false
var composeDraft = ComposeDraft()
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(),
@@ -29,6 +34,7 @@ final class AppViewModel {
) {
self.service = service
self.controlService = controlService
restoreAutosavedDraft()
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
apply(command: command)
}
@@ -40,9 +46,9 @@ final class AppViewModel {
}
var filteredThreads: [MailThread] {
threads
baseThreads(for: selectedMailbox)
.filter { thread in
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
laneFilter == nil || thread.lane == laneFilter
}
.filter { thread in
!showUnreadOnly || thread.isUnread
@@ -56,18 +62,92 @@ final class AppViewModel {
}
func threadCount(in mailbox: Mailbox) -> Int {
threads.filter { thread in
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
}
.count
baseThreads(for: 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
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)
}
}
.count
}
func load() async {
@@ -85,6 +165,8 @@ final class AppViewModel {
} else {
reconcileSelectionForCurrentFilters()
}
await refreshLiveActivity()
} catch {
errorMessage = "Unable to load mail."
}
@@ -132,11 +214,120 @@ final class AppViewModel {
}
func startCompose() {
composeDraft = ComposeDraft()
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 }
@@ -203,7 +394,7 @@ final class AppViewModel {
case let .compose(draft):
focusedMessageRouteID = nil
composeDraft = draft
composeDraft = normalizedDraft(draft)
isComposing = true
}
}
@@ -221,6 +412,8 @@ final class AppViewModel {
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."
@@ -236,7 +429,11 @@ final class AppViewModel {
thread.subject,
thread.previewText,
thread.participants.map(\.name).joined(separator: " "),
thread.tags.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
@@ -253,6 +450,57 @@ final class AppViewModel {
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 }) {
@@ -0,0 +1,168 @@
#if os(iOS) && canImport(ActivityKit) && canImport(AppIntents) && canImport(WidgetKit)
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
struct MailNotificationActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var sender: String
var initials: String
var subject: String
var preview: String
var route: String
}
var threadRouteID: String
}
struct OpenMailLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Open"
static var openAppWhenRun = true
@Parameter(title: "Route")
var route: String
init() {
route = "socialio://mailbox/inbox"
}
init(route: String) {
self.route = route
}
func perform() async throws -> some IntentResult {
.result()
}
}
struct SnoozeMailLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Snooze"
func perform() async throws -> some IntentResult {
.result()
}
}
struct MailNotificationLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MailNotificationActivityAttributes.self) { context in
VStack(alignment: .leading, spacing: 10) {
header(context: context)
Text(context.state.preview)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 10) {
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
Text("Open")
}
.buttonStyle(.borderedProminent)
Button(intent: SnoozeMailLiveActivityIntent()) {
Text("Snooze")
}
.buttonStyle(.bordered)
}
}
.padding(.vertical, 6)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "envelope.fill")
.foregroundStyle(SIO.tint)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.initials)
.font(.headline.weight(.semibold))
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
header(context: context)
Text(context.state.preview)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 10) {
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
Text("Open")
}
.buttonStyle(.borderedProminent)
Button(intent: SnoozeMailLiveActivityIntent()) {
Text("Snooze")
}
.buttonStyle(.bordered)
}
}
} compactLeading: {
Image(systemName: "envelope.fill")
} compactTrailing: {
Text(context.state.initials)
.font(.caption2.weight(.bold))
} minimal: {
Image(systemName: "envelope.fill")
}
}
}
@ViewBuilder
private func header(context: ActivityViewContext<MailNotificationActivityAttributes>) -> some View {
HStack(spacing: 10) {
Text(context.state.initials)
.font(.caption.weight(.bold))
.foregroundStyle(SIO.tint)
.frame(width: 28, height: 28)
.background(SIO.tint.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(context.state.sender)
.font(.subheadline.weight(.semibold))
Text(context.state.subject)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
#if !WIDGET_EXTENSION
enum MailNotificationActivityController {
static func startIfNeeded(with thread: MailThread) async {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
let attributes = MailNotificationActivityAttributes(threadRouteID: thread.routeID)
let state = MailNotificationActivityAttributes.ContentState(
sender: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "social.io",
initials: initials(from: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "SI"),
subject: thread.subject,
preview: thread.previewText,
route: "socialio://open?thread=\(thread.routeID)"
)
if let existing = Activity<MailNotificationActivityAttributes>.activities.first {
await existing.update(ActivityContent(state: state, staleDate: nil))
return
}
_ = try? Activity<MailNotificationActivityAttributes>.request(
attributes: attributes,
content: ActivityContent(state: state, staleDate: nil)
)
}
private static func initials(from name: String) -> String {
String(name.split(separator: " ").prefix(2).compactMap { $0.first }).uppercased()
}
}
#endif
#endif
@@ -0,0 +1,13 @@
#if os(iOS) && canImport(WidgetKit) && canImport(ActivityKit) && canImport(AppIntents)
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
@main
struct SocialIOWidgetsExtension: WidgetBundle {
var body: some Widget {
MailNotificationLiveActivity()
}
}
#endif
+26 -7
View File
@@ -3,17 +3,36 @@ import SwiftUI
@main
struct SocialIOApp: App {
@State private var model = AppViewModel()
@AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
var body: some Scene {
WindowGroup {
MailRootView(model: model)
.tint(MailTheme.accent)
.onOpenURL { url in
model.apply(url: url)
}
}
#if os(macOS)
WindowGroup {
rootView
}
.defaultSize(width: 1440, height: 900)
Settings {
AppearanceSettingsView()
.frame(width: 420, height: 300)
}
#else
WindowGroup {
rootView
}
#endif
}
private var themePreference: ThemePreference {
ThemePreference(rawValue: themeRawValue) ?? .system
}
private var rootView: some View {
MailRootView(model: model)
.tint(SIO.tint)
.preferredColorScheme(themePreference.colorScheme)
.onOpenURL { url in
model.apply(url: url)
}
}
}
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>social.io Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>MinimumOSVersion</key>
<string>$(IPHONEOS_DEPLOYMENT_TARGET)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>