eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– Landing a member in Eleven's welcome conversation, natively β€” the native
4// arm of the web's maybeStartWelcome. The conversation itself runs natively
5// too (FlowEngine + the bundled Flows/welcome.json, hosted by
6// ElevenConversationModel β€” wire-identical to the web's welcome.flow.js, so a
7// conversation started on one surface resumes on the other); the shell's job
8// here is to OPEN that thread on first entry into a space, and only while the
9// conversation is unfinished.
10//
11// "Unfinished" is read from the conversation's own persisted state: the flow's
12// finish step records itself into the encrypted "apps" snapshot, which native
13// can decrypt with the thread key it already holds. The server sees ciphertext
14// throughout, as ever. Once per (device, instance): a UserDefaults flag stops
15// repeat landings β€” after that the thread is just another row in the list.
16@MainActor
17public enum WelcomeLanding {
18 static func flagKey(_ instance: SavedInstance) -> String { "welcomeLanded-" + instance.id }
19 static func pendingKey(_ instance: SavedInstance) -> String { "welcomePending-" + instance.id }
20
21 /// Flag a freshly-claimed join for the welcome landing (SpaceClaim) β€” the
22 /// joiner-side twin of SetupLanding.markPending, and the native form of
23 /// join.js's markWelcomePending. A fresh join must land even if an earlier
24 /// life of this space (same origin, e.g. left and re-invited) already
25 /// landed once on this device, so the once-per-device flag re-arms here.
26 public static func markPending(_ instance: SavedInstance) {
27 UserDefaults.standard.set(true, forKey: pendingKey(instance))
28 UserDefaults.standard.removeObject(forKey: flagKey(instance))
29 }
30
31 /// Whether a native claim armed the welcome conversation for this space.
32 /// Stays true after landing (reopening the thread mid-conversation resumes
33 /// it) and clears for good once the flow's finish marker is seen.
34 public static func isPending(_ instance: SavedInstance) -> Bool {
35 UserDefaults.standard.bool(forKey: pendingKey(instance))
36 }
37
38 /// The welcome thread to auto-open on this device, or nil (none exists,
39 /// already landed once, or the conversation is finished). Marks the flag
40 /// when it answers, so callers can just open what they're handed.
41 public static func threadToLand(instance: SavedInstance, threads: [ChatThread]) async -> ChatThread? {
42 guard let thread = threads.first(where: { $0.appId == "welcome" && !$0.threadKeyB64.isEmpty && !$0.hidden }) else {
43 return nil
44 }
45 let key = flagKey(instance)
46 guard !UserDefaults.standard.bool(forKey: key) else { return nil }
47 UserDefaults.standard.set(true, forKey: key)
48 if await conversationFinished(instance: instance, thread: thread, app: "welcome") {
49 UserDefaults.standard.removeObject(forKey: pendingKey(instance)) // done β€” disarm for good
50 return nil
51 }
52 return thread
53 }
54
55 // The flow's finish step records { steps: { finish: { done: true } } } into
56 // the app's state. No snapshot yet = a fresh conversation β†’ land.
57 // (The snapshot can lag the log by a few seconds; worst case is landing in
58 // a just-finished conversation once β€” the embed itself re-checks the full
59 // log and never re-arms the flow.)
60 static func conversationFinished(instance: SavedInstance, thread: ChatThread, app: String) async -> Bool {
61 guard let base = instance.baseURL else { return false }
62 let api = LChatAPI(baseURL: base, token: instance.deviceToken)
63 guard let snap = await api.threadState(thread: thread.id, slot: "apps"), !snap.blob.isEmpty,
64 let gk = try? GroupKey(base64url: thread.threadKeyB64),
65 let data = try? gk.decryptData(blob: snap.blob),
66 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
67 let states = json["states"] as? [String: Any]
68 let state = states?[app] as? [String: Any]
69 let steps = state?["steps"] as? [String: Any]
70 let finish = steps?["finish"] as? [String: Any]
71 return finish?["done"] as? Bool ?? false
72 }
73}
74
75// πŸ€– Landing a space CREATOR in Eleven's admin setup conversation β€” the
76// creator-side sibling of WelcomeLanding, gated on an explicit pending flag
77// the create-space claim sets (never a scan): only a natively-created space
78// lands here, so an old admin's long-finished setup thread (from before the
79// finish step recorded a marker) is never resurfaced. The same flag arms the
80// conversation inside the thread's webview (embed.armSetup β€” see
81// FeedWebCache/AppThreadWebView), and both quietly stand down once the
82// flow's recorded finish marker appears.
83@MainActor
84public enum SetupLanding {
85 static func pendingKey(_ instance: SavedInstance) -> String { "setupPending-" + instance.id }
86 static func landedKey(_ instance: SavedInstance) -> String { "setupLanded-" + instance.id }
87
88 /// Flag a freshly-claimed space for the setup landing (SpaceCreationFlow).
89 public static func markPending(_ instance: SavedInstance) {
90 UserDefaults.standard.set(true, forKey: pendingKey(instance))
91 }
92
93 /// Whether this space's setup conversation should be armed in the thread's
94 /// embedded webview (embed.armSetup). Stays true after landing β€” reopening
95 /// the thread mid-conversation resumes it; the embed no-ops once finished.
96 public static func isPending(_ instance: SavedInstance) -> Bool {
97 UserDefaults.standard.bool(forKey: pendingKey(instance))
98 }
99
100 /// The setup thread to auto-open (once per device+instance), or nil.
101 public static func threadToLand(instance: SavedInstance, threads: [ChatThread]) async -> ChatThread? {
102 guard isPending(instance),
103 let thread = threads.first(where: { $0.appId == "setup" && !$0.threadKeyB64.isEmpty && !$0.hidden }) else {
104 return nil
105 }
106 let key = landedKey(instance)
107 guard !UserDefaults.standard.bool(forKey: key) else { return nil }
108 UserDefaults.standard.set(true, forKey: key)
109 if await WelcomeLanding.conversationFinished(instance: instance, thread: thread, app: "setup") {
110 UserDefaults.standard.removeObject(forKey: pendingKey(instance)) // done β€” disarm for good
111 return nil
112 }
113 return thread
114 }
115}
116