| 1 | import 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 |
| 17 | public 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 |
| 84 | public 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 | |