// The joiner welcome conversation as DATA (docs/conversation-scripts.md) — // Eleven greeting a new member: passkey FIRST (so the ceremony's round-trip // lands mid-conversation, not on "all done"), then name, profile photo, and // the finish chips. The engine (flow.js) interprets it; welcome.js binds it to // the host bridge. JSON-pure (test-enforced): this is the document the server // will ship to native interpreters. export default { schema: 1, flow: "welcome", entry: "hello", resumedIf: { any: ["done.access", "done.name", "bridge.myName"] }, // The passkey RETURN leg gets its own richer ack in the access step's entry, // so it skips the generic re-greeting. resumeUnless: { all: ["bridge.enrolled", "!done.access"] }, resumeSay: "Welcome back — let's pick up where we left off.", steps: [ { id: "hello", skipIf: ["resumed"], // Assume nothing: say what this place IS, who runs it, and what the // encryption means for them — in lock-and-key terms, not jargon. 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: [ { // 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: "Signed in — your access is safe", value: "passkey" }, // Just back from the sign-in ceremony: acknowledge it worked, then // move on — a separate bubble, so the ack lands before the question. // Covers BOTH arrivals: the mid-conversation return leg AND a joiner // who signed up before the conversation started (signup-first join) — // so no "welcome back", which reads wrong when nobody left. sayIfNew: "Great — you're signed in, so your access is safe. 🔒 Let's continue.", goto: "name", }, // No home server to sign in with (self-hosted): the ask would be a // dead-end chip — record the honest state and carry on (#1377). { if: "!bridge.canEnrol", recordIfNew: { label: "Your access lives in this browser", value: "local" }, goto: "name", }, ], // The sign-in ceremony returns asynchronously (a redirect back, or tabbing // back from a system browser); auto-detect that return and carry on without // a manual "Continue" tap. settle: { when: "bridge.enrolled" }, // One bubble, chips attached — context and ask together, so the opening // run is two messages (intro + this), not three. The member-facing word // is "sign in" — never the mechanism (passkeys, backups). bubbles: [ { text: "Before anything else, let's make sure you can't lose your way in. 🔑\n\n" + "Your 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\n" + "Sign in with Eleven and your access is safe — one tap, your face or fingerprint, and you'll be brought straight back so we can carry on.", }, ], chips: [ { label: "Sign in (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 signing in for now", value: "skipped" }, ack: "No problem — I'll nudge you again in a day or so.", goto: "name", }, // The fast path for people in a hurry: one form with every question at // once, ending in the same sign-in. Lives on this step deliberately — // its settle (bridge.enrolled) is what carries the flow on after the // form's fast-filled records + sign-in hand-off. { label: "Fill in a form instead", trailing: true, advancing: false, effect: { verb: "openQuickForm" }, }, ], }, { id: "access-waiting", // The ceremony may confirm while we sit here (the completion poll flips // the flag) — re-enter access so its entry records + acks, no tap needed. settle: { when: "bridge.enrolled", goto: "access" }, bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }], chips: [ { label: "Continue", record: { step: "access", label: { if: "bridge.enrolled", then: "Signed in — your access is safe", else: "Signed in (in this browser)", }, value: "passkey", }, ack: "Perfect — you're all set. 🔒", goto: "name", }, { label: "It didn't work — skip for now", spent: "Skipped", effect: { verb: "deferBackupReminders" }, record: { step: "access", label: "Skipped signing in 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", label: "Your name:", hint: "e.g. Finn Wolfhard", }, ], 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, clean: "name" }, 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", // Native v1: no attachment flow in the conversation composer — the // profile photo lives in your profile there. 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 }, // Recorded so a resume (notably the passkey return leg) doesn't // re-ask a step the person already waved away. A skip is an answer. record: { label: "skipped the profile photo", value: "skipped" }, 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", // The preview is a client-only notice: the photo is an encrypted blob, // so its decrypted object URL can't ride a persisted event. 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", // 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: [ { 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}!", // Open groups: existence only, never names — a locked level's name // stays sealed by design, and the directory is one tap away. { if: "openGroups", text: "\n\nThis space also has open groups anyone here can join — have a look around whenever you like.", }, // The one honest parting warning for someone who chose "Not now": // plain consequences, no mechanism talk, and the way out right there. { if: { all: ["!accessBackedUp", "bridge.canEnrol"] }, text: "\n\nOne more reminder before you go: you haven't signed in yet, so your access to this space lives only in this browser — if it's cleared or lost, there's no way back in. Sign in any time and you're safe.", }, ], key: "q-finish", }, { text: "Where would you like to start?" }, ], chips: [ // The parting reminder's action — finish-line chips stay live, and the // completion poll picks the sign-in up even after the flow has ended. { if: { all: ["!accessBackedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, { 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: "openGroups", label: "Browse open groups", advancing: false, effect: { verb: "openDiscover" } }, // 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, }, ], };