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:
@@ -3,6 +3,19 @@ import Foundation
|
||||
protocol MailServicing {
|
||||
func loadThreads() async throws -> [MailThread]
|
||||
func send(draft: ComposeDraft) async throws -> MailThread
|
||||
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws
|
||||
}
|
||||
|
||||
extension MailServicing {
|
||||
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {}
|
||||
}
|
||||
|
||||
enum ScreenDecision: String, CaseIterable, Identifiable {
|
||||
case approve
|
||||
case block
|
||||
case sendToPaper
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct MockMailService: MailServicing {
|
||||
@@ -47,140 +60,311 @@ struct MockMailService: MailServicing {
|
||||
messages: [message],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Sent"]
|
||||
tags: ["Sent"],
|
||||
lane: .people
|
||||
)
|
||||
}
|
||||
|
||||
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {
|
||||
try await Task.sleep(for: .milliseconds(40))
|
||||
}
|
||||
|
||||
func previewThreads() -> [MailThread] {
|
||||
seededThreads.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||
}
|
||||
|
||||
private var seededThreads: [MailThread] {
|
||||
let alex = MailPerson(name: "Alex Rivera", email: "alex@social.io")
|
||||
let nora = MailPerson(name: "Nora Chen", email: "nora@social.io")
|
||||
let tanya = MailPerson(name: "Tanya Hall", email: "tanya@design.social.io")
|
||||
let ops = MailPerson(name: "Ops Bot", email: "ops@social.io")
|
||||
let investor = MailPerson(name: "Mina Park", email: "mina@northshore.vc")
|
||||
let aiko = MailPerson(name: "Aiko Tanaka", email: "aiko@social.io")
|
||||
let appleDeveloper = MailPerson(name: "Apple Developer", email: "developer@apple.com")
|
||||
let figma = MailPerson(name: "Figma", email: "no-reply@figma.com")
|
||||
let github = MailPerson(name: "GitHub", email: "noreply@github.com")
|
||||
let lena = MailPerson(name: "Lena Park", email: "lena@orbitlabs.io")
|
||||
let linear = MailPerson(name: "Linear", email: "updates@linear.app")
|
||||
let marcos = MailPerson(name: "Marcos Vidal", email: "marcos@northbeam.vc")
|
||||
let mom = MailPerson(name: "Mom", email: "mom@example.com")
|
||||
let priya = MailPerson(name: "Priya Shah", email: "priya@social.io")
|
||||
let socialTeam = MailPerson(name: "social.io team", email: "team@social.io")
|
||||
let stripe = MailPerson(name: "Stripe", email: "receipts@stripe.com")
|
||||
|
||||
func hoursAgo(_ hours: Double) -> Date {
|
||||
.now.addingTimeInterval(-(hours * 60 * 60))
|
||||
}
|
||||
|
||||
return [
|
||||
MailThread(
|
||||
routeID: "launch-copy",
|
||||
mailbox: .inbox,
|
||||
subject: "Launch copy for the onboarding flow",
|
||||
participants: [tanya, me],
|
||||
subject: "Launch copy review - second pass",
|
||||
participants: [lena, aiko, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "launch-copy-1",
|
||||
sender: tanya,
|
||||
recipients: [me],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 2),
|
||||
body: "I tightened the onboarding copy and added a warmer empty-state tone. If you're good with it, I can hand the strings to the app team today."
|
||||
sender: lena,
|
||||
recipients: [me, aiko],
|
||||
sentAt: hoursAgo(25),
|
||||
body: "Hi Phil - pulled together the changes from yesterday. Hero is the big one; the rest are mostly trims.\n\nThe three lanes framing landed well in the user tests, so I leaned into it harder.\n\nTwo open questions:\n1. Should we keep the screener mention up top, or move it down so we lead with split inbox?\n2. The closing CTA still feels long. I attached two trims.\n\nWould love a 15-minute sync before Tuesday's ship.",
|
||||
attachments: [
|
||||
MailAttachment(name: "cta-trims.pdf", size: "184 KB")
|
||||
]
|
||||
),
|
||||
MailMessage(
|
||||
routeID: "launch-copy-2",
|
||||
sender: aiko,
|
||||
recipients: [lena, me],
|
||||
sentAt: hoursAgo(17),
|
||||
body: "Hero is great. Lead with split inbox first and keep the screener second. The shorter CTA also feels better."
|
||||
),
|
||||
MailMessage(
|
||||
routeID: "launch-copy-3",
|
||||
sender: me,
|
||||
recipients: [tanya],
|
||||
sentAt: .now.addingTimeInterval(-3600),
|
||||
body: "Looks strong. Let's keep the playful language on iPhone and simplify the desktop first-run copy a bit."
|
||||
recipients: [lena, aiko],
|
||||
sentAt: hoursAgo(12),
|
||||
body: "Agree on both points. Let's keep the copy calmer and make split inbox the first thing people understand."
|
||||
),
|
||||
MailMessage(
|
||||
routeID: "launch-copy-4",
|
||||
sender: lena,
|
||||
recipients: [me, aiko],
|
||||
sentAt: hoursAgo(5),
|
||||
body: "Perfect. I made both edits and dropped in the latest screenshots as well. Monday 2pm works for me if that still does for you."
|
||||
)
|
||||
],
|
||||
isUnread: true,
|
||||
isStarred: true,
|
||||
tags: ["Design", "Launch"]
|
||||
tags: ["Orbit Labs"],
|
||||
lane: .people,
|
||||
summary: [
|
||||
"Lena rewrote the hero and the three lanes section after Tuesday's feedback.",
|
||||
"Two open questions remain: where to introduce the screener and which shorter CTA trim to ship.",
|
||||
"A short sync before Tuesday is the last blocker."
|
||||
]
|
||||
),
|
||||
MailThread(
|
||||
routeID: "daily-sync-status",
|
||||
mailbox: .inbox,
|
||||
subject: "Daily inbox sync status",
|
||||
participants: [ops, me],
|
||||
subject: "Daily sync - 14 issues moved to Done",
|
||||
participants: [linear, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "daily-sync-status-1",
|
||||
sender: ops,
|
||||
sender: linear,
|
||||
recipients: [me],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 4),
|
||||
body: "Mock sync complete. 1,284 messages mirrored from staging. Attachment fetch remains disabled in the sandbox profile."
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["System"]
|
||||
),
|
||||
MailThread(
|
||||
routeID: "investor-update",
|
||||
mailbox: .inbox,
|
||||
subject: "Investor update before next Friday",
|
||||
participants: [investor, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "investor-update-1",
|
||||
sender: investor,
|
||||
recipients: [me],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 26),
|
||||
body: "Could you send a concise product update and a few screenshots of the new mail experience before Friday? Interested in how you are differentiating from commodity inboxes."
|
||||
sentAt: hoursAgo(8),
|
||||
body: "Mobile: iOS keyboard regression fixed. Inbox: reconnect backoff landed. Compose autosave race condition is queued for review."
|
||||
)
|
||||
],
|
||||
isUnread: true,
|
||||
isStarred: false,
|
||||
tags: ["External"]
|
||||
tags: ["System"],
|
||||
lane: .feed
|
||||
),
|
||||
MailThread(
|
||||
routeID: "investor-update",
|
||||
mailbox: .inbox,
|
||||
subject: "Re: Q1 investor update - looks great, two small notes",
|
||||
participants: [marcos, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "investor-update-1",
|
||||
sender: marcos,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(34),
|
||||
body: "Strong narrative overall. Two things: the ARR chart on slide 8 needs a zero baseline, and the differentiation story should stay concrete. The product story is strongest when you anchor it in the split inbox and command-first workflows.",
|
||||
attachments: [
|
||||
MailAttachment(name: "Q1-investor-update.pdf", size: "1.8 MB")
|
||||
]
|
||||
),
|
||||
MailMessage(
|
||||
routeID: "investor-update-2",
|
||||
sender: me,
|
||||
recipients: [marcos],
|
||||
sentAt: hoursAgo(31),
|
||||
body: "Helpful. I will tighten the chart and rework the story around calmer triage instead of generic AI claims."
|
||||
)
|
||||
],
|
||||
isUnread: true,
|
||||
isStarred: true,
|
||||
tags: ["Board & investors"],
|
||||
lane: .people,
|
||||
summary: [
|
||||
"Marcos likes the overall update but wants a tighter chart and cleaner positioning.",
|
||||
"He thinks the strongest differentiation story is split inbox plus keyboard-first workflow.",
|
||||
"A revised deck with updated screenshots is the next step."
|
||||
]
|
||||
),
|
||||
MailThread(
|
||||
routeID: "search-ranking-polish",
|
||||
mailbox: .sent,
|
||||
subject: "Re: Search ranking polish",
|
||||
participants: [alex, me],
|
||||
subject: "Search ranking polish - final round before ship",
|
||||
participants: [priya, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "search-ranking-polish-1",
|
||||
sender: alex,
|
||||
sender: priya,
|
||||
recipients: [me],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 30),
|
||||
body: "The current search sort is useful, but I still feel too much recency over intent."
|
||||
sentAt: hoursAgo(40),
|
||||
body: "I tagged the 12 queries that still feel off. Most of them are personal-name ambiguity and a few intent misses around review threads."
|
||||
),
|
||||
MailMessage(
|
||||
routeID: "search-ranking-polish-2",
|
||||
sender: me,
|
||||
recipients: [alex],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 28),
|
||||
body: "Agreed. I want us to bias toward active collaborators and threads with lightweight action language like review, approve, or ship."
|
||||
recipients: [priya],
|
||||
sentAt: hoursAgo(28),
|
||||
body: "Agree. We should bias harder toward active collaborators and action language like review, approve, and ship instead of pure recency."
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Search"]
|
||||
),
|
||||
MailThread(
|
||||
routeID: "welcome-to-socialio",
|
||||
mailbox: .drafts,
|
||||
subject: "Welcome to social.io mail",
|
||||
participants: [me, nora],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "welcome-to-socialio-1",
|
||||
sender: me,
|
||||
recipients: [nora],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 6),
|
||||
body: "Thanks for joining the early design partner group. Here is a quick overview of our calm, collaborative take on email...",
|
||||
isDraft: true
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Draft"]
|
||||
tags: ["Search"],
|
||||
lane: .people
|
||||
),
|
||||
MailThread(
|
||||
routeID: "roadmap-notes",
|
||||
mailbox: .archive,
|
||||
subject: "Roadmap notes from product sync",
|
||||
participants: [nora, alex, me],
|
||||
subject: "Roadmap notes from this morning",
|
||||
participants: [aiko, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "roadmap-notes-1",
|
||||
sender: nora,
|
||||
recipients: [alex, me],
|
||||
sentAt: .now.addingTimeInterval(-3600 * 72),
|
||||
body: "Captured the big roadmap themes: faster triage, identity-rich threads, and calmer notifications. I archived the raw notes after cleanup."
|
||||
sender: aiko,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(46),
|
||||
body: "Captured everything from the whiteboard and split it into doing, next, and later. The biggest open question is when we bring the screener into the main story instead of leaving it as a power-user feature.",
|
||||
attachments: [
|
||||
MailAttachment(name: "roadmap-notes.pdf", size: "412 KB")
|
||||
]
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: [],
|
||||
lane: .people,
|
||||
summary: [
|
||||
"The roadmap is grouped into doing, next, and later for the next planning pass.",
|
||||
"The biggest open product question is when the screener should become a core story instead of a hidden power-user tool.",
|
||||
"This archived thread is the clean summary version of the whiteboard session."
|
||||
]
|
||||
),
|
||||
MailThread(
|
||||
routeID: "stripe-receipt",
|
||||
mailbox: .inbox,
|
||||
subject: "Receipt for EUR 240.00 - social-io-prod",
|
||||
participants: [stripe, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "stripe-receipt-1",
|
||||
sender: stripe,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(72),
|
||||
body: "Your payment of EUR 240.00 to AWS Europe SARL was successful. View invoice INV-04219 in your dashboard.",
|
||||
attachments: [
|
||||
MailAttachment(name: "INV-04219.pdf", size: "96 KB")
|
||||
]
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Receipts 2026"],
|
||||
lane: .paper
|
||||
),
|
||||
MailThread(
|
||||
routeID: "figma-comment",
|
||||
mailbox: .inbox,
|
||||
subject: "Lena commented on Inbox - iPad split",
|
||||
participants: [figma, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "figma-comment-1",
|
||||
sender: figma,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(76),
|
||||
body: "Love the lane chips. Can we try them at 10pt instead of 11pt? They fight the row title a little at this size."
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Orbit Labs"],
|
||||
lane: .feed
|
||||
),
|
||||
MailThread(
|
||||
routeID: "github-pr",
|
||||
mailbox: .inbox,
|
||||
subject: "[social-io/web] PR #842 ready for review - Compose autosave",
|
||||
participants: [github, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "github-pr-1",
|
||||
sender: github,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(82),
|
||||
body: "priya-shah opened a pull request: Persist compose draft to IndexedDB every 800ms and restore on cold open. 14 files changed, plus 382 and minus 97."
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Search"],
|
||||
lane: .feed
|
||||
),
|
||||
MailThread(
|
||||
routeID: "mom-photos",
|
||||
mailbox: .inbox,
|
||||
subject: "Photos from the weekend",
|
||||
participants: [mom, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "mom-photos-1",
|
||||
sender: mom,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(90),
|
||||
body: "Hi liebling - I tried the share thing you set up. Did the photos arrive? There are 23 of them, mostly the dog with the new yellow flowers.",
|
||||
attachments: [
|
||||
MailAttachment(name: "weekend-photos.zip", size: "24 MB")
|
||||
]
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: true,
|
||||
tags: ["Product"]
|
||||
tags: [],
|
||||
lane: .people
|
||||
),
|
||||
MailThread(
|
||||
routeID: "apple-developer",
|
||||
mailbox: .inbox,
|
||||
subject: "Your annual membership renews on April 30",
|
||||
participants: [appleDeveloper, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "apple-developer-1",
|
||||
sender: appleDeveloper,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(132),
|
||||
body: "Your Apple Developer Program membership will automatically renew on April 30. Manage your membership in App Store Connect."
|
||||
)
|
||||
],
|
||||
isUnread: false,
|
||||
isStarred: false,
|
||||
tags: ["Receipts 2026"],
|
||||
lane: .paper
|
||||
),
|
||||
MailThread(
|
||||
routeID: "welcome-to-socialio",
|
||||
mailbox: .screener,
|
||||
subject: "Welcome to social.io - start here",
|
||||
participants: [socialTeam, me],
|
||||
messages: [
|
||||
MailMessage(
|
||||
routeID: "welcome-to-socialio-1",
|
||||
sender: socialTeam,
|
||||
recipients: [me],
|
||||
sentAt: hoursAgo(18),
|
||||
body: "Three things to know: split inbox is automatic, command-K does almost everything, and snoozed mail comes back when you are actually free."
|
||||
)
|
||||
],
|
||||
isUnread: true,
|
||||
isStarred: true,
|
||||
tags: [],
|
||||
lane: .people,
|
||||
isScreeningCandidate: true
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user