| 1 | import Foundation |
| 2 | import SwiftUI |
| 3 | |
| 4 | // π€ The native host for Eleven's onboarding conversations (setup, welcome): |
| 5 | // binds FlowEngine to the real thread β folds the persisted transcript |
| 6 | // (`<app>.say` / `<app>.step` events plus the member's own chat messages, |
| 7 | // wire-identical to the web's, so a conversation started on one surface |
| 8 | // resumes on the other), runs the flow with native verbs (plain admin POSTs; |
| 9 | // the passkey ceremony is skipped by env.native), and persists each line as |
| 10 | // it's said. Everything sent is encrypted under the thread key first. |
| 11 | @MainActor |
| 12 | public final class ElevenConversationModel: ObservableObject { |
| 13 | public struct Row: Identifiable { |
| 14 | public let id = UUID() |
| 15 | /// A stable message-shaped identity + timestamp so the transcript can |
| 16 | /// render through the canonical MessageBubble. |
| 17 | public var seq: Int64 = 0 |
| 18 | public var at: Int64 = 0 |
| 19 | public var kind: Kind |
| 20 | public var chips: [FlowChip]? |
| 21 | public enum Kind { |
| 22 | case eleven(String) // Eleven's bubble |
| 23 | case action(String) // a β step line ("noted: you're Paul") |
| 24 | case mine(String) // the member's own message/answer |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | private var nextSeq: Int64 = 0 |
| 29 | private func append(_ kind: Row.Kind, chips: [FlowChip]? = nil) { |
| 30 | nextSeq += 1 |
| 31 | rows.append(Row(seq: nextSeq, at: Int64(Date().timeIntervalSince1970), kind: kind, chips: chips)) |
| 32 | } |
| 33 | |
| 34 | @Published public private(set) var rows: [Row] = [] |
| 35 | @Published public private(set) var typing = false |
| 36 | @Published public var composerHint: String? |
| 37 | /// The composer's inset field label ("Your name:") β rides say extras like |
| 38 | /// the hint and follows the same reset rules. |
| 39 | @Published public var composerLabel: String? |
| 40 | @Published public private(set) var ready = false |
| 41 | |
| 42 | public let instance: SavedInstance |
| 43 | public let thread: ChatThread |
| 44 | let appId: String |
| 45 | let sayType: String |
| 46 | let stepType: String |
| 47 | private let api: LChatAPI? |
| 48 | private let key: GroupKey? |
| 49 | private var socket: ChatSocket? |
| 50 | private var engine: FlowEngine? |
| 51 | private var env = FlowEnv() |
| 52 | private var said: [String: Bool] = [:] |
| 53 | private var steps: [String: (done: Bool, value: String)] = [:] |
| 54 | private var foldedIds = Set<Int64>() |
| 55 | private var answerHandler: ((FlowAnswer) -> Void)? |
| 56 | /// Native navigation for the finish chips ("Say hi to {inviter}"). |
| 57 | public var openThread: ((String) -> Void)? |
| 58 | /// The finish chip's Admin hop: open (creating if needed) the Admin app |
| 59 | /// thread β the host owns thread creation and navigation. |
| 60 | public var openAdmin: (() -> Void)? |
| 61 | /// The welcome finish's "Browse open groups" chip β the host presents the |
| 62 | /// Discover surface (the twin of the web's showDiscover). |
| 63 | public var openDiscover: (() -> Void)? |
| 64 | /// The "Fill in a form instead" chip β the host presents the quick-form |
| 65 | /// sheet (ElevenQuickFormView), which submits back through applyQuickForm. |
| 66 | public var openQuickForm: (() -> Void)? |
| 67 | /// The welcome flow's name step just published a label β the host must |
| 68 | /// also persist the name into the saved instance (outgoing payloads stamp |
| 69 | /// SavedInstance.screenName; without this a flow rename fixes the roster |
| 70 | /// but new messages keep carrying the old β or empty β name snapshot). |
| 71 | public var onNameSet: ((String) -> Void)? |
| 72 | |
| 73 | public init?(instance: SavedInstance, thread: ChatThread, myUserId: String) { |
| 74 | guard let appId = thread.appId, appId == "setup" || appId == "welcome", |
| 75 | !thread.threadKeyB64.isEmpty else { return nil } |
| 76 | self.instance = instance |
| 77 | self.thread = thread |
| 78 | self.appId = appId |
| 79 | self.sayType = appId + ".say" |
| 80 | self.stepType = appId + ".step" |
| 81 | self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) } |
| 82 | self.key = try? GroupKey(base64url: thread.threadKeyB64) |
| 83 | self.myUserId = myUserId |
| 84 | } |
| 85 | |
| 86 | let myUserId: String |
| 87 | |
| 88 | public func start(roster: [RosterEntry], threads: [ChatThread]) { |
| 89 | guard engine == nil else { return } |
| 90 | Task { await load(roster: roster, threads: threads) } |
| 91 | } |
| 92 | |
| 93 | public func stop() { |
| 94 | socket?.stop() |
| 95 | socket = nil |
| 96 | } |
| 97 | |
| 98 | // MARK: history fold + engine start |
| 99 | |
| 100 | private func load(roster: [RosterEntry], threads: [ChatThread]) async { |
| 101 | guard let api, let key else { return } |
| 102 | // Kept for engine re-entries (the quick form's fast-fill restarts the |
| 103 | // run the way a fresh open would β see restartFlow). |
| 104 | envRoster = roster |
| 105 | envThreads = threads |
| 106 | let messages = await api.messages(thread: thread.id, after: 0) |
| 107 | for m in messages { fold(id: m.id, blob: m.blob, key: key) } |
| 108 | startSocket() |
| 109 | ready = true |
| 110 | await startEngine() |
| 111 | } |
| 112 | |
| 113 | private func startEngine() async { |
| 114 | guard let api, let flow = AppFlows.flow(appId) else { return } |
| 115 | let env = FlowEnv() |
| 116 | env.flags["native"] = .bool(true) |
| 117 | env.flags["suggested"] = .string("") |
| 118 | // The roster carries no role natively β the greeting falls back to |
| 119 | // "run by one of its members" (setup's creator IS the admin anyway). |
| 120 | env.flags["adminName"] = .string("") |
| 121 | // The inviter: the other member of the joiner's first person-DM. |
| 122 | var inviter = "" |
| 123 | var pendingThreadId: String? |
| 124 | if let dm = envThreads.first(where: { $0.kind == "dm" && $0.appId == nil }) { |
| 125 | if let other = dm.members.first(where: { $0 != myUserId }), |
| 126 | let entry = envRoster.first(where: { $0.user_id == other }) { |
| 127 | inviter = entry.label |
| 128 | pendingThreadId = dm.id |
| 129 | } |
| 130 | } |
| 131 | env.flags["inviterName"] = .string(inviter) |
| 132 | env.flags["hasPendingThread"] = .bool(pendingThreadId != nil) |
| 133 | // Open groups (welcome only): existence, never names β a locked |
| 134 | // level's name stays sealed by design. Joined rows don't count: |
| 135 | // default-join groups already sit in the sidebar. |
| 136 | if appId == "welcome" { |
| 137 | let open = await api.openThreads() |
| 138 | env.flags["openGroups"] = .bool(open.contains { row in |
| 139 | !(row.joined ?? false) && ((row.open_tier ?? "").isEmpty || (row.eligible ?? false)) |
| 140 | }) |
| 141 | } |
| 142 | let name = await api.groupName() |
| 143 | env.space = ["name": .string(name), "nameSet": .bool(!name.isEmpty)] |
| 144 | env.live["bridge.myName"] = { [instance] in .string(instance.screenName) } |
| 145 | env.live["bridge.enrolled"] = { .bool(false) } |
| 146 | env.live["bridge.fromHome"] = { .bool(false) } |
| 147 | env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in |
| 148 | let hop = pendingThreadId |
| 149 | pendingHop = hop |
| 150 | self.env = env |
| 151 | let engine = FlowEngine(flow: flow, env: env, io: self) |
| 152 | self.engine = engine |
| 153 | engine.start() |
| 154 | } |
| 155 | |
| 156 | private var pendingHop: String? |
| 157 | private var envRoster: [RosterEntry] = [] |
| 158 | private var envThreads: [ChatThread] = [] |
| 159 | |
| 160 | // Folds dedupe on CONVERSATION state (said keys, recorded steps, pending |
| 161 | // own sends), not just message ids β our own optimistic rows race the |
| 162 | // socket echo of the same event, and the REST response's id can lose that |
| 163 | // race (the live-duplicates bug). |
| 164 | private func fold(id: Int64, blob: String, key: GroupKey) { |
| 165 | guard !foldedIds.contains(id), let data = try? key.decryptData(blob: blob), |
| 166 | let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
| 167 | foldedIds.insert(id) |
| 168 | let type = obj["type"] as? String |
| 169 | if type == sayType { |
| 170 | let k = obj["key"] as? String ?? (obj["text"] as? String ?? "") |
| 171 | if said[k] == true { return } // already shown (our own echo) |
| 172 | said[k] = true |
| 173 | if let text = obj["text"] as? String, !text.isEmpty { |
| 174 | append(.eleven(text)) |
| 175 | } |
| 176 | } else if type == stepType { |
| 177 | guard let step = obj["step"] as? String else { return } |
| 178 | if steps[step]?.done == true { return } // already shown |
| 179 | steps[step] = (true, obj["value"] as? String ?? "") |
| 180 | if let label = obj["label"] as? String, !label.isEmpty { |
| 181 | append(.action(label)) |
| 182 | } |
| 183 | } else if type == nil || type == "chat" { |
| 184 | guard let text = obj["text"] as? String, !text.isEmpty else { return } |
| 185 | if let i = pendingMine.firstIndex(of: text) { |
| 186 | pendingMine.remove(at: i) // our own send's echo |
| 187 | return |
| 188 | } |
| 189 | append(.mine(text)) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Texts we've optimistically shown, awaiting their echo. |
| 194 | private var pendingMine: [String] = [] |
| 195 | |
| 196 | private func startSocket() { |
| 197 | guard let base = instance.baseURL else { return } |
| 198 | let socket = ChatSocket(baseURL: base, token: instance.deviceToken) |
| 199 | socket?.onFrame = { [weak self] frame in |
| 200 | guard let self, frame.thread_id == self.thread.id, frame.type == "msg", |
| 201 | frame.ephemeral != true, let id = frame.id, let blob = frame.blob, |
| 202 | let key = self.key else { return } |
| 203 | self.fold(id: id, blob: blob, key: key) |
| 204 | } |
| 205 | socket?.start() |
| 206 | self.socket = socket |
| 207 | } |
| 208 | |
| 209 | // MARK: composer |
| 210 | |
| 211 | /// The member typed into the conversation: persist it as a normal chat |
| 212 | /// message (the web transcript shows the real answers) and hand it to the |
| 213 | /// flow's waiter if one is armed. |
| 214 | public func send(_ text: String) async { |
| 215 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 216 | guard !trimmed.isEmpty else { return } |
| 217 | append(.mine(trimmed)) |
| 218 | pendingMine.append(trimmed) |
| 219 | await postEvent(["text": trimmed]) |
| 220 | if let handler = answerHandler { |
| 221 | answerHandler = nil |
| 222 | composerHint = nil |
| 223 | composerLabel = nil |
| 224 | handler(FlowAnswer(text: trimmed)) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // Stamp the transport fields and POST the encrypted payload as a message |
| 229 | // (the same wire the web's emit rides; REST so a pre-socket line lands). |
| 230 | @discardableResult |
| 231 | private func postEvent(_ fields: [String: Any]) async -> Bool { |
| 232 | guard let key, let base = instance.baseURL else { return false } |
| 233 | var obj = fields |
| 234 | obj["name"] = instance.screenName |
| 235 | obj["ts"] = Int64(Date().timeIntervalSince1970 * 1000) |
| 236 | if !myUserId.isEmpty { obj["uid"] = myUserId } |
| 237 | guard let data = try? JSONSerialization.data(withJSONObject: obj), |
| 238 | let blob = try? key.encryptData(data) else { return false } |
| 239 | var comps = URLComponents(url: base.appending(path: "api/messages"), resolvingAgainstBaseURL: false)! |
| 240 | comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)] |
| 241 | guard let url = comps.url, |
| 242 | let body = try? JSONSerialization.data(withJSONObject: ["thread_id": thread.id, "blob": blob]) else { return false } |
| 243 | var req = URLRequest(url: url) |
| 244 | req.httpMethod = "POST" |
| 245 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 246 | req.httpBody = body |
| 247 | guard let (respData, resp) = try? await URLSession.shared.data(for: req), |
| 248 | (resp as? HTTPURLResponse)?.statusCode == 200 else { return false } |
| 249 | // Mark the echo folded so the socket doesn't duplicate the row. |
| 250 | struct Resp: Decodable { let id: Int64? } |
| 251 | if let r = try? JSONDecoder().decode(Resp.self, from: respData), let id = r.id { |
| 252 | foldedIds.insert(id) |
| 253 | } |
| 254 | return true |
| 255 | } |
| 256 | |
| 257 | // MARK: the quick form ("Fill in a form instead") |
| 258 | |
| 259 | /// Which quick form the sheet draws ("setup" | "welcome") + its prefills. |
| 260 | public var quickFormIsSetup: Bool { appId == "setup" } |
| 261 | /// The current space name, only when it was explicitly set (the flow's |
| 262 | /// "Keep" affordance becomes a prefill). |
| 263 | public var quickFormSpaceName: String { |
| 264 | (env.space["nameSet"]?.truthy == true) ? (env.space["name"]?.stringValue ?? "") : "" |
| 265 | } |
| 266 | |
| 267 | /// Fast-fill: run the SAME verbs and emit the SAME step records the |
| 268 | /// conversational path would (labels verbatim from the flow documents), |
| 269 | /// then re-enter the flow so it resumes over the recorded steps β no taps |
| 270 | /// needed. Native flows skip the access/keys sign-in and the photo step, |
| 271 | /// so no records are needed for those here. Returns a user-facing error |
| 272 | /// message (the sheet stays open), or nil on success. |
| 273 | public func applyQuickForm(_ v: QuickFormValues) async -> String? { |
| 274 | // The typed answer's clean-before-cap rule (flow.js / FlowEngine twin). |
| 275 | let name = String(cleanNameAnswer(v.name).prefix(32)) |
| 276 | guard !name.isEmpty else { return "Add your name to continue." } |
| 277 | _ = await verb("setMyName", args: [.string(name)]) |
| 278 | await recordIfNew(step: "name", label: "noted: you're \(name)", value: name) |
| 279 | if appId == "setup" { |
| 280 | let space = v.spaceName.trimmingCharacters(in: .whitespacesAndNewlines) |
| 281 | let r = await verb("setGroupName", args: [.string(space)]) |
| 282 | if FlowEngine.resultKey(r) == "invalid" { |
| 283 | return "That space name didn't stick β it needs to be 1β60 characters." |
| 284 | } |
| 285 | if FlowEngine.resultKey(r) != "ok" { |
| 286 | return "Something went wrong saving the space name β try again in a moment." |
| 287 | } |
| 288 | await recordIfNew(step: "group", label: "named this space β\(space)β", value: space) |
| 289 | let email = v.email.trimmingCharacters(in: .whitespacesAndNewlines) |
| 290 | if email.isEmpty { |
| 291 | await recordIfNew(step: "email", label: "skipped the contact email", value: "skipped") |
| 292 | } else { |
| 293 | if FlowEngine.resultKey(await verb("setContactEmail", args: [.string(email)])) != "ok" { |
| 294 | return "That doesn't look like an email address β fix it, or leave it empty." |
| 295 | } |
| 296 | await recordIfNew(step: "email", label: "noted a contact email", value: email) |
| 297 | } |
| 298 | _ = await verb("setUserInvites", args: [.bool(v.membersInvite)]) |
| 299 | await recordIfNew(step: "invites", |
| 300 | label: v.membersInvite ? "decided members can invite people" |
| 301 | : "decided only the admin invites people", |
| 302 | value: v.membersInvite ? "members" : "admin only") |
| 303 | // The extras trio mirrors the conversation's two paths: everything |
| 304 | // on is the one-tap record (defaults are already on server-side), |
| 305 | // anything off walks the granular records, writing each gate. |
| 306 | if v.ai && v.bots && v.apps { |
| 307 | await recordIfNew(step: "extras", label: "kept all the extras on", value: "all-on") |
| 308 | } else { |
| 309 | for (key, on, noun) in [("ai", v.ai, "AI"), ("bots", v.bots, "bots"), ("apps", v.apps, "apps")] { |
| 310 | _ = await verb("setFeature", args: [.string(key), .bool(on)]) |
| 311 | await recordIfNew(step: "extras-\(key)", |
| 312 | label: on ? "enabled \(noun)" : "switched \(noun) off", |
| 313 | value: on ? "on" : "off") |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | restartFlow() |
| 318 | return nil |
| 319 | } |
| 320 | |
| 321 | // The flow never re-records a done step (recordIfNew discipline) β a form |
| 322 | // submitted after some conversational answers mustn't duplicate their |
| 323 | // action lines; its verbs still ran, so the changed values apply. |
| 324 | private func recordIfNew(step: String, label: String, value: String) async { |
| 325 | if done(step) { return } |
| 326 | await record(step: step, label: label, value: value) |
| 327 | } |
| 328 | |
| 329 | /// A clean re-entry: the live engine is parked mid-step (its armed ask and |
| 330 | /// settle belong to the step the form just answered), so rather than poke |
| 331 | /// its internals, drop it and start a fresh run β the exact path a fresh |
| 332 | /// open takes. The new run folds the fast-filled records (the model's |
| 333 | /// `steps`/`said` maps carry them) and walks the remaining steps to the |
| 334 | /// finish on its own β the "advance without user taps". |
| 335 | private func restartFlow() { |
| 336 | cancelAnswer() |
| 337 | engine = nil |
| 338 | Task { @MainActor in await startEngine() } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | /// What the quick-form sheet collects. Native flows skip the access/keys |
| 343 | /// sign-in and the photo step, so: welcome = name only; setup adds the space |
| 344 | /// name, contact email, invites, and the extras trio. |
| 345 | public struct QuickFormValues: Sendable { |
| 346 | public var name = "" |
| 347 | public var spaceName = "" |
| 348 | public var email = "" |
| 349 | public var membersInvite = true |
| 350 | public var ai = true |
| 351 | public var bots = true |
| 352 | public var apps = true |
| 353 | public init() {} |
| 354 | } |
| 355 | |
| 356 | // MARK: - the flow io |
| 357 | |
| 358 | extension ElevenConversationModel: FlowIO { |
| 359 | public func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async { |
| 360 | let sayKey = extra.key ?? text |
| 361 | // The typing beat: on, held a readable moment scaled to length, off |
| 362 | // once the bubble is in the transcript (eleven.js's pacing, simplified). |
| 363 | let alreadySaid = !extra.local && said[sayKey] == true |
| 364 | if !text.isEmpty && !alreadySaid { |
| 365 | typing = true |
| 366 | let ms = min(2000.0, 500.0 + Double(text.count) * 4) |
| 367 | try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000)) |
| 368 | typing = false |
| 369 | } |
| 370 | if !text.isEmpty { |
| 371 | if extra.local { |
| 372 | append(.eleven(text), chips: chips) |
| 373 | } else if !alreadySaid { |
| 374 | said[sayKey] = true |
| 375 | append(.eleven(text), chips: chips) |
| 376 | await postEvent(["type": sayType, "key": sayKey, "text": text]) |
| 377 | } else if let chips { |
| 378 | // Re-run over a said line: attach the chips to a fresh row's |
| 379 | // stead β the last transcript row carries them. |
| 380 | attachChips(chips) |
| 381 | } |
| 382 | } else if let chips { |
| 383 | attachChips(chips) |
| 384 | } |
| 385 | // Each fresh persisted line RESETS the hint unless it carries its own β |
| 386 | // a question's placeholder ("Type a contact email") must not outlive |
| 387 | // the step that asked it. Local lines (resume greetings) leave it be. |
| 388 | if extra.hint != nil || extra.attachHint != nil { |
| 389 | composerHint = extra.hint ?? extra.attachHint |
| 390 | } else if !extra.local { |
| 391 | composerHint = nil |
| 392 | } |
| 393 | // The inset field label follows the same lifecycle as the hint. |
| 394 | if extra.label != nil { |
| 395 | composerLabel = extra.label |
| 396 | } else if !extra.local { |
| 397 | composerLabel = nil |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | private func attachChips(_ chips: [FlowChip]) { |
| 402 | if var last = rows.last { |
| 403 | last.chips = chips |
| 404 | rows[rows.count - 1] = last |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | public func ask(_ handler: @escaping (FlowAnswer) -> Void) { |
| 409 | answerHandler = handler |
| 410 | } |
| 411 | |
| 412 | public func cancelAnswer() { |
| 413 | answerHandler = nil |
| 414 | composerHint = nil |
| 415 | composerLabel = nil |
| 416 | } |
| 417 | |
| 418 | public func record(step: String, label: String, value: String) async { |
| 419 | steps[step] = (true, value) |
| 420 | if !label.isEmpty { append(.action(label)) } |
| 421 | await postEvent(["type": stepType, "step": step, "label": label, "value": value]) |
| 422 | } |
| 423 | |
| 424 | public func done(_ step: String) -> Bool { steps[step]?.done ?? false } |
| 425 | |
| 426 | public func delay(ms: Double, _ fn: @escaping () -> Void) { |
| 427 | Task { @MainActor in |
| 428 | try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000)) |
| 429 | fn() |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | public func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON { |
| 434 | guard let api else { return .bool(false) } |
| 435 | switch name { |
| 436 | case "setMyName": |
| 437 | let raw = args.first?.stringValue ?? "" |
| 438 | let capped = String(raw.prefix(32)) |
| 439 | guard !capped.isEmpty else { return .bool(false) } |
| 440 | await publishLabel(capped) |
| 441 | onNameSet?(capped) |
| 442 | return .null |
| 443 | case "setGroupName": |
| 444 | let name = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces) |
| 445 | guard (1...60).contains(name.count) else { return .string("invalid") } |
| 446 | let ok = await api.setGroupSettings(name: name) |
| 447 | if ok { env.space["name"] = .string(name); env.space["nameSet"] = .bool(true) } |
| 448 | return .string(ok ? "ok" : "error") |
| 449 | case "setContactEmail": |
| 450 | let email = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces) |
| 451 | guard email.contains("@"), email.contains(".") else { return .string("invalid") } |
| 452 | return .string(await api.setGroupSettings(adminEmail: email) ? "ok" : "error") |
| 453 | case "setUserInvites": |
| 454 | let on = args.first.map { $0.truthy } ?? true |
| 455 | _ = await api.setAdminSettings(["user_invites": on]) |
| 456 | return .null |
| 457 | case "setFeature": |
| 458 | // The extras steps' gates ("ai" | "bots" | "apps" β ai_enabled etc.). |
| 459 | // Whitelisted so flow data can't name an arbitrary settings key. |
| 460 | guard let key = args.first?.stringValue, ["ai", "bots", "apps"].contains(key) else { return .null } |
| 461 | let enabled = args.count > 1 ? args[1].truthy : true |
| 462 | _ = await api.setAdminSettings(["\(key)_enabled": enabled]) |
| 463 | return .null |
| 464 | case "markSetupComplete": |
| 465 | _ = await api.setAdminSettings(["setup_complete": true]) |
| 466 | return .null |
| 467 | case "deferBackupReminders": |
| 468 | return .null // native access is the sign-in; nothing to nag about |
| 469 | case "openPendingThread": |
| 470 | if let hop = pendingHop { openThread?(hop) } |
| 471 | return .null |
| 472 | case "openAdmin": |
| 473 | openAdmin?() |
| 474 | return .null |
| 475 | case "openDiscover": |
| 476 | openDiscover?() |
| 477 | return .null |
| 478 | case "openQuickForm": |
| 479 | openQuickForm?() |
| 480 | return .null |
| 481 | default: |
| 482 | return .null // unknown/web-only verbs are quiet no-ops natively |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | // The member's screen name, encrypted under the instance key (the web's |
| 487 | // publishMyLabel): the server holds only the blob. |
| 488 | private func publishLabel(_ name: String) async { |
| 489 | guard let api else { return } |
| 490 | // The instance key comes wrapped to us on the roster path; SpaceClaim |
| 491 | // minted it for creators. Fetch + unwrap via the instance keypair. |
| 492 | guard let wrapped = await api.instanceKeyWrapped() else { |
| 493 | // A keyless joiner mid-welcome (#1376): the grant hasn't landed |
| 494 | // yet. Park the name β ThreadStore's keyless loop is already |
| 495 | // nudging holders, and the refresh that unwraps the granted key |
| 496 | // publishes the parked name. onNameSet persisted it locally, so |
| 497 | // outgoing messages carry it in the meantime. |
| 498 | PendingLabel.park(name, instanceID: instance.id) |
| 499 | await api.nudgeInstanceKey() |
| 500 | return |
| 501 | } |
| 502 | guard let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv), |
| 503 | let ik = try? GroupKey(rawKey: raw), |
| 504 | let blob = try? ik.encryptJSON(name) else { return } |
| 505 | if await api.postLabel(labelBlob: blob) { |
| 506 | // A name parked in an earlier keyless moment is superseded. |
| 507 | PendingLabel.clear(instanceID: instance.id) |
| 508 | } |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // MARK: - cache: one live conversation per thread (leaving and returning |
| 513 | // resumes the same run; the flow also resumes from recorded state cold). |
| 514 | |
| 515 | @MainActor |
| 516 | public final class ElevenConversationCache { |
| 517 | public static let shared = ElevenConversationCache() |
| 518 | private var models: [String: ElevenConversationModel] = [:] |
| 519 | |
| 520 | public func model(for instance: SavedInstance, thread: ChatThread, |
| 521 | myUserId: String) -> ElevenConversationModel? { |
| 522 | let key = instance.id + "|" + thread.id |
| 523 | if let existing = models[key] { return existing } |
| 524 | guard let model = ElevenConversationModel(instance: instance, thread: thread, |
| 525 | myUserId: myUserId) else { return nil } |
| 526 | models[key] = model |
| 527 | return model |
| 528 | } |
| 529 | } |
| 530 | |