eleven / messenger Public

Native onboarding: Eleven's conversations run fully native (Swift flow interpreter, no webview) (#493)

* web+ios+mac: the joiner welcome conversation runs natively — land in a chat with Eleven

The embed boot (bootEmbed) now arms the same client-local welcome flow the
full web boot runs, when the embedded thread is the welcome app-thread:
Eleven greets, asks the name, offers the photo — no bot, no AI. env.native
skips the passkey step (access is already secured by Sign in with Eleven)
and the access step records that reason instead.

The flow's finish step now persists a completion marker (new step-level
record support in flow.js) — the native embed has no localStorage join
marker, so 'is this conversation over?' is read from recorded state, and a
finished conversation never re-arms.

Native lands members there: iOS opens the welcome thread on first entry
into a space, the Mac selects it instead of the first chat — once per
device+instance (WelcomeLanding, gated on the conversation being
unfinished, read from the encrypted apps snapshot with the thread key).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+ios+mac: creators land in Eleven's admin setup conversation after native create-a-space

The creator-side sibling of the joiner welcome landing: the native claim
pipeline creates the 'setup' app-thread and flags the instance; iOS/Mac
land there on first entry, and the thread's webview runs the same
client-local setup conversation the web runs — with env.native skipping
the passkey pitch (the space's key is already saved to the creator's
Eleven account) and hiding the cross-thread Open-admin chip.

Arming is explicit (embed.armSetup from the pending flag), never a plain
thread tap, so old admins' long-finished setup threads — which predate
the finish step recording a completion marker — can never re-run.

setup.flow.js: the greeting moves to its own hello step and resumedIf now
means 'a step was recorded' (a pre-set name alone is the native creator's
FIRST run and still gets greeted); the keys step gains the native bypass;
finish records its completion marker (like welcome). The eager
setup_complete at claim stays — it's the reaper's not-abandoned signal,
now decoupled from the conversation's own completion state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge origin/main (Mac spaces menu, eleven CLI bundle, cards)

* ios+mac: Eleven's onboarding conversations run FULLY native — a Swift flow interpreter, no webview

FlowEngine (LChatCore) is the Swift twin of flow.js — the interpreter the
conversation-scripts doc promised natives. It walks the same flow documents
(Flows/*.json, byte-derived from *.flow.js, drift-gated in npm test) with
the identical semantics: entries, skips, templates, chips, asks, effect
branching, settle polls, records. FlowEngineTests mirror test/flow.test.mjs
over the real bundled flows.

ElevenConversationModel binds it to a thread: folds the persisted
transcript (say/step events + the member's answers — wire-identical to the
web, so a conversation resumes across surfaces), runs native verbs (admin
POSTs, label publish; passkey skipped by env.native), and persists each
line as it speaks. ElevenChatView is the shared SwiftUI surface: Eleven's
bubbles, tappable chips, action lines, a typing beat, a plain composer.

Because the flows ship in the app bundle, onboarding needs NO server
web-code deploy — it works against any instance. The webview embed arms
(maybeStartEmbedWelcome/Setup, armSetup seeds) are removed as dead; native
v1 skips the photo steps (Admin/profile cover them) and the web-only tour
chip, gated in the flow data under 'native'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: fix conversation echo-duplicates (dedupe folds on said/step/pending state); Eleven wears the app icon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: no avatars beside conversation bubbles (product convention in every chat stream)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: the Eleven conversation honours the app's text size (\.chatFontSize)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: the Eleven conversation reuses the canonical chat components

Bubbles render through MessageBubble (theme, reading size, selection —
identical to every native feed), the typing beat through TypingBubble,
action lines via the feeds' exact recipe, and the composer is the Mac
feed composer's pill (bordered pill on the panel background, same send
disc), themed from the same nativeTheme pref on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: composer stays typeable when the hint changes (recreate the field, hand focus back; stop the chip ticker destroying view identity)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: a question's composer hint doesn't outlive its step

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac+ios: Eleven's threads wear the brand icon, not the clock glyph

ElevenBrand.icon (the bundled icon-192, web parity with welcome.js's
avatar) wins over the app glyph in the sidebar, header, search hits and
rail for setup/welcome threads. Eleven has no gender and no face — the
mark is the product's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge cleanup: keep main's threadState/decryptData, one encryptData

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 611f5530ded6e08403e381b82f289a67fe7ce19a parent c359762
26 files changed, +2790 −34
  • docs/conversation-scripts.md +6 −1
  • ios/LChat/Views/ElevenChatScreen.swift +37 −0
  • ios/LChat/Views/ThreadListView.swift +36 −5
  • ios/LChatCore/Package.swift +3 −1
  • ios/LChatCore/Sources/LChatCore/AppFlows.swift +24 −0
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/ElevenChatView.swift +219 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +358 −0
  • ios/LChatCore/Sources/LChatCore/FlowEngine.swift +599 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +525 −0
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +362 −0
  • ios/LChatCore/Sources/LChatCore/Icons/icon-192.png binary
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +41 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +3 −0
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +29 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +2 −1
  • ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift +91 −0
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +229 −0
  • ios/LChatMac/ElevenConversationPane.swift +34 −0
  • ios/LChatMac/NativeChatView.swift +29 −5
  • test/flow.test.mjs +90 −6
  • web/static/apps/flow.js +7 −0
  • web/static/apps/setup.flow.js +36 −8
  • web/static/apps/welcome.flow.js +16 −2
  • web/static/apps/welcome.js +3 −2
  • web/static/shell.js +4 −3
diff --git a/docs/conversation-scripts.md b/docs/conversation-scripts.md
index e2692b0..9fd0887 100644
--- a/docs/conversation-scripts.md
+++ b/docs/conversation-scripts.md
@@ -72,7 +72,12 @@ Everything the two shipped flows actually do — this list is derived from
(`cancelAnswer`), and an effect can be guarded so it never runs if the step
already resolved (the photo-vs-Skip race).
6. **Recording.** A completed step emits its `*.step` event (label + value) —
- the ✓ action lines that make the transcript real history.
+ the ✓ action lines that make the transcript real history. A step may also
+ carry a top-level `record` that persists ON ARRIVAL (once — skipped when the
+ step is already recorded): welcome's `finish` records its completion marker
+ this way, so a host with no run-local state (the native embed has no
+ localStorage join marker) can tell a finished conversation from a half-done
+ one purely from recorded state.
**What the schema deliberately does NOT express: feel.** Typing beats, pacing
jitter, the reply queue, persistence retry/echo-wait, spent-chip rendering,
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift
new file mode 100644
index 0000000..b1cc3cc
--- /dev/null
+++ b/ios/LChat/Views/ElevenChatScreen.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+import LChatCore
+
+// 🤖 iOS host for Eleven's native onboarding conversation (setup / welcome):
+// owns the model's lifecycle and wires the finish chips' thread hop into the
+// navigation path. Models are cached per thread so leaving and returning
+// resumes the same run (the flow itself also resumes from recorded state).
+struct ElevenChatScreen: View {
+ let instance: SavedInstance
+ let thread: ChatThread
+ @ObservedObject var store: ThreadStore
+ @Binding var path: [String]
+ @State private var model: ElevenConversationModel?
+ @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
+ @AppStorage("nativeTheme") private var themeName = "classic"
+
+ var body: some View {
+ Group {
+ if let model {
+ ElevenChatView(model: model, theme: .named(themeName))
+ .environment(\.chatFontSize, chatFontSize)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(thread.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ guard model == nil else { return }
+ let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
+ myUserId: store.myUserId)
+ m?.openThread = { id in path.append(id) }
+ m?.start(roster: store.roster, threads: store.threads)
+ model = m
+ }
+ }
+}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index bb69b9c..1e7a8bf 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -20,6 +20,10 @@ struct ThreadListView: View {
// Files waiting in the share-extension inbox (app-group container).
@State private var shareFiles: [URL] = []
+ // One landing check per view lifetime (WelcomeLanding's UserDefaults flag
+ // makes it once per device+instance across launches).
+ @State private var welcomeChecked = false
+
// The funnel menu's filters (web parity: Online only + People/Bots/Apps).
@State private var filterOnline = false
@State private var filterPeople = true
@@ -76,6 +80,26 @@ struct ThreadListView: View {
}
}
.searchable(text: $search, prompt: "Search chats")
+ // A fresh member lands straight in Eleven's welcome conversation (the
+ // web's maybeStartWelcome, natively): open the welcome app-thread on
+ // first entry while its conversation is unfinished — the thread's
+ // webview runs the same client-local script the web runs.
+ .onReceive(store.$threads) { list in
+ guard !welcomeChecked, !list.isEmpty else { return }
+ welcomeChecked = true
+ Task {
+ // Creators land in the admin SETUP conversation, joiners in
+ // the WELCOME one — mutually exclusive by construction (a
+ // creator has no welcome thread, a joiner no pending setup).
+ if let setup = await SetupLanding.threadToLand(instance: store.instance, threads: list),
+ path.isEmpty {
+ path.append(setup.id)
+ } else if let welcome = await WelcomeLanding.threadToLand(instance: store.instance, threads: list),
+ path.isEmpty {
+ path.append(welcome.id)
+ }
+ }
+ }
.overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
.overlay { if !store.loading && store.threads.isEmpty { empty } }
.navigationTitle(serverTitle) // backs the chat screen's back button
@@ -139,7 +163,7 @@ struct ThreadListView: View {
path.append(t.id)
} label: {
HStack(spacing: 10) {
- Avatar(name: t.title, size: 34, source: t.avatar, glyph: t.glyph)
+ Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
Text(t.title).font(.body.weight(.medium)).lineLimit(1)
if let badge = t.badge { AgentBadge(badge) }
}
@@ -163,7 +187,7 @@ struct ThreadListView: View {
}
.onAppear { shareFiles = ShareInbox.pending() }
.navigationDestination(for: String.self) { id in
- ThreadDestination(store: store, id: id)
+ ThreadDestination(store: store, id: id, path: $path)
}
// Pull-to-refresh lives on the UIKit collection view (ThreadListCollection),
// not SwiftUI's .refreshable, since the scroll view is now UIKit's.
@@ -192,7 +216,7 @@ struct ThreadListView: View {
path.append(t.id)
} label: {
HStack(spacing: 10) {
- Avatar(name: t.title, size: 34, source: t.avatar, glyph: t.glyph)
+ Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
Text(t.title).font(.body.weight(.medium)).lineLimit(1)
if let badge = t.badge { AgentBadge(badge) }
}
@@ -315,6 +339,9 @@ struct ThreadListView: View {
struct ThreadDestination: View {
@ObservedObject var store: ThreadStore
let id: String
+ // The navigation path, for surfaces that hop threads (the Eleven
+ // conversation's "Say hi to {inviter}" chip).
+ @Binding var path: [String]
// iOS adoption of the native feed (docs/native-chat.md), behind the server
// drawer's "Native chat (beta)" toggle — the webview ChatView stays the
// default until parity.
@@ -340,10 +367,14 @@ struct ThreadDestination: View {
}
@ViewBuilder private func chatSurface(_ t: ChatThread) -> some View {
- // Mini-app threads always take the webview surface — the app's own
- // interface — whatever the native-feed flag says.
+ // Mini-app threads take the webview surface — except Eleven's
+ // onboarding conversations (setup, welcome), which run FULLY native:
+ // the bundled flow interpreter + a SwiftUI chat (no webview, no
+ // server web-code dependency).
if !t.isApp {
NativeChatScreen(instance: store.instance, thread: t, store: store)
+ } else if t.appId == "setup" || t.appId == "welcome" {
+ ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path)
} else {
ChatView(instance: store.instance, thread: t, store: store,
myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
diff --git a/ios/LChatCore/Package.swift b/ios/LChatCore/Package.swift
index da9228c..3bf5789 100644
--- a/ios/LChatCore/Package.swift
+++ b/ios/LChatCore/Package.swift
@@ -12,7 +12,9 @@ let package = Package(
.library(name: "LChatCore", targets: ["LChatCore"])
],
targets: [
- .target(name: "LChatCore"),
+ // Flows/ carries the conversation scripts (byte-derived copies of
+ // web/static/apps/*.flow.js, drift-gated by test/flow.test.mjs).
+ .target(name: "LChatCore", resources: [.copy("Flows"), .copy("Icons")]),
// The cross-implementation drift gates (fixtures generated by the web
// crypto — see test/threadkey.test.mjs). `swift test` runs them natively
// on the Mac, no simulator needed.
diff --git a/ios/LChatCore/Sources/LChatCore/AppFlows.swift b/ios/LChatCore/Sources/LChatCore/AppFlows.swift
new file mode 100644
index 0000000..3117fe5
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppFlows.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+// 🤖 Loads the bundled conversation-script documents (Flows/<name>.json —
+// byte-derived copies of web/static/apps/<name>.flow.js, drift-gated by
+// test/flow.test.mjs) for the native flow interpreter (FlowEngine).
+public enum AppFlows {
+ public static func flow(_ name: String) -> FlowJSON? {
+ guard let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Flows"),
+ let data = try? Data(contentsOf: url) else { return nil }
+ return FlowJSON.parse(data)
+ }
+}
+
+// 🤖 The brand mark for Eleven-the-guide's threads: the app icon (the purple
+// "11" tile — web parity: welcome.js wears /static/icon-192.png), bundled per
+// the each-surface-embeds-its-own-copy convention in CLAUDE.md. Eleven has no
+// gender and no face — the mark is the product's, worn where an avatar goes.
+public enum ElevenBrand {
+ public static let icon: PlatformImage? = {
+ guard let url = Bundle.module.url(forResource: "icon-192", withExtension: "png", subdirectory: "Icons"),
+ let data = try? Data(contentsOf: url) else { return nil }
+ return PlatformImage(data: data)
+ }()
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift
index b8579d5..81aff1d 100644
--- a/ios/LChatCore/Sources/LChatCore/Crypto.swift
+++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift
@@ -167,6 +167,13 @@ public struct GroupKey {
/// Decrypts a base64url blob to its raw plaintext bytes — for callers that
/// parse tolerantly with JSONSerialization (mini-app state snapshots hold
/// several apps' shapes at once) rather than a typed decode.
+ /// Encrypts raw JSON bytes to a base64url blob (the message-blob format) —
+ /// the write-side twin of decryptData, for dynamic payloads built with
+ /// JSONSerialization (conversation say/step events).
+ public func encryptData(_ data: Data) throws -> String {
+ try encrypt(data).base64urlEncodedString()
+ }
+
public func decryptData(blob: String) throws -> Data {
try decrypt(base64urlBlob: blob)
}
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift
new file mode 100644
index 0000000..20382fe
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift
@@ -0,0 +1,219 @@
+import SwiftUI
+
+// 🤖 The native surface for Eleven's onboarding conversations. The transcript
+// renders through the CANONICAL components — MessageBubble for bubbles (same
+// theme, reading size, selection, and look as every native feed), TypingBubble
+// for the beat, and the feeds' exact action-line recipe — so this view adds
+// only what's unique to a conversation: tappable chips and a composer pill
+// built to the Mac feed composer's recipe. Logic lives in
+// ElevenConversationModel/FlowEngine; this view only draws.
+public struct ElevenChatView: View {
+ @ObservedObject var model: ElevenConversationModel
+ var theme: ChatTheme = .classic
+ @State private var input = ""
+ @FocusState private var focused: Bool
+ @Environment(\.chatFontSize) private var fontSize
+ @Environment(\.colorScheme) private var scheme
+
+ public init(model: ElevenConversationModel, theme: ChatTheme = .classic) {
+ self.model = model
+ self.theme = theme
+ }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 2) {
+ if model.rows.isEmpty && model.ready {
+ Text("This is the start of your chat with Eleven.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.top, 24)
+ }
+ ForEach(model.rows) { row in
+ rowView(row)
+ }
+ if model.typing {
+ typingRow.id("typing")
+ }
+ Color.clear.frame(height: 1).id("bottom")
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+ .onChange(of: model.rows.count) {
+ withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
+ }
+ .onChange(of: model.typing) {
+ withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
+ }
+ }
+ composer
+ }
+ }
+
+ @ViewBuilder private func rowView(_ row: ElevenConversationModel.Row) -> some View {
+ switch row.kind {
+ case .eleven(let text):
+ VStack(alignment: .leading, spacing: 6) {
+ MessageBubble(message: bubbleMessage(row, text: text, mine: false),
+ showSender: false, theme: theme)
+ if let chips = row.chips {
+ FlowChipWrap(chips: chips, accent: theme.accent.resolved(scheme))
+ .padding(.leading, 4)
+ }
+ }
+ .padding(.vertical, 2)
+ case .mine(let text):
+ MessageBubble(message: bubbleMessage(row, text: text, mine: true),
+ showSender: false, theme: theme)
+ .padding(.vertical, 2)
+ case .action(let text):
+ // The feeds' exact /me action-line recipe (NativeFeedView.row).
+ Text(text)
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
+ }
+ }
+
+ private func bubbleMessage(_ row: ElevenConversationModel.Row, text: String, mine: Bool) -> ChatFeedModel.Message {
+ ChatFeedModel.Message(id: row.seq, name: mine ? model.instance.screenName : "Eleven",
+ text: text, mine: mine, at: row.at, edited: false)
+ }
+
+ private var typingRow: some View {
+ HStack(alignment: .bottom, spacing: 8) {
+ TypingBubble(theme: theme)
+ Spacer()
+ }
+ .transition(.opacity)
+ }
+
+ // The Mac feed composer's pill recipe (NativeFeedView.composerRow), shared
+ // here by both platforms: a plain field in a bordered pill on the panel
+ // background, with the same send disc.
+ private var composer: some View {
+ HStack(alignment: .bottom, spacing: 8) {
+ TextField(model.composerHint ?? "Message Eleven", text: $input, axis: .vertical)
+ .textFieldStyle(.plain)
+ .font(.system(size: fontSize))
+ .lineLimit(1...9)
+ .focused($focused)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ .onSubmit(send)
+ // A prompt change under an ACTIVE field editor wedges the
+ // AppKit-backed field (caret blinks, keys ignored). Recreate
+ // the field on each hint and hand focus straight back.
+ .id("composer-" + (model.composerHint ?? ""))
+ .onChange(of: model.composerHint) {
+ DispatchQueue.main.async { focused = true }
+ }
+ Button(action: send) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 28))
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, input.trimmingCharacters(in: .whitespaces).isEmpty
+ ? AnyShapeStyle(.gray.opacity(0.5))
+ : AnyShapeStyle(theme.accent.resolved(scheme)))
+ }
+ .buttonStyle(.plain)
+ .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
+ .accessibilityLabel("Send")
+ .padding(.bottom, 2)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ }
+
+ private func send() {
+ let text = input
+ input = ""
+ Task { await model.send(text) }
+ }
+}
+
+// A wrapping row of chips; each greys to its spent label once tapped or stale.
+struct FlowChipWrap: View {
+ let chips: [FlowChip]
+ var accent: Color = .accentColor
+ @Environment(\.chatFontSize) private var fontSize
+ @State private var tapped: Set<UUID> = []
+ @State private var beat = 0
+
+ var body: some View {
+ FlowLayoutHStack {
+ ForEach(chips) { chip in
+ // `beat < 0` is always false — it exists to make body READ the
+ // beat so staleness re-evaluates, WITHOUT the identity-destroying
+ // .id(beat) (which re-created subtrees every 500ms and fought
+ // the composer's field editor for first responder).
+ let spent = tapped.contains(chip.id) || chip.isStale() || beat < 0
+ Button {
+ guard !spent else { return }
+ tapped.insert(chip.id)
+ chip.run()
+ } label: {
+ Text(spent ? (chip.spentLabel ?? chip.label) : chip.label)
+ .font(.system(size: max(12, fontSize - 2)))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Capsule().fill(spent ? AnyShapeStyle(.quaternary.opacity(0.4))
+ : AnyShapeStyle(accent.opacity(0.14))))
+ .foregroundStyle(spent ? Color.secondary : accent)
+ }
+ .buttonStyle(.plain)
+ .disabled(spent)
+ }
+ }
+ // Staleness is a closure over engine state — re-read it on a slow beat.
+ .task {
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ beat += 1
+ }
+ }
+ }
+}
+
+// A minimal wrapping layout (chips can outgrow one line).
+struct FlowLayoutHStack: Layout {
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let width = proposal.width ?? 400
+ var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0
+ for sub in subviews {
+ let size = sub.sizeThatFits(.unspecified)
+ if x + size.width > width, x > 0 {
+ x = 0
+ y += rowH + 6
+ rowH = 0
+ }
+ x += size.width + 6
+ rowH = max(rowH, size.height)
+ }
+ return CGSize(width: width, height: y + rowH)
+ }
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ var x = bounds.minX, y = bounds.minY, rowH: CGFloat = 0
+ for sub in subviews {
+ let size = sub.sizeThatFits(.unspecified)
+ if x + size.width > bounds.maxX, x > bounds.minX {
+ x = bounds.minX
+ y += rowH + 6
+ rowH = 0
+ }
+ sub.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
+ x += size.width + 6
+ rowH = max(rowH, size.height)
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
new file mode 100644
index 0000000..94c6f80
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
@@ -0,0 +1,358 @@
+import Foundation
+import SwiftUI
+
+// 🤖 The native host for Eleven's onboarding conversations (setup, welcome):
+// binds FlowEngine to the real thread — folds the persisted transcript
+// (`<app>.say` / `<app>.step` events plus the member's own chat messages,
+// wire-identical to the web's, so a conversation started on one surface
+// resumes on the other), runs the flow with native verbs (plain admin POSTs;
+// the passkey ceremony is skipped by env.native), and persists each line as
+// it's said. Everything sent is encrypted under the thread key first.
+@MainActor
+public final class ElevenConversationModel: ObservableObject {
+ public struct Row: Identifiable {
+ public let id = UUID()
+ /// A stable message-shaped identity + timestamp so the transcript can
+ /// render through the canonical MessageBubble.
+ public var seq: Int64 = 0
+ public var at: Int64 = 0
+ public var kind: Kind
+ public var chips: [FlowChip]?
+ public enum Kind {
+ case eleven(String) // Eleven's bubble
+ case action(String) // a ✓ step line ("noted: you're Paul")
+ case mine(String) // the member's own message/answer
+ }
+ }
+
+ private var nextSeq: Int64 = 0
+ private func append(_ kind: Row.Kind, chips: [FlowChip]? = nil) {
+ nextSeq += 1
+ rows.append(Row(seq: nextSeq, at: Int64(Date().timeIntervalSince1970), kind: kind, chips: chips))
+ }
+
+ @Published public private(set) var rows: [Row] = []
+ @Published public private(set) var typing = false
+ @Published public var composerHint: String?
+ @Published public private(set) var ready = false
+
+ public let instance: SavedInstance
+ public let thread: ChatThread
+ let appId: String
+ let sayType: String
+ let stepType: String
+ private let api: LChatAPI?
+ private let key: GroupKey?
+ private var socket: ChatSocket?
+ private var engine: FlowEngine?
+ private var env = FlowEnv()
+ private var said: [String: Bool] = [:]
+ private var steps: [String: (done: Bool, value: String)] = [:]
+ private var foldedIds = Set<Int64>()
+ private var answerHandler: ((FlowAnswer) -> Void)?
+ /// Native navigation for the finish chips ("Say hi to {inviter}").
+ public var openThread: ((String) -> Void)?
+
+ public init?(instance: SavedInstance, thread: ChatThread, myUserId: String) {
+ guard let appId = thread.appId, appId == "setup" || appId == "welcome",
+ !thread.threadKeyB64.isEmpty else { return nil }
+ self.instance = instance
+ self.thread = thread
+ self.appId = appId
+ self.sayType = appId + ".say"
+ self.stepType = appId + ".step"
+ self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
+ self.key = try? GroupKey(base64url: thread.threadKeyB64)
+ self.myUserId = myUserId
+ }
+
+ let myUserId: String
+
+ public func start(roster: [RosterEntry], threads: [ChatThread]) {
+ guard engine == nil else { return }
+ Task { await load(roster: roster, threads: threads) }
+ }
+
+ public func stop() {
+ socket?.stop()
+ socket = nil
+ }
+
+ // MARK: history fold + engine start
+
+ private func load(roster: [RosterEntry], threads: [ChatThread]) async {
+ guard let api, let key else { return }
+ let messages = await api.messages(thread: thread.id, after: 0)
+ for m in messages { fold(id: m.id, blob: m.blob, key: key) }
+ startSocket()
+ ready = true
+
+ guard let flow = AppFlows.flow(appId) else { return }
+ env = FlowEnv()
+ env.flags["native"] = .bool(true)
+ env.flags["suggested"] = .string("")
+ // The roster carries no role natively — the greeting falls back to
+ // "run by one of its members" (setup's creator IS the admin anyway).
+ env.flags["adminName"] = .string("")
+ // The inviter: the other member of the joiner's first person-DM.
+ var inviter = ""
+ var pendingThreadId: String?
+ if let dm = threads.first(where: { $0.kind == "dm" && $0.appId == nil }) {
+ if let other = dm.members.first(where: { $0 != myUserId }),
+ let entry = roster.first(where: { $0.user_id == other }) {
+ inviter = entry.label
+ pendingThreadId = dm.id
+ }
+ }
+ env.flags["inviterName"] = .string(inviter)
+ env.flags["hasPendingThread"] = .bool(pendingThreadId != nil)
+ let name = await api.groupName()
+ env.space = ["name": .string(name), "nameSet": .bool(!name.isEmpty)]
+ env.live["bridge.myName"] = { [instance] in .string(instance.screenName) }
+ env.live["bridge.enrolled"] = { .bool(false) }
+ env.live["bridge.fromHome"] = { .bool(false) }
+ env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
+ let hop = pendingThreadId
+ pendingHop = hop
+ let engine = FlowEngine(flow: flow, env: env, io: self)
+ self.engine = engine
+ engine.start()
+ }
+
+ private var pendingHop: String?
+
+ // Folds dedupe on CONVERSATION state (said keys, recorded steps, pending
+ // own sends), not just message ids — our own optimistic rows race the
+ // socket echo of the same event, and the REST response's id can lose that
+ // race (the live-duplicates bug).
+ private func fold(id: Int64, blob: String, key: GroupKey) {
+ guard !foldedIds.contains(id), let data = try? key.decryptData(blob: blob),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
+ foldedIds.insert(id)
+ let type = obj["type"] as? String
+ if type == sayType {
+ let k = obj["key"] as? String ?? (obj["text"] as? String ?? "")
+ if said[k] == true { return } // already shown (our own echo)
+ said[k] = true
+ if let text = obj["text"] as? String, !text.isEmpty {
+ append(.eleven(text))
+ }
+ } else if type == stepType {
+ guard let step = obj["step"] as? String else { return }
+ if steps[step]?.done == true { return } // already shown
+ steps[step] = (true, obj["value"] as? String ?? "")
+ if let label = obj["label"] as? String, !label.isEmpty {
+ append(.action(label))
+ }
+ } else if type == nil || type == "chat" {
+ guard let text = obj["text"] as? String, !text.isEmpty else { return }
+ if let i = pendingMine.firstIndex(of: text) {
+ pendingMine.remove(at: i) // our own send's echo
+ return
+ }
+ append(.mine(text))
+ }
+ }
+
+ // Texts we've optimistically shown, awaiting their echo.
+ private var pendingMine: [String] = []
+
+ private func startSocket() {
+ guard let base = instance.baseURL else { return }
+ let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
+ socket?.onFrame = { [weak self] frame in
+ guard let self, frame.thread_id == self.thread.id, frame.type == "msg",
+ frame.ephemeral != true, let id = frame.id, let blob = frame.blob,
+ let key = self.key else { return }
+ self.fold(id: id, blob: blob, key: key)
+ }
+ socket?.start()
+ self.socket = socket
+ }
+
+ // MARK: composer
+
+ /// The member typed into the conversation: persist it as a normal chat
+ /// message (the web transcript shows the real answers) and hand it to the
+ /// flow's waiter if one is armed.
+ public func send(_ text: String) async {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ append(.mine(trimmed))
+ pendingMine.append(trimmed)
+ await postEvent(["text": trimmed])
+ if let handler = answerHandler {
+ answerHandler = nil
+ composerHint = nil
+ handler(FlowAnswer(text: trimmed))
+ }
+ }
+
+ // Stamp the transport fields and POST the encrypted payload as a message
+ // (the same wire the web's emit rides; REST so a pre-socket line lands).
+ @discardableResult
+ private func postEvent(_ fields: [String: Any]) async -> Bool {
+ guard let key, let base = instance.baseURL else { return false }
+ var obj = fields
+ obj["name"] = instance.screenName
+ obj["ts"] = Int64(Date().timeIntervalSince1970 * 1000)
+ if !myUserId.isEmpty { obj["uid"] = myUserId }
+ guard let data = try? JSONSerialization.data(withJSONObject: obj),
+ let blob = try? key.encryptData(data) else { return false }
+ var comps = URLComponents(url: base.appending(path: "api/messages"), resolvingAgainstBaseURL: false)!
+ comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
+ guard let url = comps.url,
+ let body = try? JSONSerialization.data(withJSONObject: ["thread_id": thread.id, "blob": blob]) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = body
+ guard let (respData, resp) = try? await URLSession.shared.data(for: req),
+ (resp as? HTTPURLResponse)?.statusCode == 200 else { return false }
+ // Mark the echo folded so the socket doesn't duplicate the row.
+ struct Resp: Decodable { let id: Int64? }
+ if let r = try? JSONDecoder().decode(Resp.self, from: respData), let id = r.id {
+ foldedIds.insert(id)
+ }
+ return true
+ }
+}
+
+// MARK: - the flow io
+
+extension ElevenConversationModel: FlowIO {
+ public func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async {
+ let sayKey = extra.key ?? text
+ // The typing beat: on, held a readable moment scaled to length, off
+ // once the bubble is in the transcript (eleven.js's pacing, simplified).
+ let alreadySaid = !extra.local && said[sayKey] == true
+ if !text.isEmpty && !alreadySaid {
+ typing = true
+ let ms = min(2000.0, 500.0 + Double(text.count) * 4)
+ try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000))
+ typing = false
+ }
+ if !text.isEmpty {
+ if extra.local {
+ append(.eleven(text), chips: chips)
+ } else if !alreadySaid {
+ said[sayKey] = true
+ append(.eleven(text), chips: chips)
+ await postEvent(["type": sayType, "key": sayKey, "text": text])
+ } else if let chips {
+ // Re-run over a said line: attach the chips to a fresh row's
+ // stead — the last transcript row carries them.
+ attachChips(chips)
+ }
+ } else if let chips {
+ attachChips(chips)
+ }
+ // Each fresh persisted line RESETS the hint unless it carries its own —
+ // a question's placeholder ("Type a contact email") must not outlive
+ // the step that asked it. Local lines (resume greetings) leave it be.
+ if extra.hint != nil || extra.attachHint != nil {
+ composerHint = extra.hint ?? extra.attachHint
+ } else if !extra.local {
+ composerHint = nil
+ }
+ }
+
+ private func attachChips(_ chips: [FlowChip]) {
+ if var last = rows.last {
+ last.chips = chips
+ rows[rows.count - 1] = last
+ }
+ }
+
+ public func ask(_ handler: @escaping (FlowAnswer) -> Void) {
+ answerHandler = handler
+ }
+
+ public func cancelAnswer() {
+ answerHandler = nil
+ composerHint = nil
+ }
+
+ public func record(step: String, label: String, value: String) async {
+ steps[step] = (true, value)
+ if !label.isEmpty { append(.action(label)) }
+ await postEvent(["type": stepType, "step": step, "label": label, "value": value])
+ }
+
+ public func done(_ step: String) -> Bool { steps[step]?.done ?? false }
+
+ public func delay(ms: Double, _ fn: @escaping () -> Void) {
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: UInt64(ms * 1_000_000))
+ fn()
+ }
+ }
+
+ public func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON {
+ guard let api else { return .bool(false) }
+ switch name {
+ case "setMyName":
+ let raw = args.first?.stringValue ?? ""
+ let capped = String(raw.prefix(32))
+ guard !capped.isEmpty else { return .bool(false) }
+ await publishLabel(capped)
+ return .null
+ case "setGroupName":
+ let name = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces)
+ guard (1...60).contains(name.count) else { return .string("invalid") }
+ let ok = await api.setGroupSettings(name: name)
+ if ok { env.space["name"] = .string(name); env.space["nameSet"] = .bool(true) }
+ return .string(ok ? "ok" : "error")
+ case "setContactEmail":
+ let email = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces)
+ guard email.contains("@"), email.contains(".") else { return .string("invalid") }
+ return .string(await api.setGroupSettings(adminEmail: email) ? "ok" : "error")
+ case "setUserInvites":
+ let on = args.first.map { $0.truthy } ?? true
+ _ = await api.setAdminSettings(["user_invites": on])
+ return .null
+ case "markSetupComplete":
+ _ = await api.setAdminSettings(["setup_complete": true])
+ return .null
+ case "deferBackupReminders":
+ return .null // native access is the sign-in; nothing to nag about
+ case "openPendingThread":
+ if let hop = pendingHop { openThread?(hop) }
+ return .null
+ default:
+ return .null // unknown/web-only verbs are quiet no-ops natively
+ }
+ }
+
+ // The member's screen name, encrypted under the instance key (the web's
+ // publishMyLabel): the server holds only the blob.
+ private func publishLabel(_ name: String) async {
+ guard let api else { return }
+ // The instance key comes wrapped to us on the roster path; SpaceClaim
+ // minted it for creators. Fetch + unwrap via the instance keypair.
+ guard let wrapped = await api.instanceKeyWrapped(),
+ let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv),
+ let ik = try? GroupKey(rawKey: raw),
+ let blob = try? ik.encryptJSON(name) else { return }
+ _ = await api.postLabel(labelBlob: blob)
+ }
+}
+
+// MARK: - cache: one live conversation per thread (leaving and returning
+// resumes the same run; the flow also resumes from recorded state cold).
+
+@MainActor
+public final class ElevenConversationCache {
+ public static let shared = ElevenConversationCache()
+ private var models: [String: ElevenConversationModel] = [:]
+
+ public func model(for instance: SavedInstance, thread: ChatThread,
+ myUserId: String) -> ElevenConversationModel? {
+ let key = instance.id + "|" + thread.id
+ if let existing = models[key] { return existing }
+ guard let model = ElevenConversationModel(instance: instance, thread: thread,
+ myUserId: myUserId) else { return nil }
+ models[key] = model
+ return model
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
new file mode 100644
index 0000000..f18b6ef
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift
@@ -0,0 +1,599 @@
+import Foundation
+
+// 🤖 The Swift twin of web/static/apps/flow.js: the interpreter for the
+// conversation scripts (docs/conversation-scripts.md) — the JSON documents
+// that ARE Eleven's onboarding conversations (setup.flow.js, welcome.flow.js).
+// The docs promised "the document the server will ship to native
+// interpreters"; this is that interpreter. Byte-identical copies of the flow
+// data are bundled in Flows/ (drift-gated by test/flow.test.mjs), and the two
+// engines are held together by mirrored tests (FlowEngineTests ↔
+// test/flow.test.mjs) — change semantics on one side and the other changes in
+// the same PR.
+//
+// Same shape as flow.js: a pure-data walker over steps (entries, skipIf,
+// bubbles, chips, inputs, effects with result branching, settle polls), with
+// ALL side effects behind the io surface — say, ask, verbs, records, delays.
+// The engine never touches the network, crypto, or UI.
+
+// MARK: - FlowJSON: a minimal dynamic JSON value for the flow documents
+
+public indirect enum FlowJSON: Equatable, Sendable {
+ case null
+ case bool(Bool)
+ case number(Double)
+ case string(String)
+ case array([FlowJSON])
+ case object([String: FlowJSON])
+
+ public init(any: Any?) {
+ switch any {
+ case nil, is NSNull: self = .null
+ case let n as NSNumber:
+ if CFGetTypeID(n as CFTypeRef) == CFBooleanGetTypeID() { self = .bool(n.boolValue) }
+ else { self = .number(n.doubleValue) }
+ case let s as String: self = .string(s)
+ case let a as [Any]: self = .array(a.map { FlowJSON(any: $0) })
+ case let o as [String: Any]: self = .object(o.mapValues { FlowJSON(any: $0) })
+ default: self = .null
+ }
+ }
+
+ public static func parse(_ data: Data) -> FlowJSON? {
+ guard let any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { return nil }
+ return FlowJSON(any: any)
+ }
+
+ public subscript(key: String) -> FlowJSON {
+ if case .object(let o) = self { return o[key] ?? .null }
+ return .null
+ }
+
+ public var arrayValue: [FlowJSON] {
+ if case .array(let a) = self { return a }
+ return []
+ }
+
+ public var stringValue: String? {
+ if case .string(let s) = self { return s }
+ return nil
+ }
+
+ public var isNull: Bool { self == .null }
+
+ var truthy: Bool {
+ switch self {
+ case .null: return false
+ case .bool(let b): return b
+ case .number(let n): return n != 0 && !n.isNaN
+ case .string(let s): return !s.isEmpty
+ case .array(let a): return !a.isEmpty
+ case .object: return true
+ }
+ }
+
+ var display: String {
+ switch self {
+ case .string(let s): return s
+ case .number(let n):
+ if n.isFinite, n == n.rounded(), abs(n) < 9e15 { return String(Int64(n)) }
+ return String(n)
+ case .bool(let b): return b ? "true" : "false"
+ default: return ""
+ }
+ }
+}
+
+// MARK: - the environment
+
+// The flow's env: template/condition state plus the bridge closures. Dotted
+// paths resolve exactly as flow.js does — "done"/"done.X" through the io,
+// "vars.*" and "space.*" into their dictionaries, "bridge.*" through closures
+// (called), everything else through `flags` ("native", "suggested",
+// "inviterName", …; closures allowed for live reads like accessBackedUp).
+@MainActor
+public final class FlowEnv {
+ public var vars: [String: FlowJSON] = [:]
+ public var space: [String: FlowJSON] = [:]
+ public var flags: [String: FlowJSON] = [:]
+ /// Live lookups (bridge.myName, accessBackedUp, …) — resolved per read.
+ public var live: [String: () -> FlowJSON] = [:]
+ var ack = ""
+ var resumed = false
+ /// Set by the engine on entry to each step ("done" resolves through it).
+ var currentStepDone: () -> Bool = { false }
+ var doneOf: (String) -> Bool = { _ in false }
+ public var onEnd: () -> Void = {}
+
+ public init() {}
+
+ func resolve(_ path: String) -> FlowJSON {
+ if path == "done" { return .bool(currentStepDone()) }
+ if path.hasPrefix("done.") { return .bool(doneOf(String(path.dropFirst(5)))) }
+ if path == "resumed" { return .bool(resumed) }
+ let parts = path.split(separator: ".").map(String.init)
+ guard let first = parts.first else { return .null }
+ var cur: FlowJSON
+ if first == "vars" {
+ cur = .object(vars)
+ } else if first == "space" {
+ cur = .object(space)
+ } else if first == "bridge", parts.count > 1, let fn = live["bridge." + parts[1]] {
+ return fn()
+ } else if let fn = live[path] {
+ return fn()
+ } else if let fn = live[first] {
+ cur = fn()
+ for p in parts.dropFirst() { cur = cur[p] }
+ return cur
+ } else if let v = flags[first] {
+ cur = v
+ } else {
+ return .null
+ }
+ for p in parts.dropFirst() { cur = cur[p] }
+ return cur
+ }
+}
+
+// MARK: - the io surface
+
+/// One tappable chip on a bubble. `spentLabel` is its past-tense form once
+/// tapped; `isStale` ties liveness to the step (finish-line chips stay live).
+@MainActor
+public final class FlowChip: Identifiable {
+ public let id = UUID()
+ public let label: String
+ public let spentLabel: String?
+ public let isStale: () -> Bool
+ public let run: () -> Void
+ init(label: String, spentLabel: String?, isStale: @escaping () -> Bool, run: @escaping () -> Void) {
+ self.label = label
+ self.spentLabel = spentLabel
+ self.isStale = isStale
+ self.run = run
+ }
+}
+
+/// Extra presentation riding a said line (mirrors makeSay's extra).
+public struct FlowSayExtra: Sendable {
+ public var key: String?
+ public var hint: String?
+ public var attachHint: String?
+ public var local = false
+ public var imgToken: String?
+ public var imgAlt = ""
+ public var imgRound = false
+ public init() {}
+}
+
+/// A composer answer: the typed text plus any image attachment (photo steps).
+public struct FlowAnswer: Sendable {
+ public var text: String
+ public var imageAttachment: [String: String]? // {id, type} when an image rode along
+ public init(text: String, imageAttachment: [String: String]? = nil) {
+ self.text = text
+ self.imageAttachment = imageAttachment
+ }
+}
+
+/// What the engine needs from its host — the exact io contract flow.js takes.
+/// All methods are MainActor; say/verb/record are async (pacing, network).
+@MainActor
+public protocol FlowIO: AnyObject {
+ /// Speak one line (persist unless extra.local), with optional chips.
+ func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async
+ /// Arm the one-shot answer waiter: the next composer send is the answer.
+ func ask(_ handler: @escaping (FlowAnswer) -> Void)
+ func cancelAnswer()
+ /// Run a bridge verb; nil/true → "ok", false → "error", string verbatim.
+ func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON
+ /// Persist a completed step (the ✓ line): the app's `.step` event.
+ func record(step: String, label: String, value: String) async
+ /// Whether a step already recorded (folded from the thread's history).
+ func done(_ step: String) -> Bool
+ /// Schedule fn after ms (settle polls, chip probes).
+ func delay(ms: Double, _ fn: @escaping () -> Void)
+}
+
+// MARK: - the engine
+
+@MainActor
+public final class FlowEngine {
+ let flow: FlowJSON
+ let env: FlowEnv
+ weak var ioRef: AnyObject?
+ let io: FlowIO
+ private var steps: [String: (step: FlowJSON, next: String?)] = [:]
+ private var currentGo = OnceStep()
+
+ public init(flow: FlowJSON, env: FlowEnv, io: FlowIO) {
+ self.flow = flow
+ self.env = env
+ self.io = io
+ let list = flow["steps"].arrayValue
+ for (i, s) in list.enumerated() {
+ guard let id = s["id"].stringValue else { continue }
+ let next = i + 1 < list.count ? list[i + 1]["id"].stringValue : nil
+ steps[id] = (s, next)
+ }
+ env.doneOf = { [weak self] id in self?.io.done(id) ?? false }
+ }
+
+ // One-tap-wins step guard (flow.js onceStep).
+ final class OnceStep {
+ var taken = false
+ func callAsFunction(_ fn: () -> Void) {
+ if taken { return }
+ taken = true
+ fn()
+ }
+ }
+
+ // MARK: conditions & templates (mirrors evalCond/template/renderText)
+
+ public func evalCond(_ expr: FlowJSON) -> Bool {
+ switch expr {
+ case .null: return true
+ case .bool(let b): return b
+ case .object(let o):
+ if let all = o["all"] { return all.arrayValue.allSatisfy { evalCond($0) } }
+ if let any = o["any"] { return any.arrayValue.contains { evalCond($0) } }
+ return false
+ case .string(let s):
+ let neg = s.hasPrefix("!")
+ let v = env.resolve(neg ? String(s.dropFirst()) : s)
+ return neg ? !v.truthy : v.truthy
+ default:
+ return false
+ }
+ }
+
+ public func template(_ s: String) -> String {
+ var out = ""
+ var i = s.startIndex
+ while i < s.endIndex {
+ if s[i] == "{" , let close = s[i...].firstIndex(of: "}") {
+ let body = String(s[s.index(after: i)..<close])
+ if body == "ack?" {
+ let a = env.ack
+ env.ack = ""
+ out += a.isEmpty ? "" : a + "\n\n"
+ } else {
+ let parts = body.split(separator: "|", maxSplits: 1).map(String.init)
+ let v = env.resolve(parts[0].trimmingCharacters(in: .whitespaces))
+ if v.isNull || v.display.isEmpty {
+ out += parts.count > 1 ? parts[1] : ""
+ } else {
+ out += v.display
+ }
+ }
+ i = s.index(after: close)
+ } else {
+ out.append(s[i])
+ i = s.index(after: i)
+ }
+ }
+ return out
+ }
+
+ public func renderText(_ t: FlowJSON) -> String {
+ switch t {
+ case .string(let s): return template(s)
+ case .array(let segs):
+ return segs.map { seg -> String in
+ if let str = seg.stringValue { return template(str) }
+ if !seg["if"].isNull || !seg["text"].isNull {
+ return evalCond(seg["if"]) ? template(seg["text"].stringValue ?? "") : ""
+ }
+ return ""
+ }.joined()
+ case .object(let o):
+ if o["if"] != nil {
+ let branch = evalCond(o["if"] ?? .null) ? o["then"] : o["else"]
+ return template(branch?.stringValue ?? "")
+ }
+ return ""
+ default:
+ return ""
+ }
+ }
+
+ // "$answer"/"$answerText" read captured input; anything else templates.
+ func valueOf(_ v: FlowJSON) -> FlowJSON {
+ if let s = v.stringValue {
+ if s == "$answer" { return env.vars["answer"] ?? .null }
+ if s == "$answerText" { return env.vars["answerText"] ?? .null }
+ return .string(template(s))
+ }
+ return v
+ }
+
+ func setPaths(_ sets: FlowJSON) {
+ for (path, value) in sets.arrayPairs {
+ let parts = path.split(separator: ".").map(String.init)
+ let resolved: FlowJSON = value.stringValue != nil ? valueOf(value) : value
+ if parts.first == "vars", parts.count == 2 {
+ env.vars[parts[1]] = resolved
+ } else if parts.count == 1 {
+ env.flags[parts[0]] = resolved
+ }
+ }
+ }
+
+ // Map a verb result onto a branch key (flow.js resultKey).
+ static func resultKey(_ r: FlowJSON) -> String {
+ if r.isNull { return "ok" }
+ if case .bool(let b) = r { return b ? "ok" : "error" }
+ return r.display
+ }
+
+ // MARK: run
+
+ public func start() {
+ env.resumed = evalCond(flow["resumedIf"].isNull ? .bool(false) : flow["resumedIf"])
+ // Strong self: the initial walk must survive even before the owner
+ // stores the engine (asks/chips re-anchor through the owner after).
+ if env.resumed,
+ !(flow["resumeUnless"] != .null && evalCond(flow["resumeUnless"])),
+ let line = flow["resumeSay"].stringValue {
+ Task {
+ var extra = FlowSayExtra()
+ extra.local = true
+ await self.io.say(line, chips: nil, extra: extra)
+ await self.enter(self.flow["entry"].stringValue ?? "")
+ }
+ } else {
+ Task {
+ await self.enter(self.flow["entry"].stringValue ?? "")
+ }
+ }
+ }
+
+ // Actions shared by chips, `on` branches, and entries. True = we moved.
+ @discardableResult
+ func runActions(_ a: FlowJSON, stepId: String) async -> Bool {
+ if a.isNull { return false }
+ if !a["set"].isNull { setPaths(a["set"]) }
+ if !a["record"].isNull {
+ let rec = a["record"]
+ let step = rec["step"].stringValue ?? stepId
+ let value = rec["value"].isNull ? "" : valueOf(rec["value"]).display
+ await io.record(step: step, label: renderText(rec["label"]), value: value)
+ }
+ if let ack = a["ack"].stringValue { env.ack = renderText(.string(ack)) }
+ if let sayText = a["say"].stringValue {
+ var extra = FlowSayExtra()
+ extra.local = a["local"].truthy
+ await io.say(renderText(.string(sayText)), chips: nil, extra: extra)
+ }
+ if !a["ask"].isNull {
+ let text = a["ask"].stringValue ?? a["ask"]["text"].stringValue ?? ""
+ await askIn(stepId: stepId, text: renderText(.string(text)))
+ return true
+ }
+ if !a["gotoAsk"].isNull {
+ let target = a["gotoAsk"]["step"].stringValue ?? stepId
+ await askIn(stepId: target, text: renderText(.string(a["gotoAsk"]["text"].stringValue ?? "")))
+ return true
+ }
+ if a["retry"].stringValue == "step" || a["retry"].truthy {
+ await enter(stepId)
+ return true
+ }
+ if let goto = a["goto"].stringValue {
+ await enter(goto)
+ return true
+ }
+ return false
+ }
+
+ func runEffect(stepId: String, effect: FlowJSON, argsOverride: [FlowJSON]? = nil) async {
+ guard let step = steps[stepId]?.step else { return }
+ let args = argsOverride ?? effect["args"].arrayValue.map { valueOf($0) }
+ let r = await io.verb(effect["verb"].stringValue ?? "", args: args)
+ let branch = step["on"][Self.resultKey(r)]
+ if branch.isNull { return } // fire-and-forget verb
+ let moved = await runActions(branch, stepId: stepId)
+ if !moved { armInput(stepId: stepId) } // rejected input re-asks in place
+ }
+
+ func buildChips(stepId: String, go: OnceStep) -> [FlowChip]? {
+ guard let step = steps[stepId]?.step else { return nil }
+ let chips = step["chips"].arrayValue.filter { evalCond($0["if"]) }.map { c -> FlowChip in
+ let advancing = !(c["advancing"] == .bool(false))
+ let hasProbe = !c["probe"].isNull
+ return FlowChip(
+ label: renderText(c["label"]),
+ spentLabel: c["spent"].stringValue,
+ isStale: (!advancing && !hasProbe) ? { false } : { go.taken },
+ run: { [weak self] in
+ guard let self else { return }
+ let act: () -> Void = {
+ Task { @MainActor in
+ if c["cancelInput"].truthy { self.io.cancelAnswer() }
+ if !c["set"].isNull { self.setPaths(c["set"]) }
+ if !c["effect"].isNull {
+ _ = await self.io.verb(c["effect"]["verb"].stringValue ?? "",
+ args: c["effect"]["args"].arrayValue.map { self.valueOf($0) })
+ }
+ await self.runActions(c, stepId: stepId)
+ }
+ }
+ if !advancing {
+ act()
+ if hasProbe {
+ let after = c["probe"]["afterMs"].isNull ? 1500 : c["probe"]["afterMs"].numberValue
+ self.io.delay(ms: after) { [weak self] in
+ guard let self else { return }
+ if !self.evalCond(c["probe"]["unless"]) {
+ go { Task { await self.enter(c["probe"]["goto"].stringValue ?? stepId) } }
+ }
+ }
+ }
+ } else {
+ go(act)
+ }
+ })
+ }
+ return chips.isEmpty ? nil : chips
+ }
+
+ func armInput(stepId: String) {
+ guard let step = steps[stepId]?.step, !step["input"].isNull else { return }
+ let input = step["input"]
+ let go = currentGo
+ if input["kind"].stringValue == "text" {
+ io.ask { [weak self] answer in
+ guard let self else { return }
+ go {
+ Task { @MainActor in
+ var text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
+ if case .number(let maxLen) = input["maxLen"] { text = String(text.prefix(Int(maxLen))) }
+ self.env.vars["answer"] = .string(text)
+ if input["required"].truthy && text.isEmpty {
+ await self.enter(stepId)
+ return
+ }
+ if !step["effect"].isNull {
+ await self.runEffect(stepId: stepId, effect: step["effect"])
+ }
+ }
+ }
+ }
+ } else if input["kind"].stringValue == "photo" {
+ armPhoto(stepId: stepId, step: step, go: go)
+ }
+ }
+
+ private func armPhoto(stepId: String, step: FlowJSON, go: OnceStep) {
+ io.ask { [weak self] answer in
+ guard let self else { return }
+ guard let img = answer.imageAttachment else {
+ Task { @MainActor in
+ var extra = FlowSayExtra()
+ extra.local = step["input"]["reject"]["local"].truthy
+ await self.io.say(self.renderText(step["input"]["reject"]["text"]), chips: nil, extra: extra)
+ self.armPhoto(stepId: stepId, step: step, go: go)
+ }
+ return
+ }
+ go {
+ Task { @MainActor in
+ let text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.env.vars["answerText"] = .string(text)
+ await self.runEffect(stepId: stepId, effect: step["effect"],
+ argsOverride: [.object(img.mapValues { .string($0) }), .string(text)])
+ }
+ }
+ }
+ }
+
+ public func enter(_ id: String) async {
+ guard let ctx = steps[id] else { return }
+ let step = ctx.step
+ env.currentStepDone = { [weak self] in self?.io.done(id) ?? false }
+
+ // Entry branches: state that satisfies the step on arrival.
+ for e in step["entries"].arrayValue {
+ guard evalCond(e["if"]) else { continue }
+ if !e["set"].isNull { setPaths(e["set"]) }
+ if !io.done(id) {
+ if !e["recordIfNew"].isNull {
+ await io.record(step: id, label: renderText(e["recordIfNew"]["label"]),
+ value: e["recordIfNew"]["value"].stringValue ?? "")
+ }
+ if let line = e["sayIfNew"].stringValue {
+ await io.say(renderText(.string(line)), chips: nil, extra: FlowSayExtra())
+ }
+ }
+ await enter(e["goto"].stringValue ?? ctx.next ?? "")
+ return
+ }
+ if step["skipIf"].arrayValue.contains(where: { evalCond($0) }) {
+ await enter(step["skipTo"].stringValue ?? ctx.next ?? "")
+ return
+ }
+
+ // A step may persist itself on arrival (finish's completion marker).
+ if !step["record"].isNull, !io.done(id) {
+ await io.record(step: id, label: renderText(step["record"]["label"]),
+ value: step["record"]["value"].stringValue ?? "")
+ }
+
+ let go = OnceStep()
+ currentGo = go
+
+ if !step["effects"].isNull {
+ for eff in step["effects"].arrayValue where evalCond(eff["if"]) {
+ _ = await io.verb(eff["verb"].stringValue ?? "", args: eff["args"].arrayValue.map { valueOf($0) })
+ }
+ }
+
+ let bubbles = step["bubbles"].arrayValue.filter { evalCond($0["if"]) }
+ for (i, b) in bubbles.enumerated() {
+ let last = i == bubbles.count - 1
+ var extra = FlowSayExtra()
+ if let key = b["key"].stringValue { extra.key = template(key) }
+ extra.hint = b["hint"].stringValue
+ extra.attachHint = b["attachHint"].stringValue
+ extra.local = b["local"].truthy
+ if let img = b["img"].stringValue {
+ extra.imgToken = img
+ extra.imgAlt = renderText(b["imgAlt"])
+ extra.imgRound = b["imgRound"].truthy
+ }
+ await io.say(renderText(b["text"]), chips: last ? buildChips(stepId: id, go: go) : nil, extra: extra)
+ }
+ if bubbles.isEmpty, !step["chips"].isNull {
+ await io.say("", chips: buildChips(stepId: id, go: go), extra: FlowSayExtra())
+ }
+ armInput(stepId: id)
+
+ // Settle: wait for a condition to become true on its own, then re-enter.
+ if !step["settle"].isNull, !evalCond(step["settle"]["when"]) {
+ let period = step["settle"]["everyMs"].isNull ? 700 : step["settle"]["everyMs"].numberValue
+ func tick() {
+ if go.taken { return }
+ if evalCond(step["settle"]["when"]) {
+ go { Task { await self.enter(step["settle"]["goto"].stringValue ?? id) } }
+ return
+ }
+ io.delay(ms: period, tick)
+ }
+ io.delay(ms: period, tick)
+ }
+
+ if let auto = step["auto"].stringValue {
+ await enter(auto)
+ return
+ }
+ if step["end"].truthy { env.onEnd() }
+ }
+
+ // A retry that re-asks with new text: one fresh line carrying the step's
+ // chips + hint, then the input re-arms.
+ func askIn(stepId: String, text: String) async {
+ guard let step = steps[stepId]?.step else { return }
+ let go = OnceStep()
+ currentGo = go
+ var extra = FlowSayExtra()
+ if let src = step["bubbles"].arrayValue.last {
+ extra.hint = src["hint"].stringValue
+ extra.attachHint = src["attachHint"].stringValue
+ }
+ await io.say(text, chips: buildChips(stepId: stepId, go: go), extra: extra)
+ armInput(stepId: stepId)
+ }
+}
+
+private extension FlowJSON {
+ var numberValue: Double {
+ if case .number(let n) = self { return n }
+ return 0
+ }
+ /// Ordered key/value pairs of an object (order not semantic for `set`).
+ var arrayPairs: [(String, FlowJSON)] {
+ if case .object(let o) = self { return o.map { ($0.key, $0.value) } }
+ return []
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
new file mode 100644
index 0000000..048c1d4
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -0,0 +1,525 @@
+{
+ "schema": 1,
+ "flow": "setup",
+ "entry": "hello",
+ "resumedIf": {
+ "any": [
+ "done.name",
+ "done.group",
+ "done.email",
+ "done.photo",
+ "done.invites",
+ "done.keys"
+ ]
+ },
+ "resumeSay": "Welcome back — let's pick up where we left off.",
+ "steps": [
+ {
+ "id": "hello",
+ "skipIf": [
+ "resumed"
+ ],
+ "bubbles": [
+ {
+ "text": "Hi — I'm Eleven! 👋 This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\nLet's take two minutes to set things up."
+ }
+ ],
+ "auto": "name"
+ },
+ {
+ "id": "name",
+ "skipIf": [
+ "done",
+ "bridge.myName"
+ ],
+ "bubbles": [
+ {
+ "text": "First — what should everyone here call you? Reply with your name.",
+ "hint": "Type your name"
+ }
+ ],
+ "input": {
+ "kind": "text",
+ "required": true,
+ "maxLen": 32
+ },
+ "effect": {
+ "verb": "setMyName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "on": {
+ "ok": {
+ "record": {
+ "label": "noted: you're {vars.answer}",
+ "value": "$answer"
+ },
+ "ack": "Lovely to meet you, {vars.answer}! 🤝",
+ "goto": "group"
+ }
+ }
+ },
+ {
+ "id": "group",
+ "skipIf": [
+ "done"
+ ],
+ "bubbles": [
+ {
+ "text": "{ack?}Next, this space needs a name. It's the one everyone sees — on the sign-in page, in invites, on the app icon.",
+ "key": "q-group"
+ },
+ {
+ "text": {
+ "if": "space.nameSet",
+ "then": "It's currently “{space.name}” — keep that, or reply with a new name.",
+ "else": "What should we call it? Reply with a name."
+ },
+ "hint": "Type a name for this space"
+ }
+ ],
+ "chips": [
+ {
+ "if": "space.nameSet",
+ "label": "Keep “{space.name}”",
+ "spent": "Kept",
+ "goto": "email"
+ }
+ ],
+ "input": {
+ "kind": "text",
+ "required": true
+ },
+ "effect": {
+ "verb": "setGroupName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "on": {
+ "ok": {
+ "set": {
+ "space.name": "$answer"
+ },
+ "record": {
+ "label": "named this space “{vars.answer}”",
+ "value": "$answer"
+ },
+ "goto": "email"
+ },
+ "invalid": {
+ "say": "Hmm, that name didn't stick — it needs to be 1–60 characters. Try another?",
+ "retry": "step"
+ },
+ "error": {
+ "say": "Something went wrong saving that — it's not the name's fault. Give it another go in a moment.",
+ "retry": "step"
+ }
+ }
+ },
+ {
+ "id": "email",
+ "skipIf": [
+ "done"
+ ],
+ "bubbles": [
+ {
+ "text": "Your contact email is shared with members — useful if someone's locked out, or something needs a human. It's optional."
+ },
+ {
+ "text": "How can people here reach you outside the chat? Reply with an email address, or skip it.",
+ "hint": "Type a contact email"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Skip for now",
+ "spent": "Skipped",
+ "goto": "photo"
+ }
+ ],
+ "input": {
+ "kind": "text"
+ },
+ "effect": {
+ "verb": "setContactEmail",
+ "args": [
+ "$answer"
+ ]
+ },
+ "on": {
+ "ok": {
+ "record": {
+ "label": "noted a contact email",
+ "value": "$answer"
+ },
+ "goto": "photo"
+ },
+ "invalid": {
+ "ask": "That doesn't look like an email address — try again, or skip it."
+ },
+ "error": {
+ "ask": "Something went wrong saving that — try again in a moment, or skip it."
+ }
+ }
+ },
+ {
+ "id": "photo",
+ "skipIf": [
+ "done",
+ "native"
+ ],
+ "skipTo": "invites",
+ "bubbles": [
+ {
+ "text": "Want to give this space a face? The photo shows on the welcome page and when people join — you can always change it later."
+ },
+ {
+ "text": "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text.",
+ "attachHint": "Optional: type alt text for the photo"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Skip for now",
+ "spent": "Skipped",
+ "cancelInput": true,
+ "goto": "invites"
+ }
+ ],
+ "input": {
+ "kind": "photo",
+ "reject": {
+ "text": "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip."
+ }
+ },
+ "effect": {
+ "verb": "adoptServerPhoto"
+ },
+ "on": {
+ "ok": {
+ "set": {
+ "vars.photoAlt": "$answerText"
+ },
+ "goto": "photo-preview"
+ },
+ "error": {
+ "say": "Hmm, I couldn't use that one — try another?",
+ "ask": "Send another photo when you're ready, or skip it."
+ }
+ }
+ },
+ {
+ "id": "photo-preview",
+ "bubbles": [
+ {
+ "text": [
+ "Here's how it looks.",
+ {
+ "if": "vars.photoAlt",
+ "text": "\n\nIts alt text is the message you sent — “{vars.photoAlt}” — and like the photo itself it's PUBLIC: anyone with an invite link can see both."
+ },
+ "\n\nHappy with it, or want to try another?"
+ ],
+ "img": "$serverAvatar",
+ "imgAlt": "{vars.photoAlt|The space photo}",
+ "local": true
+ }
+ ],
+ "chips": [
+ {
+ "label": "Keep this photo",
+ "spent": "Kept",
+ "record": {
+ "step": "photo",
+ "label": "set the space photo",
+ "value": "{vars.photoAlt}"
+ },
+ "goto": "invites"
+ },
+ {
+ "label": "Use a different photo",
+ "gotoAsk": {
+ "step": "photo",
+ "text": "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like alt text. 📷"
+ }
+ }
+ ]
+ },
+ {
+ "id": "invites",
+ "skipIf": [
+ "done"
+ ],
+ "bubbles": [
+ {
+ "text": "Who gets to bring people in? You can always invite people. The question is whether members can too, or whether new faces only arrive through you."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Members can invite people",
+ "effect": {
+ "verb": "setUserInvites",
+ "args": [
+ true
+ ]
+ },
+ "record": {
+ "label": "decided members can invite people",
+ "value": "members"
+ },
+ "goto": "keys"
+ },
+ {
+ "label": "Only me",
+ "effect": {
+ "verb": "setUserInvites",
+ "args": [
+ false
+ ]
+ },
+ "record": {
+ "label": "decided only the admin invites people",
+ "value": "admin only"
+ },
+ "goto": "keys"
+ }
+ ]
+ },
+ {
+ "id": "keys",
+ "entries": [
+ {
+ "if": "native",
+ "set": {
+ "vars.backedUp": true
+ },
+ "recordIfNew": {
+ "label": "Your space's key is saved with your Eleven account",
+ "value": "native"
+ },
+ "goto": "finish"
+ },
+ {
+ "if": "bridge.enrolled",
+ "set": {
+ "vars.backedUp": true
+ },
+ "recordIfNew": {
+ "label": "Saved your credentials securely with a passkey",
+ "value": "passkey"
+ },
+ "sayIfNew": "Your key is safely backed up behind your passkey. ✅",
+ "goto": "finish"
+ },
+ {
+ "if": "bridge.fromHome",
+ "goto": "keys-home"
+ }
+ ],
+ "settle": {
+ "when": "bridge.enrolled"
+ },
+ "bubbles": [
+ {
+ "text": "Now the most important thing of all. 🔑\n\nEverything here is locked with a key that exists only on your devices. There's no password reset and no support desk — not even whoever runs the server can read your messages or recover that key. That's not a flaw; it's the whole point.\n\nBut it means: right now, this browser holds the only key to your space. If it's lost — cleared, broken, stolen — your space is gone with it, and nobody can get it back."
+ },
+ {
+ "text": "Sign in with Eleven and your key stays safe with your passkey — one tap, and you'll be brought straight back."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Sign in",
+ "advancing": false,
+ "effect": {
+ "verb": "savePasskey"
+ },
+ "probe": {
+ "afterMs": 1500,
+ "unless": "bridge.enrolled",
+ "goto": "keys-waiting"
+ }
+ },
+ {
+ "label": "Not now",
+ "spent": "Skipped",
+ "goto": "keys-confirm"
+ }
+ ]
+ },
+ {
+ "id": "keys-home",
+ "entries": [
+ {
+ "if": "bridge.enrolled",
+ "set": {
+ "vars.backedUp": true
+ },
+ "recordIfNew": {
+ "label": "Saved your credentials securely with a passkey",
+ "value": "passkey"
+ },
+ "sayIfNew": "Your key is safely backed up behind your passkey. ✅",
+ "goto": "finish"
+ }
+ ],
+ "settle": {
+ "when": "bridge.enrolled"
+ },
+ "bubbles": [
+ {
+ "text": "Last thing — the key. 🔑\n\nThis space is locked with a key that was just created here, in this browser. Your Eleven sign-in doesn't hold it yet — so if this browser is lost, the space goes with it."
+ },
+ {
+ "text": "Sign in and it's added to your passkey — one tap, and you'll be brought straight back."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Sign in",
+ "advancing": false,
+ "effect": {
+ "verb": "savePasskey"
+ },
+ "probe": {
+ "afterMs": 1500,
+ "unless": "bridge.enrolled",
+ "goto": "keys-waiting"
+ }
+ },
+ {
+ "label": "Not now",
+ "spent": "Skipped",
+ "goto": "keys-confirm"
+ }
+ ]
+ },
+ {
+ "id": "keys-waiting",
+ "bubbles": [
+ {
+ "text": "I'll wait here while you finish signing in. When you're done, tap Continue."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Continue",
+ "record": {
+ "step": "keys",
+ "label": {
+ "if": "bridge.enrolled",
+ "then": "Saved your credentials securely with a passkey",
+ "else": "Saved your credentials with a passkey (in your browser)"
+ },
+ "value": "passkey"
+ },
+ "set": {
+ "vars.backedUp": true
+ },
+ "goto": "finish"
+ },
+ {
+ "label": "It didn't work — skip for now",
+ "spent": "Skipped",
+ "record": {
+ "step": "keys",
+ "label": "Skipped the credentials backup for now",
+ "value": "skipped"
+ },
+ "set": {
+ "vars.backedUp": false
+ },
+ "goto": "finish"
+ }
+ ]
+ },
+ {
+ "id": "keys-confirm",
+ "bubbles": [
+ {
+ "text": "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone — permanently, for everyone, and no one can help. I'll remind you again in a day or so."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Go back and save it",
+ "advancing": false,
+ "effect": {
+ "verb": "savePasskey"
+ },
+ "probe": {
+ "afterMs": 1500,
+ "unless": "bridge.enrolled",
+ "goto": "keys-waiting"
+ }
+ },
+ {
+ "label": "I understand the risk",
+ "spent": "Understood",
+ "record": {
+ "step": "keys",
+ "label": "Skipped the credentials backup for now",
+ "value": "skipped"
+ },
+ "set": {
+ "vars.backedUp": false
+ },
+ "goto": "finish"
+ }
+ ]
+ },
+ {
+ "id": "finish",
+ "record": {
+ "label": "Your space is ready",
+ "value": "done"
+ },
+ "effects": [
+ {
+ "if": "!vars.backedUp",
+ "verb": "deferBackupReminders"
+ },
+ {
+ "verb": "markSetupComplete"
+ }
+ ],
+ "bubbles": [
+ {
+ "text": [
+ "That's it — {space.name|your space} is ready! 🎉\n\n",
+ "Everything we just set up lives in your Admin panel — invite links, the space's name and photo, who can invite. Type `/admin` anywhere to open it; it's yours alone as this space's admin.",
+ {
+ "if": "!vars.backedUp",
+ "text": "\n\nAnd do come back to that key backup soon."
+ }
+ ]
+ },
+ {
+ "text": "Where would you like to start?"
+ }
+ ],
+ "chips": [
+ {
+ "if": "!native",
+ "label": "Open admin",
+ "advancing": false,
+ "effect": {
+ "verb": "openAdmin"
+ }
+ },
+ {
+ "if": "!native",
+ "label": "Take the tour",
+ "advancing": false,
+ "effect": {
+ "verb": "startTour"
+ }
+ }
+ ],
+ "end": true
+ }
+ ]
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
new file mode 100644
index 0000000..a00381d
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -0,0 +1,362 @@
+{
+ "schema": 1,
+ "flow": "welcome",
+ "entry": "hello",
+ "resumedIf": {
+ "any": [
+ "done.access",
+ "done.name",
+ "bridge.myName"
+ ]
+ },
+ "resumeUnless": {
+ "all": [
+ "bridge.enrolled",
+ "!done.access"
+ ]
+ },
+ "resumeSay": "Welcome back — let's pick up where we left off.",
+ "steps": [
+ {
+ "id": "hello",
+ "skipIf": [
+ "resumed"
+ ],
+ "bubbles": [
+ {
+ "text": [
+ "Welcome! 👋 I'm Eleven, your guide here.\n\n",
+ {
+ "if": "inviterName",
+ "text": "{inviterName} invited you to {space.name|this space}"
+ },
+ {
+ "if": "!inviterName",
+ "text": "You've been invited to {space.name|this space}"
+ },
+ " — a private, invite-only chat space. It isn't a big platform with accounts and feeds: it's a small server ",
+ {
+ "if": "adminName",
+ "text": "run by {adminName}"
+ },
+ {
+ "if": "!adminName",
+ "text": "run by one of its members"
+ },
+ ", just for the people in it.\n\n",
+ "Everything here — messages, photos, even names — is locked with keys only members hold, so nobody outside a chat can ever read it. Not the server, and not whoever runs it."
+ ]
+ }
+ ],
+ "auto": "access"
+ },
+ {
+ "id": "access",
+ "entries": [
+ {
+ "if": "native",
+ "recordIfNew": {
+ "label": "Your access is already secured by your Eleven sign-in",
+ "value": "native"
+ },
+ "goto": "name"
+ },
+ {
+ "if": {
+ "any": [
+ "bridge.enrolled",
+ "done"
+ ]
+ },
+ "recordIfNew": {
+ "label": "Saved your access securely with a passkey",
+ "value": "passkey"
+ },
+ "sayIfNew": "Great — welcome back. 🔒 Now that your access is secure, let's continue.",
+ "goto": "name"
+ }
+ ],
+ "settle": {
+ "when": "bridge.enrolled"
+ },
+ "bubbles": [
+ {
+ "text": "Before anything else, let's make sure you can't lose your way in. 🔑\n\nYour access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\nShall we save it behind a passkey now? One tap — you'll be brought straight back and we'll carry on."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Save my access (recommended)",
+ "advancing": false,
+ "effect": {
+ "verb": "savePasskey"
+ },
+ "probe": {
+ "afterMs": 1500,
+ "unless": "bridge.enrolled",
+ "goto": "access-waiting"
+ }
+ },
+ {
+ "label": "Not now",
+ "spent": "Skipped",
+ "effect": {
+ "verb": "deferBackupReminders"
+ },
+ "record": {
+ "label": "Skipped the access backup for now",
+ "value": "skipped"
+ },
+ "ack": "No problem — I'll nudge you again in a day or so.",
+ "goto": "name"
+ }
+ ]
+ },
+ {
+ "id": "access-waiting",
+ "bubbles": [
+ {
+ "text": "I'll wait here while you finish saving the passkey. When you're done, tap Continue."
+ }
+ ],
+ "chips": [
+ {
+ "label": "Continue",
+ "record": {
+ "step": "access",
+ "label": {
+ "if": "bridge.enrolled",
+ "then": "Saved your access securely with a passkey",
+ "else": "Saved your access with a passkey (in your browser)"
+ },
+ "value": "passkey"
+ },
+ "ack": "Perfect — that's saved. 🔒",
+ "goto": "name"
+ },
+ {
+ "label": "It didn't work — skip for now",
+ "spent": "Skipped",
+ "effect": {
+ "verb": "deferBackupReminders"
+ },
+ "record": {
+ "step": "access",
+ "label": "Skipped the access backup for now",
+ "value": "skipped"
+ },
+ "ack": "No problem — I'll nudge you again in a day or so.",
+ "goto": "name"
+ }
+ ]
+ },
+ {
+ "id": "name",
+ "skipIf": [
+ "done",
+ "bridge.myName"
+ ],
+ "bubbles": [
+ {
+ "text": "{ack?}First things first — what should folks here call you? Reply with your name.",
+ "key": "q-name",
+ "hint": "Type your name"
+ }
+ ],
+ "chips": [
+ {
+ "if": "suggested",
+ "label": "I'm {suggested}",
+ "set": {
+ "vars.answer": "{suggested}"
+ },
+ "effect": {
+ "verb": "setMyName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "record": {
+ "label": "noted: you're {vars.answer}",
+ "value": "$answer"
+ },
+ "ack": "Lovely to meet you, {vars.answer}! 🤝",
+ "goto": "photo"
+ }
+ ],
+ "input": {
+ "kind": "text",
+ "required": true,
+ "maxLen": 32
+ },
+ "effect": {
+ "verb": "setMyName",
+ "args": [
+ "$answer"
+ ]
+ },
+ "on": {
+ "ok": {
+ "record": {
+ "label": "noted: you're {vars.answer}",
+ "value": "$answer"
+ },
+ "ack": "Lovely to meet you, {vars.answer}! 🤝",
+ "goto": "photo"
+ }
+ }
+ },
+ {
+ "id": "photo",
+ "skipIf": [
+ "done",
+ "native"
+ ],
+ "skipTo": "finish",
+ "bubbles": [
+ {
+ "text": "{ack?}Want to add a profile photo? Only members of this space can see it — like everything else here, it's encrypted.",
+ "key": "q-photo"
+ },
+ {
+ "text": "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. If you type a message with it, I'll keep that as the photo's description. Or skip for now.",
+ "attachHint": "Optional: describe your photo"
+ }
+ ],
+ "chips": [
+ {
+ "label": "Skip for now",
+ "spent": "Skipped",
+ "cancelInput": true,
+ "set": {
+ "vars.skippedPhoto": true
+ },
+ "goto": "finish"
+ }
+ ],
+ "input": {
+ "kind": "photo",
+ "reject": {
+ "text": "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip.",
+ "local": true
+ }
+ },
+ "effect": {
+ "verb": "adoptProfilePhoto"
+ },
+ "on": {
+ "ok": {
+ "goto": "photo-preview"
+ },
+ "nokey": {
+ "say": "One sec — I'm still finishing your secure setup. Try sending it again in a moment, or skip for now.",
+ "local": true,
+ "ask": "Ready when you are — send that photo again, or skip it."
+ },
+ "error": {
+ "say": "Hmm, I couldn't use that one — try another?",
+ "local": true,
+ "ask": "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷"
+ }
+ }
+ },
+ {
+ "id": "photo-preview",
+ "bubbles": [
+ {
+ "text": "Here's how it looks. Happy with it, or want to try another?",
+ "img": "$myAvatar",
+ "imgAlt": "Your profile photo",
+ "imgRound": true,
+ "local": true
+ }
+ ],
+ "chips": [
+ {
+ "label": "Keep this photo",
+ "spent": "Kept",
+ "record": {
+ "step": "photo",
+ "label": "set your profile photo"
+ },
+ "goto": "finish"
+ },
+ {
+ "label": "Use a different photo",
+ "gotoAsk": {
+ "step": "photo",
+ "text": "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷"
+ }
+ }
+ ]
+ },
+ {
+ "id": "finish",
+ "record": {
+ "label": "You're all set",
+ "value": "done"
+ },
+ "effects": [
+ {
+ "verb": "markWelcomeDone"
+ }
+ ],
+ "bubbles": [
+ {
+ "text": [
+ {
+ "if": "vars.skippedPhoto",
+ "text": "No problem — you can add one anytime from your profile.\n\n"
+ },
+ "That's it — you're all set. 🎉 Enjoy {space.name|this space}!",
+ {
+ "if": "!accessBackedUp",
+ "text": "\n\nAnd do come back to that access backup soon."
+ }
+ ],
+ "key": "q-finish"
+ },
+ {
+ "text": "Where would you like to start?"
+ }
+ ],
+ "chips": [
+ {
+ "if": {
+ "all": [
+ "hasPendingThread",
+ "inviterName"
+ ]
+ },
+ "label": "Say hi to {inviterName}",
+ "advancing": false,
+ "effect": {
+ "verb": "openPendingThread"
+ }
+ },
+ {
+ "if": {
+ "all": [
+ "hasPendingThread",
+ "!inviterName"
+ ]
+ },
+ "label": "Open your first chat",
+ "advancing": false,
+ "effect": {
+ "verb": "openPendingThread"
+ }
+ },
+ {
+ "if": "!native",
+ "label": "Take the tour",
+ "advancing": false,
+ "effect": {
+ "verb": "startTour"
+ }
+ }
+ ],
+ "end": true
+ }
+ ]
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Icons/icon-192.png b/ios/LChatCore/Sources/LChatCore/Icons/icon-192.png
new file mode 100644
index 0000000..3917fe1
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Icons/icon-192.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 66e1ee2..8da9b52 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -178,6 +178,47 @@ public struct LChatAPI {
return (resp as? HTTPURLResponse)?.statusCode == 200
}
+
+ /// Update the admin group settings, merging over the current trio (the
+ /// server's setGroup clears any field it isn't sent — the web's
+ /// saveGroupSettings does the same GET+merge). Admin: the device token's
+ /// user holds the role.
+ public func setGroupSettings(name: String? = nil, adminEmail: String? = nil) async -> Bool {
+ var current: [String: Any] = [:]
+ if let url = URL(string: "/api/admin/group", relativeTo: baseURL) {
+ var req = URLRequest(url: url)
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ if let (data, resp) = try? await session.data(for: req),
+ (resp as? HTTPURLResponse)?.statusCode == 200,
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ current = obj
+ }
+ }
+ let body: [String: Any] = [
+ "name": name ?? (current["name"] as? String ?? ""),
+ "admin_name": current["admin_name"] as? String ?? "",
+ "admin_email": adminEmail ?? (current["admin_email"] as? String ?? ""),
+ ]
+ return await postAdmin("/api/admin/group", body)
+ }
+
+ /// POST /api/admin/settings (user_invites, setup_complete, …).
+ public func setAdminSettings(_ fields: [String: Any]) async -> Bool {
+ await postAdmin("/api/admin/settings", fields)
+ }
+
+ private func postAdmin(_ path: String, _ body: [String: Any]) async -> Bool {
+ guard let url = URL(string: path, relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ req.httpBody = data
+ guard let (_, resp) = try? await session.data(for: req) else { return false }
+ return (resp as? HTTPURLResponse)?.statusCode == 200
+ }
+
/// Pin/unpin a chat for this user — a per-user marker the server syncs across
/// the user's devices and floats to the top of their list.
public func setPinned(thread: String, pinned: Bool) async {
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index abfeeb4..77041b8 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -243,6 +243,9 @@ public struct AvatarSource: Equatable {
// (ChatThread, not Thread: an unqualified `Thread` from a library would collide
// with Foundation's in every importing file.)
public struct ChatThread: Identifiable, Equatable {
+ /// Eleven-the-guide's conversation threads (setup / welcome) — they wear
+ /// the brand icon (ElevenBrand.icon) instead of an app glyph.
+ public var isEleven: Bool { appId == "setup" || appId == "welcome" }
public let id: String
public let kind: String // "self" | "dm" | "group"
public var title: String // "Notes" / the other person / the group name
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
index f1fa749..4a8da25 100644
--- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
+++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
@@ -67,6 +67,12 @@ public enum SpaceCreationFlow {
let inst = try await SpaceClaim.claim(setupLink: creation.setup_link,
screenName: screenName)
await SpaceSetup.finish(origin: inst.origin, token: inst.deviceToken, name: "")
+ // Eleven's setup conversation: create its app-thread now and flag the
+ // landing, so the creator's first screen is that chat (SetupLanding).
+ // Best-effort — without it they land on the (empty) list, like today.
+ if await SpaceSetup.createSetupThread(inst) {
+ await SetupLanding.markPending(inst)
+ }
return inst
}
}
@@ -239,6 +245,29 @@ public enum SpaceSetup {
return ok
}
+ /// Create the "setup" app-thread — Eleven's admin setup conversation, the
+ /// thread the creator lands in (the web's resolveAppThread("setup"),
+ /// natively): a self thread whose encrypted name_blob is {app, title},
+ /// key wrapped only to us. Nil myUserId (roster lag) skips quietly.
+ static func createSetupThread(_ inst: SavedInstance) async -> Bool {
+ guard let base = inst.baseURL, !inst.myUserId.isEmpty else { return false }
+ let api = LChatAPI(baseURL: base, token: inst.deviceToken)
+ let userPub = inst.userPub
+ let built: (nameBlob: String, wrapped: String)? =
+ await Task.detached(priority: .userInitiated) {
+ let raw = ECIES.randomAESKey()
+ guard let key = try? GroupKey(rawKey: raw),
+ let nameBlob = try? key.encryptJSON(["app": "setup", "title": "Eleven"]),
+ let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub)
+ else { return nil }
+ return (nameBlob, wrapped)
+ }.value
+ guard let built else { return false }
+ return await api.createThread(kind: "self", nameBlob: built.nameBlob,
+ members: [["user_id": inst.myUserId,
+ "wrapped": built.wrapped]]) != nil
+ }
+
/// Up to three attempts with a short breath between — the call runs right
/// after the instance's first health check, when a hiccup is most likely.
private static func retrying(_ op: () async -> Bool) async -> Bool {
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 3be63e4..26abac0 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -549,6 +549,7 @@ public final class ThreadStore: ObservableObject {
"todo": "To-do", "remind": "Reminders", "poll": "Poll",
"bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat",
"connect": "Integrations", "dnsimple": "DNSimple", "github": "GitHub", "admin": "Admin",
+ "welcome": "Eleven", "setup": "Eleven", // the onboarding conversations — host-mounted, never in pickers
]
// Each app's emoji face — the sidebar/list avatar, mirroring the `glyph`
@@ -557,7 +558,7 @@ public final class ThreadStore: ObservableObject {
nonisolated private static let appGlyphs: [String: String] = [
"todo": "✓", "remind": "⏰", "poll": "📊",
"bookmarks": "🔖", "board": "🎨", "chat": "💬", "admin": "🛠️",
- "connect": "🔌", "dnsimple": "🌐", "github": "🐙",
+ "connect": "🔌", "dnsimple": "🌐", "github": "🐙", "welcome": "🕚", "setup": "🕚",
]
// A one-line "what you'd do here" blurb, shown as the sidebar subtitle for an
diff --git a/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift b/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift
new file mode 100644
index 0000000..f393b5c
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift
@@ -0,0 +1,91 @@
+import Foundation
+
+// 🤖 Landing a member in Eleven's welcome conversation, natively — the native
+// arm of the web's maybeStartWelcome. The conversation itself is the web's
+// client-local flow (welcome.js + flow.js), which the app-thread's embedded
+// webview runs; the native shell's only job is to OPEN that thread on first
+// entry into a space, and only while the conversation is unfinished.
+//
+// "Unfinished" is read from the conversation's own persisted state: the flow's
+// finish step records itself into the encrypted "apps" snapshot, which native
+// can decrypt with the thread key it already holds. The server sees ciphertext
+// throughout, as ever. Once per (device, instance): a UserDefaults flag stops
+// repeat landings — after that the thread is just another row in the list.
+@MainActor
+public enum WelcomeLanding {
+ static func flagKey(_ instance: SavedInstance) -> String { "welcomeLanded-" + instance.id }
+
+ /// The welcome thread to auto-open on this device, or nil (none exists,
+ /// already landed once, or the conversation is finished). Marks the flag
+ /// when it answers, so callers can just open what they're handed.
+ public static func threadToLand(instance: SavedInstance, threads: [ChatThread]) async -> ChatThread? {
+ guard let thread = threads.first(where: { $0.appId == "welcome" && !$0.threadKeyB64.isEmpty && !$0.hidden }) else {
+ return nil
+ }
+ let key = flagKey(instance)
+ guard !UserDefaults.standard.bool(forKey: key) else { return nil }
+ UserDefaults.standard.set(true, forKey: key)
+ return await conversationFinished(instance: instance, thread: thread, app: "welcome") ? nil : thread
+ }
+
+ // The flow's finish step records { steps: { finish: { done: true } } } into
+ // the app's state. No snapshot yet = a fresh conversation → land.
+ // (The snapshot can lag the log by a few seconds; worst case is landing in
+ // a just-finished conversation once — the embed itself re-checks the full
+ // log and never re-arms the flow.)
+ static func conversationFinished(instance: SavedInstance, thread: ChatThread, app: String) async -> Bool {
+ guard let base = instance.baseURL else { return false }
+ let api = LChatAPI(baseURL: base, token: instance.deviceToken)
+ guard let snap = await api.threadState(thread: thread.id, slot: "apps"), !snap.blob.isEmpty,
+ let gk = try? GroupKey(base64url: thread.threadKeyB64),
+ let data = try? gk.decryptData(blob: snap.blob),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
+ let states = json["states"] as? [String: Any]
+ let state = states?[app] as? [String: Any]
+ let steps = state?["steps"] as? [String: Any]
+ let finish = steps?["finish"] as? [String: Any]
+ return finish?["done"] as? Bool ?? false
+ }
+}
+
+// 🤖 Landing a space CREATOR in Eleven's admin setup conversation — the
+// creator-side sibling of WelcomeLanding, gated on an explicit pending flag
+// the create-space claim sets (never a scan): only a natively-created space
+// lands here, so an old admin's long-finished setup thread (from before the
+// finish step recorded a marker) is never resurfaced. The same flag arms the
+// conversation inside the thread's webview (embed.armSetup — see
+// FeedWebCache/AppThreadWebView), and both quietly stand down once the
+// flow's recorded finish marker appears.
+@MainActor
+public enum SetupLanding {
+ static func pendingKey(_ instance: SavedInstance) -> String { "setupPending-" + instance.id }
+ static func landedKey(_ instance: SavedInstance) -> String { "setupLanded-" + instance.id }
+
+ /// Flag a freshly-claimed space for the setup landing (SpaceCreationFlow).
+ public static func markPending(_ instance: SavedInstance) {
+ UserDefaults.standard.set(true, forKey: pendingKey(instance))
+ }
+
+ /// Whether this space's setup conversation should be armed in the thread's
+ /// embedded webview (embed.armSetup). Stays true after landing — reopening
+ /// the thread mid-conversation resumes it; the embed no-ops once finished.
+ public static func isPending(_ instance: SavedInstance) -> Bool {
+ UserDefaults.standard.bool(forKey: pendingKey(instance))
+ }
+
+ /// The setup thread to auto-open (once per device+instance), or nil.
+ public static func threadToLand(instance: SavedInstance, threads: [ChatThread]) async -> ChatThread? {
+ guard isPending(instance),
+ let thread = threads.first(where: { $0.appId == "setup" && !$0.threadKeyB64.isEmpty && !$0.hidden }) else {
+ return nil
+ }
+ let key = landedKey(instance)
+ guard !UserDefaults.standard.bool(forKey: key) else { return nil }
+ UserDefaults.standard.set(true, forKey: key)
+ if await WelcomeLanding.conversationFinished(instance: instance, thread: thread, app: "setup") {
+ UserDefaults.standard.removeObject(forKey: pendingKey(instance)) // done — disarm for good
+ return nil
+ }
+ return thread
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
new file mode 100644
index 0000000..e10d71c
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
@@ -0,0 +1,229 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The Swift half of the flow-interpreter drift gate: runs the REAL bundled
+// flow documents (Flows/*.json — byte-derived from *.flow.js, enforced by
+// test/flow.test.mjs) through FlowEngine with a scripted io, mirroring
+// test/flow.test.mjs's harness. The native runs here are the ones the app
+// ships: env.native skips every passkey step.
+@MainActor
+final class FlowEngineTests: XCTestCase {
+
+ // MARK: harness (the Swift twin of flow.test.mjs's harness())
+
+ final class Harness: FlowIO {
+ struct Line {
+ let text: String
+ let chips: [FlowChip]?
+ let extra: FlowSayExtra
+ }
+ var transcript: [Line] = []
+ var records: [(step: String, label: String, value: String)] = []
+ var verbLog: [(String, [FlowJSON])] = []
+ var doneSteps: [String: Bool] = [:]
+ var answerHandler: ((FlowAnswer) -> Void)?
+ var cancelled = 0
+ var pendingDelays: [(ms: Double, fn: () -> Void)] = []
+ var verbResults: [String: FlowJSON] = [:]
+ var ended = false
+
+ func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async {
+ transcript.append(Line(text: text, chips: chips, extra: extra))
+ }
+ func ask(_ handler: @escaping (FlowAnswer) -> Void) { answerHandler = handler }
+ func cancelAnswer() {
+ cancelled += 1
+ answerHandler = nil
+ }
+ func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON {
+ verbLog.append((name, args))
+ return verbResults[name] ?? .null
+ }
+ func record(step: String, label: String, value: String) async {
+ records.append((step, label, value))
+ doneSteps[step] = true
+ }
+ func done(_ step: String) -> Bool { doneSteps[step] ?? false }
+ func delay(ms: Double, _ fn: @escaping () -> Void) { pendingDelays.append((ms, fn)) }
+
+ var lastChips: [FlowChip]? {
+ transcript.reversed().first(where: { $0.chips != nil })?.chips
+ }
+ func tap(_ label: String) async {
+ guard let chip = lastChips?.first(where: { $0.label == label }) else {
+ return XCTFail("no chip \(label); have \(lastChips?.map(\.label) ?? [])")
+ }
+ chip.run()
+ await settle()
+ }
+ func answer(_ text: String, image: [String: String]? = nil) async {
+ guard let h = answerHandler else { return XCTFail("no answer waiter armed") }
+ answerHandler = nil
+ h(FlowAnswer(text: text, imageAttachment: image))
+ await settle()
+ }
+ // Let the engine's fire-and-forget Tasks drain (all MainActor).
+ func settle() async {
+ for _ in 0..<40 { await Task.yield() }
+ }
+ }
+
+ // The Flows resources live in the LChatCore module bundle (AppFlows reads
+ // Bundle.module from inside the library, which resolves correctly here).
+ func moduleFlow(_ name: String) throws -> FlowJSON {
+ try XCTUnwrap(AppFlows.flow(name), "bundled flow \(name).json")
+ }
+
+ func nativeEnv(name: String = "", space: String = "Test Space") -> FlowEnv {
+ let env = FlowEnv()
+ env.flags["native"] = .bool(true)
+ env.flags["suggested"] = .string("")
+ env.flags["inviterName"] = .string("")
+ env.flags["adminName"] = .string("")
+ env.flags["hasPendingThread"] = .bool(false)
+ env.space = ["name": .string(space), "nameSet": .bool(!space.isEmpty)]
+ env.live["bridge.myName"] = { .string(name) }
+ env.live["bridge.enrolled"] = { .bool(false) }
+ env.live["bridge.fromHome"] = { .bool(false) }
+ env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
+ return env
+ }
+
+ // MARK: template + condition mini-language (mirrors the JS test)
+
+ func testTemplateAndConditions() throws {
+ let env = FlowEnv()
+ env.vars["x"] = .string("hi")
+ env.space = ["name": .string("")]
+ env.ack = "A"
+ let engine = FlowEngine(flow: .object([:]), env: env, io: Harness())
+ XCTAssertEqual(engine.renderText(.string("{vars.x} {space.name|fallback}")), "hi fallback")
+ XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "A\n\nrest")
+ XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "rest", "ack is consumed")
+ XCTAssertEqual(engine.renderText(.array([.string("a"),
+ .object(["if": .string("vars.x"), "text": .string("-b")]),
+ .object(["if": .string("vars.missing"), "text": .string("-c")])])), "a-b")
+ XCTAssertEqual(engine.renderText(.object(["if": .string("vars.missing"), "then": .string("T"), "else": .string("E")])), "E")
+ XCTAssertTrue(engine.evalCond(.string("!vars.missing")))
+ }
+
+ // MARK: the native creator setup run (mirrors the JS native test)
+
+ func testNativeSetupRun() async throws {
+ let flow = try moduleFlow("setup")
+ let io = Harness()
+ io.verbResults["setGroupName"] = .string("ok")
+ let env = nativeEnv(name: "Paul")
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ // First run WITH a name: greeted (not "resumed"), name step skipped.
+ XCTAssertTrue(io.transcript.first?.text.hasPrefix("Hi — I'm Eleven! 👋") ?? false)
+ XCTAssertFalse(io.transcript.contains { $0.text.contains("Welcome back") })
+ XCTAssertFalse(io.transcript.contains { $0.text.contains("what should everyone here call you") })
+ XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" })
+
+ await io.answer("Team Awesome")
+ env.space["name"] = .string("Team Awesome") // the web bridge updates space; the harness mirrors it
+ await io.tap("Skip for now") // email; the photo step auto-skips under native
+ XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" })
+ await io.tap("Members can invite people")
+
+ // Keys: never pitched — recorded as covered by the Eleven account.
+ XCTAssertFalse(io.transcript.contains { $0.text.contains("Now the most important thing of all") })
+ XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" })
+ let keys = io.records.first { $0.step == "keys" }
+ XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account")
+ XCTAssertEqual(keys?.value, "native")
+
+ // Finish: completion marker, setup marked complete, no backup nag, no
+ // cross-thread admin chip.
+ XCTAssertEqual(io.records.last?.step, "finish")
+ XCTAssertEqual(io.records.last?.value, "done")
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" })
+ XCTAssertFalse(io.verbLog.contains { $0.0 == "deferBackupReminders" })
+ XCTAssertFalse(io.transcript.contains { $0.text.contains("come back to that key backup") })
+ // No web-only chips natively (admin hop, tour).
+ let allChips = io.transcript.flatMap { $0.chips ?? [] }.map(\.label)
+ XCTAssertFalse(allChips.contains("Take the tour"))
+ XCTAssertFalse(allChips.contains("Open admin"))
+ }
+
+ // MARK: the native joiner welcome run
+
+ func testNativeWelcomeRun() async throws {
+ let flow = try moduleFlow("welcome")
+ let io = Harness()
+ let env = nativeEnv()
+ env.flags["inviterName"] = .string("Ciara")
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ // Greeting names the inviter; the passkey ask never renders; access
+ // records the native reason; the name question is armed.
+ XCTAssertTrue(io.transcript.first?.text.contains("Ciara invited you to Test Space") ?? false)
+ XCTAssertFalse(io.transcript.contains { $0.text.contains("Shall we save it behind a passkey") })
+ let access = io.records.first
+ XCTAssertEqual(access?.step, "access")
+ XCTAssertEqual(access?.value, "native")
+ XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-name" })
+
+ await io.answer("Paul")
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" && $0.1.first == .string("Paul") })
+ // The photo step auto-skips under native — finish follows directly.
+ XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" })
+
+ // Finish: no backup nag (access is the sign-in), completion recorded.
+ let fin = io.transcript.first { $0.extra.key == "q-finish" }
+ XCTAssertNotNil(fin)
+ XCTAssertFalse(fin?.text.contains("access backup") ?? true)
+ XCTAssertEqual(io.records.last?.step, "finish")
+ XCTAssertEqual(io.records.last?.value, "done")
+ XCTAssertTrue(io.ended == false) // onEnd fires on env, not io — checked below
+ }
+
+ // MARK: resume semantics
+
+ func testResumeSkipsAnsweredSteps() async throws {
+ let flow = try moduleFlow("welcome")
+ let io = Harness()
+ io.doneSteps = ["access": true, "name": true]
+ let env = nativeEnv(name: "Paul")
+ var ended = false
+ env.onEnd = { ended = true }
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ // Resumed: the re-greeting, hello skipped; photo auto-skips under
+ // native, so the flow runs straight to finish and ends.
+ XCTAssertTrue(io.transcript.first?.text.hasPrefix("Welcome back") ?? false)
+ XCTAssertTrue(io.transcript.first?.extra.local ?? false)
+ XCTAssertFalse(io.transcript.contains { $0.text.hasPrefix("Welcome! 👋") })
+ XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" })
+ XCTAssertTrue(io.records.contains { $0.step == "finish" })
+ XCTAssertTrue(ended, "end step fires env.onEnd")
+ }
+
+ // MARK: a finished conversation never replays
+
+ func testFinishedConversationEntersAndEndsQuietly() async throws {
+ let flow = try moduleFlow("welcome")
+ let io = Harness()
+ io.doneSteps = ["access": true, "name": true, "photo": true, "finish": true]
+ let env = nativeEnv(name: "Paul")
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+ // Finish is already recorded: no new records, and the only lines are
+ // the resume re-greeting + finish bubbles (chips stay live).
+ XCTAssertFalse(io.records.contains { $0.step != "finish" })
+ XCTAssertEqual(io.records.count, 0)
+ }
+}
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift
new file mode 100644
index 0000000..fb75850
--- /dev/null
+++ b/ios/LChatMac/ElevenConversationPane.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+import LChatCore
+
+// 🤖 Mac host for Eleven's native onboarding conversation (setup / welcome):
+// the takeover pane NativeChatView routes these app-threads to. The finish
+// chips' thread hop selects the target in the sidebar.
+struct ElevenConversationPane: View {
+ let instance: SavedInstance
+ let thread: ChatThread
+ @ObservedObject var store: ThreadStore
+ @Binding var selectedID: String?
+ @State private var model: ElevenConversationModel?
+ @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
+ @AppStorage("nativeTheme") private var themeName = "classic"
+
+ var body: some View {
+ Group {
+ if let model {
+ ElevenChatView(model: model, theme: .named(themeName))
+ .environment(\.chatFontSize, CGFloat(ChatTextSize.snap(chatTextSize)))
+ } else {
+ ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .onAppear {
+ guard model == nil else { return }
+ let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
+ myUserId: store.myUserId)
+ m?.openThread = { id in selectedID = id }
+ m?.start(roster: store.roster, threads: store.threads)
+ model = m
+ }
+ }
+}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 4949fb3..8747bbb 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -126,6 +126,19 @@ struct NativeChatView: View {
)
.task {
await store.refresh()
+ // Creators land in Eleven's admin SETUP conversation, fresh
+ // members in the WELCOME one (the web's maybeStartSetup /
+ // maybeStartWelcome, natively) — once per device, and only while
+ // the conversation is unfinished; the thread's webview runs the
+ // same client-local script the web runs.
+ if selectedID == nil,
+ let setup = await SetupLanding.threadToLand(instance: instance, threads: store.threads) {
+ selectedID = setup.id
+ }
+ if selectedID == nil,
+ let welcome = await WelcomeLanding.threadToLand(instance: instance, threads: store.threads) {
+ selectedID = welcome.id
+ }
if selectedID == nil {
selectedID = store.threads.first(where: { !$0.hidden })?.id
}
@@ -274,7 +287,7 @@ struct NativeChatView: View {
selectedID = thread.id
store.markRead(thread)
} label: {
- ThreadAvatar(title: thread.title, source: thread.avatar, glyph: thread.glyph)
+ ThreadAvatar(title: thread.title, source: thread.avatar, photo: thread.isEleven ? ElevenBrand.icon : nil, glyph: thread.glyph)
.overlay(alignment: .topTrailing) {
if thread.unread {
Circle().fill(theme.accent.resolved(scheme))
@@ -476,7 +489,7 @@ struct NativeChatView: View {
private func feedHeader(_ t: ChatThread) -> some View {
HStack(spacing: 10) {
- ThreadAvatar(title: t.title, source: t.avatar, size: 34, glyph: t.glyph,
+ ThreadAvatar(title: t.title, source: t.avatar, size: 34, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph,
onTap: t.glyph == nil ? openAvatarLightbox(named: t.title) : nil)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 5) {
@@ -669,6 +682,13 @@ struct NativeChatView: View {
@ViewBuilder private var feed: some View {
if let thread = selectedThread, isCompanion(thread) {
companionSplit(thread)
+ } else if let thread = selectedThread,
+ thread.appId == "setup" || thread.appId == "welcome" {
+ // Eleven's onboarding conversations run FULLY native — the
+ // bundled flow interpreter + a SwiftUI chat, no webview.
+ ElevenConversationPane(instance: instance, thread: thread, store: store,
+ selectedID: $selectedID)
+ .id(thread.id + "|" + thread.threadKeyB64 + "|eleven")
} else if let thread = selectedThread, thread.isApp {
// Takeover mini-apps get the app's own web interface, full pane —
// never the raw native event-log feed.
@@ -867,7 +887,7 @@ struct NativeChatView: View {
.padding(EdgeInsets(top: 8, leading: 12, bottom: 4, trailing: 12))
ForEach(Array(convoMatches.enumerated()), id: \.element.id) { i, t in
searchRow(index: i) {
- ThreadAvatar(title: t.title, source: t.avatar, size: 24, glyph: t.glyph)
+ ThreadAvatar(title: t.title, source: t.avatar, size: 24, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
Text(t.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
}
}
@@ -1026,7 +1046,7 @@ struct NativeChatView: View {
store.reopenIfHidden(t) // opening a closed chat from search reopens it
} label: {
HStack(spacing: 10) {
- ThreadAvatar(title: t.title, source: t.avatar, glyph: t.glyph)
+ ThreadAvatar(title: t.title, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(t.title)
@@ -1080,6 +1100,8 @@ struct NativeChatView: View {
let title: String
let source: AvatarSource?
var size: CGFloat = 40
+ /// A ready image that wins over everything (Eleven's brand icon).
+ var photo: PlatformImage? = nil
/// A mini-app's emoji face — overrides photo/initials (apps have no
/// member photo; a self-thread would show YOUR face otherwise).
var glyph: String? = nil
@@ -1092,7 +1114,9 @@ struct NativeChatView: View {
var body: some View {
ZStack {
- if let glyph {
+ if let photo {
+ Image(platform: photo).resizable().scaledToFill()
+ } else if let glyph {
Circle().fill(.primary.opacity(0.08))
Text(glyph).font(.system(size: size * 0.45))
} else if let image {
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index fae2d68..2b46990 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -169,7 +169,8 @@ await test("setup: the full first-run transcript, happy path with detours", asyn
// They finish the ceremony elsewhere; Continue records the verified label.
h.enrolled = true;
await h.tap("Continue");
- assert.deepEqual(h.records.at(-1), { step: "keys", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.deepEqual(h.records.find((r) => r.step === "keys"), { step: "keys", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.deepEqual(h.records.at(-1), { step: "finish", label: "Your space is ready", value: "done" }, "finish persists its completion marker");
// Finish: complete marked, no reminder deferral (backed up), no nag line.
assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"));
@@ -182,7 +183,9 @@ await test("setup: the full first-run transcript, happy path with detours", asyn
});
await test("setup: resumed run — skips, Keep chip, skip-everything path", async () => {
- const h = harness({ myName: "Paul", verbs: { setContactEmail: () => "ok" } });
+ // Resumed = a recorded step (done.name), not merely a set name — a name
+ // alone is the native creator's FIRST run and still gets the greeting.
+ const h = harness({ myName: "Paul", done: { name: true }, verbs: { setContactEmail: () => "ok" } });
h.env.space = { name: "Old Name", nameSet: true };
runFlow(SETUP_FLOW, h.env, h.io);
await tick();
@@ -206,7 +209,8 @@ await test("setup: resumed run — skips, Keep chip, skip-everything path", asyn
await h.tap("Not now");
assert.match(h.lastText(), /I have to be straight with you/);
await h.tap("I understand the risk");
- assert.deepEqual(h.records.at(-1), { step: "keys", label: "Skipped the credentials backup for now", value: "skipped" });
+ assert.deepEqual(h.records.find((r) => r.step === "keys"), { step: "keys", label: "Skipped the credentials backup for now", value: "skipped" });
+ assert.deepEqual(h.records.at(-1), { step: "finish", label: "Your space is ready", value: "done" });
// Finish without a backup: reminders deferred, the nag line present.
assert.ok(h.verbLog.find((v) => v[0] === "deferBackupReminders"));
@@ -232,7 +236,8 @@ await test("setup: already enrolled — keys short-circuits with the once-only a
const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, keys: true } });
runFlow(SETUP_FLOW, h2.env, h2.io);
await tick();
- assert.equal(h2.records.length, 0);
+ // No duplicate keys record — only finish stamping its completion marker.
+ assert.deepEqual(h2.records, [{ step: "finish", label: "Your space is ready", value: "done" }]);
assert.equal(h2.transcript.some((t) => /safely backed up/.test(t.text)), false);
});
@@ -257,11 +262,48 @@ await test("setup: from-home key step + settle auto-detects the enrolment return
settle.fn();
await tick();
await tick();
- assert.deepEqual(h.records.at(-1), { step: "keys-home", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.deepEqual(h.records.find((r) => r.step === "keys-home"), { step: "keys-home", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.deepEqual(h.records.at(-1), { step: "finish", label: "Your space is ready", value: "done" });
assert.ok(h.transcript.find((t) => /safely backed up behind your passkey. ✅/.test(t.text)));
assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "reached finish without a tap");
});
+await test("setup: native creator — greeting shows, name pre-set, passkey never pitched, no admin hop", async () => {
+ // The native create-space landing: screen name typed in the create UI, the
+ // conversation runs in the app-thread's embedded webview with env.native.
+ const h = harness({ myName: "Paul", verbs: { setGroupName: () => "ok", setContactEmail: () => "ok" } });
+ h.env.native = true;
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+
+ // First run WITH a name: still greeted (not "resumed"), name step skipped.
+ assert.match(h.transcript[0].text, /^Hi — I'm Eleven! 👋/);
+ assert.equal(h.transcript.some((t) => /Welcome back/.test(t.text)), false);
+ assert.equal(h.transcript.some((t) => /what should everyone here call you/.test(t.text)), false);
+ assert.ok(h.transcript.find((t) => t.extra.key === "q-group"));
+
+ await h.answer("Team Awesome");
+ await h.tap("Skip for now"); // email; photo auto-skips under native
+ assert.equal(h.transcript.some((t) => t.extra.key === "q-photo"), false);
+ await h.tap("Members can invite people");
+
+ // Keys: never pitched — recorded as covered by the Eleven account.
+ assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false);
+ assert.equal(h.verbLog.some((v) => v[0] === "savePasskey"), false);
+ assert.deepEqual(h.records.find((r) => r.step === "keys"), { step: "keys", label: "Your space's key is saved with your Eleven account", value: "native" });
+
+ // Finish: completion marker recorded, complete marked, no backup nag or
+ // reminder deferral (the key IS backed up), and no web-only chips (admin
+ // hop, tour) — the finish bubble's /admin pointer covers it.
+ assert.deepEqual(h.records.at(-1), { step: "finish", label: "Your space is ready", value: "done" });
+ assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"));
+ assert.equal(h.verbLog.some((v) => v[0] === "deferBackupReminders"), false);
+ assert.equal(h.transcript.some((t) => /come back to that key backup/.test(t.text)), false);
+ const chipLabels = h.transcript.flatMap((t) => t.chips || []); // harness stores labels
+ assert.equal(chipLabels.includes("Take the tour"), false);
+ assert.equal(chipLabels.includes("Open admin"), false);
+});
+
await test("engine: template + condition mini-language", () => {
const env = { vars: { x: "hi" }, space: { name: "" }, __ack: "A" };
assert.equal(renderText(env, "{vars.x} {space.name|fallback}"), "hi fallback");
@@ -331,7 +373,8 @@ await test("welcome: invited fresh join — passkey probe, suggested name, photo
assert.equal(h.transcript.at(-1).extra.img, "IMG($myAvatar)");
assert.equal(h.transcript.at(-1).extra.imgRound, true);
await h.tap("Keep this photo");
- assert.deepEqual(h.records.at(-1), { step: "photo", label: "set your profile photo", value: "" });
+ assert.deepEqual(h.records.find((r) => r.step === "photo"), { step: "photo", label: "set your profile photo", value: "" });
+ assert.deepEqual(h.records.at(-1), { step: "finish", label: "You're all set", value: "done" }, "finish persists its completion marker");
// Finish: welcome marked done, no backup nag (enrolled), inviter chip first.
assert.ok(h.verbLog.find((v) => v[0] === "markWelcomeDone"));
@@ -370,6 +413,31 @@ await test("welcome: skip everything — nudge acks, photo skip disarms, nag on
assert.deepEqual(h.lastChips.map((c) => c.label), ["Take the tour"]);
});
+await test("welcome: native embed — passkey step skipped, access secured by the sign-in, finish records completion", async () => {
+ const h = harness({ envExtra: welcomeEnv({ native: true, inviterName: "Ciara" }) });
+ h.env.accessBackedUp = () => true; // native: secured by Sign in with Eleven
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+
+ // The passkey ask never renders; the access step records the native reason
+ // and the conversation goes straight from the greeting to the name.
+ assert.equal(h.transcript.some((t) => /Shall we save it behind a passkey/.test(t.text)), false);
+ assert.deepEqual(h.records[0], { step: "access", label: "Your access is already secured by your Eleven sign-in", value: "native" });
+ assert.ok(h.transcript.find((t) => t.extra.key === "q-name"));
+
+ // Run it to the end: finish persists its completion marker (what the native
+ // embed gates on — no localStorage marker there) and never nags about the
+ // access backup.
+ await h.answer("Paul");
+ // The photo step auto-skips under native (no attachment flow in the
+ // conversation composer yet) — finish follows the name directly.
+ const fin = h.transcript.find((t) => t.extra.key === "q-finish");
+ assert.ok(fin, "finish reached without a photo ask");
+ assert.equal(h.transcript.some((t) => t.extra.key === "q-photo"), false);
+ assert.equal(/access backup/.test(fin.text), false, "no backup nag — access is the sign-in");
+ assert.ok(h.records.find((r) => r.step === "finish" && r.value === "done"), "finish records the completion marker");
+});
+
await test("welcome: passkey-return leg — richer ack, no generic re-greet, question never re-asked", async () => {
const h = harness({ enrolled: true, envExtra: welcomeEnv() });
h.env.accessBackedUp = () => true;
@@ -394,3 +462,19 @@ await test("welcome: passkey-return leg — richer ack, no generic re-greet, que
assert.equal(h2.transcript.some((t) => /^Welcome! 👋/.test(t.text)), false, "hello step skipped on resume");
assert.ok(h2.transcript.find((t) => t.extra.key === "q-photo"), "resumes at the first undone step");
});
+
+// ---- native bundle drift gate ------------------------------------------------
+// The Swift interpreter (ios/.../FlowEngine.swift) runs byte-derived copies of
+// these flow documents (Flows/*.json). Regenerate after editing a flow:
+// node -e 'import("./web/static/apps/setup.flow.js").then(async m => { (await import("fs")).writeFileSync("ios/LChatCore/Sources/LChatCore/Flows/setup.json", JSON.stringify(m.default, null, 2) + "\n"); })'
+// (and the welcome twin).
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+const __dir = dirname(fileURLToPath(import.meta.url));
+await test("native flow bundles match the canonical flow documents", () => {
+ for (const [name, flow] of [["setup", SETUP_FLOW], ["welcome", WELCOME_FLOW]]) {
+ const bundled = JSON.parse(readFileSync(join(__dir, "..", "ios", "LChatCore", "Sources", "LChatCore", "Flows", `${name}.json`), "utf8"));
+ assert.deepEqual(bundled, flow, `${name}.json drifted — regenerate from ${name}.flow.js (see comment above)`);
+ }
+});
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js
index 75b3f27..a4b64ff 100644
--- a/web/static/apps/flow.js
+++ b/web/static/apps/flow.js
@@ -247,6 +247,13 @@ export function runFlow(flow, env, io) {
}
if ((step.skipIf || []).some((c) => evalCond(env, c))) return enter(step.skipTo || stepCtx.next);
+ // A step may persist itself on arrival (welcome's finish records its
+ // completion marker) — once only, so a host can tell a FINISHED
+ // conversation from a half-done one purely from recorded state.
+ if (step.record && !io.done(step.id)) {
+ await io.record(step.id, renderText(env, step.record.label), step.record.value != null ? step.record.value : "");
+ }
+
stepCtx.go = onceStep();
// Effects fire before the step speaks (markSetupComplete lands before the
// "you're all set" bubble, as the hand-written flow did).
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index c9f770e..bd96b47 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -6,20 +6,29 @@
export default {
schema: 1,
flow: "setup",
- entry: "name",
- resumedIf: "bridge.myName",
+ entry: "hello",
+ // Resumed = a step was actually recorded in an earlier session. (A name
+ // alone doesn't count: the native creator arrives with one pre-set from the
+ // create screen, and that first run must still get the greeting.)
+ resumedIf: { any: ["done.name", "done.group", "done.email", "done.photo", "done.invites", "done.keys"] },
resumeSay: "Welcome back — let's pick up where we left off.",
steps: [
{
- id: "name",
- skipIf: ["done", "bridge.myName"],
+ id: "hello",
+ skipIf: ["resumed"],
bubbles: [
{
- if: "!resumed",
text:
"Hi — I'm Eleven! 👋 This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" +
"Let's take two minutes to set things up.",
},
+ ],
+ auto: "name",
+ },
+ {
+ id: "name",
+ skipIf: ["done", "bridge.myName"],
+ bubbles: [
{ text: "First — what should everyone here call you? Reply with your name.", hint: "Type your name" },
],
input: { kind: "text", required: true, maxLen: 32 },
@@ -88,7 +97,9 @@ export default {
},
{
id: "photo",
- skipIf: ["done"],
+ // Native v1: no attachment flow in the conversation composer — the space
+ // photo lives in the Admin app there.
+ skipIf: ["done", "native"],
skipTo: "invites",
bubbles: [
{
@@ -172,6 +183,15 @@ export default {
{
id: "keys",
entries: [
+ {
+ // Native shells (iOS/Mac): the space was created from a signed-in
+ // Eleven account — its key is already saved to the creator's vault,
+ // so the whole passkey pitch is moot.
+ if: "native",
+ set: { "vars.backedUp": true },
+ recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" },
+ goto: "finish",
+ },
{
if: "bridge.enrolled",
set: { "vars.backedUp": true },
@@ -294,6 +314,11 @@ export default {
},
{
id: "finish",
+ // The completion marker: hosts without the server flag's context (the
+ // native embed gates on conversation state, not setup_complete — native
+ // sets that eagerly at claim as the reaper's not-abandoned signal) read
+ // this recorded step to know the conversation is over.
+ record: { label: "Your space is ready", value: "done" },
effects: [{ if: "!vars.backedUp", verb: "deferBackupReminders" }, { verb: "markSetupComplete" }],
bubbles: [
{
@@ -306,8 +331,11 @@ export default {
{ text: "Where would you like to start?" },
],
chips: [
- { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } },
- { label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
+ // The embed renders one thread — no cross-thread hop to the Admin
+ // app there; the bubble's /admin pointer covers it.
+ { if: "!native", label: "Open admin", advancing: false, effect: { verb: "openAdmin" } },
+ // The tour is the web's guided overlay — web-only for now.
+ { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
],
end: true,
},
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js
index c4f4f2c..cc499f9 100644
--- a/web/static/apps/welcome.flow.js
+++ b/web/static/apps/welcome.flow.js
@@ -38,6 +38,13 @@ export default {
{
id: "access",
entries: [
+ {
+ // Native shells (iOS/Mac): access is already secured by Sign in
+ // with Eleven — no browser key to lose, so the passkey ask is moot.
+ if: "native",
+ recordIfNew: { label: "Your access is already secured by your Eleven sign-in", value: "native" },
+ goto: "name",
+ },
{
if: { any: ["bridge.enrolled", "done"] },
recordIfNew: { label: "Saved your access securely with a passkey", value: "passkey" },
@@ -139,7 +146,9 @@ export default {
},
{
id: "photo",
- skipIf: ["done"],
+ // Native v1: no attachment flow in the conversation composer — the
+ // profile photo lives in your profile there.
+ skipIf: ["done", "native"],
skipTo: "finish",
bubbles: [
{
@@ -210,6 +219,10 @@ export default {
},
{
id: "finish",
+ // The completion marker: hosts without the web's localStorage flag (the
+ // native embed) read this recorded step to know the conversation is
+ // over and must not re-arm.
+ record: { label: "You're all set", value: "done" },
effects: [{ verb: "markWelcomeDone" }],
bubbles: [
{
@@ -235,7 +248,8 @@ export default {
advancing: false,
effect: { verb: "openPendingThread" },
},
- { label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
+ // The tour is the web's guided overlay — web-only for now.
+ { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
],
end: true,
},
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
index 1f72004..8dded43 100644
--- a/web/static/apps/welcome.js
+++ b/web/static/apps/welcome.js
@@ -102,10 +102,11 @@ export function startWelcome(env) {
env.bridge = { myName: () => setupBridge().myName(), enrolled: () => setupBridge().enrolled() };
env.hasPendingThread = !!env.openPendingThread;
// Backed-up is real state, not run-local memory: enrolled now, or the access
- // step recorded a passkey in an earlier session.
+ // step recorded a passkey in an earlier session — or "native", where access
+ // is secured by Sign in with Eleven and there's no browser key to back up.
env.accessBackedUp = () => {
const s = stepState(env.threadId, "access");
- return setupBridge().enrolled() || !!(s && s.value === "passkey");
+ return setupBridge().enrolled() || !!(s && (s.value === "passkey" || s.value === "native"));
};
env.onEnd = () => {
running.delete(env.threadId);
diff --git a/web/static/shell.js b/web/static/shell.js
index fe12ca8..584cbb3 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -84,13 +84,13 @@ import {
appWantsChatView,
appDefaultView,
appChatOnly,
+ appStateFor,
personalApps,
groupCapableApps,
groupAppsFor,
emitAppEvent,
messageActionsFor,
renderInline as renderAppLine,
- appStateFor,
setCommandPanelObserver,
closeCommandPanel,
} from "/static/apps.js";
@@ -809,8 +809,9 @@ async function bootEmbed() {
// 🤖 The native shell can ask for a specific view — its companion split
// renders chat NATIVELY and embeds this page purely as the app's
// interface, so without this the pane would default to the chat view and
- // show a second, redundant web chat beside the native one.
- if (embed.view && threadApp[tid]) appThreadView[tid] = embed.view;
+ // show a second, redundant web chat beside the native one. Chat-only apps
+ // (the welcome conversation) have no Interface — the chat view stands.
+ if (embed.view && threadApp[tid] && !appChatOnly(threadApp[tid])) appThreadView[tid] = embed.view;
// 🤖 A GROUP's shared app pane: the native shell names which added app to
// show (its own header switch renders chat natively, so this page is
// purely that app's interface). Assigned directly — the groupapps fold