// /tutorial — Eleven's guided tour. A client-driven, in-Eleven's-voice onboarding // flow: a sequence of private "only visible to you" notices with tap-to-advance // chips, rendered right where you are. It's the interactive successor to the // static welcome DM (the welcome now offers a "Take the tour" chip that starts it). // // Why client-side rather than a bot conversation: the tour must work with NO AI // (it's the app basics) and even with no bot at all; it's stateful and interactive; // and two of its behaviours — advancing on a tap, ending with a flourish — are // inherently client concerns. Eleven still owns the voice (the notices are // attributed to it), and a member with conversational AI on can always just ask. // // Nothing here is sent to the server or to other members — notices are local and // ephemeral (see buildLocalNotice). The tour reads the group name + optional // administrator identity via spaceInfo(), and shows the AI section only when a // conversational helper is present. import { registerFloorCommand, defaultBot, openAppThread, spaceInfo, elevenTour } from "./apps.js"; // The tour is entered two ways — typing /tutorial, and the welcome DM's "Take the // tour" chip — so registration lives here and startTour is exported for the chip. registerFloorCommand("/tutorial", { desc: "Take a quick guided tour", run: (_args, _threadId, reply) => startTour(reply), }); // startTour(respond, typing, opts): respond(spec) appends one Eleven notice to // the current feed. Both entry points supply their own respond bound to the // thread you're in. opts.skipIntro jumps past the tour's own "Hi, fancy a tour?" // greeting straight into it — used when a conversation's "Take the tour" chip // already asked (the user has just been talking to Eleven; a fresh /tutorial // keeps the greeting). export function startTour(respond, typing, opts = {}) { const bot = defaultBot(); // With a typing hook (the setup/welcome conversations pass theirs), each tour // reply gets a short beat first — same voice, same rhythm as Eleven's script. let q = Promise.resolve(); const respondWithBeat = !typing ? respond : (spec) => { q = q.then(async () => { if (spec && spec.text) { typing(true); await new Promise((r) => setTimeout(r, Math.min(1400, 550 + (spec.text.length || 0) * 3))); typing(false); } respond(spec); }); return q; }; const env = { respond: respondWithBeat, // Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise // still speak as "Eleven" the guide — the tour is a client feature, not a bot. sender: bot || { name: "Eleven", kind: "bot" }, ai: !!bot && bot.kind === "ai", space: {}, }; // Fetch the space name + administrator identity, then open — but never block the // tour on it: if the read fails, we degrade to a nameless "this space". // skipIntro drops straight into the tour proper (in the Eleven DM, exactly as // the greeting's "Show me around" would have); otherwise it opens with the // greeting. spaceInfo() .then((info) => { env.space = info || {}; }) .catch(() => {}) .finally(() => (opts.skipIntro ? toElevenDm(env, () => stepSpace(env)) : stepWelcome(env))); } // A per-step guard: the first advancing tap wins, later taps are ignored. The // notice (and its chips) are rebuilt on every feed refresh, so without this a // second tap on a stale "Next" would append the same step twice. function onceStep() { const go = (fn) => { if (go.taken) return; go.taken = true; fn(); }; go.taken = false; return go; } // advance = a chip that moves the tour on (guarded); side = a chip that does // something without advancing (open an app), so it stays tappable if you return. const advance = (go, label, fn) => ({ label, stale: () => go.taken, run: () => go(fn) }); const side = (label, fn) => ({ label, run: fn }); function say(env, text, actions) { env.respond({ sender: env.sender, text, actions }); } function stepWelcome(env) { const go = onceStep(); say( env, "Hi — I'm Eleven, your guide to this app (yes, I'm named after it 🙂).\n\n" + "Eleven is a private group chat: every message is end-to-end encrypted, so only the people in a chat can read it — never the server, and never whoever runs it. " + "A few things you can do anytime: type `/help` to see the commands, `/theme` to change the colours, or just ask me a question.\n\n" + "Fancy a quick tour? I'll run it here in our chat.", [advance(go, "Show me around →", () => toElevenDm(env, () => stepSpace(env)))], ); } // The tour belongs in your DM with Eleven. "Show me around" moves it there (from // wherever it was started) and continues; from then on every step renders in that // chat, so you can wander off to try something and come back to it. If there's no // Eleven to DM, we just carry on in place. function toElevenDm(env, next) { elevenTour() .then((tour) => { if (tour) { env.respond = tour.notice; // render the rest of the tour into the Eleven DM env.ask = tour.ask; // and let the AI step ask Eleven a real question there } }) .catch(() => {}) .finally(next); } function stepSpace(env) { const go = onceStep(); const name = env.space.name || "this space"; let text = `You're in ${name}. `; if (env.space.adminName) { text += `It's looked after by ${env.space.adminName}`; text += env.space.adminEmail ? ` — you can reach them at ${env.space.adminEmail}.` : "."; } else { text += "It's privately run — no company in the middle, just the people here."; } text += "\n\nEvery space is its own separate, isolated world: its own members, its own keys, nothing shared with anywhere else."; say(env, text, [advance(go, "Next →", () => stepTodo(env))]); } function stepTodo(env) { const go = onceStep(); say( env, "Let me show you two handy built-ins.\n\n" + "First — To-do, your own private checklist. Go ahead and try it now: type `/todo buy milk` in any chat (or tap “Open To-do” below). It files into your own list — encrypted, and synced across your devices.\n\n" + "Don't worry about losing your place: our tour stays right here in this chat with me. When you're ready to come back, just come back to this conversation — or tap “Next” to carry on.", [side("Open To-do", () => openAppThread("todo")), advance(go, "Next →", () => stepRemind(env))], ); } function stepRemind(env) { const go = onceStep(); say( env, "And Reminders — for nudging yourself later. Try typing `/remind me to call mum in 10 minutes` (or `/remind standup at 9:00`). " + "It'll message you at the right moment, on every device — and even the reminder's text and time stay private to you.\n\n" + "Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.", [side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))], ); } function stepPrivacy(env) { const go = onceStep(); say( env, "Now the part that matters most: privacy.\n\n" + "Everything here — messages, photos, names, files — is end-to-end encrypted. The server only ever stores scrambled data it can't unlock, so even whoever runs this space can never read your chats.\n\n" + "Want the slightly more technical version?", [ side("Tell me about security", () => go(() => stepSecurity(env))), side("Sounds secure enough to me!", () => go(() => afterPrivacy(env))), ], ); } function stepSecurity(env) { const go = onceStep(); say( env, "Here's how it works:\n\n" + "• You have a personal key that never leaves your device.\n" + "• Every chat has its own key, locked to each member's key — so only its members can open it.\n" + "• Photos are shrunk and stripped of location data before they're encrypted and uploaded.\n" + "• Invite links carry a one-time secret in the part of the address your browser never sends to the server.\n\n" + "The one thing the server sees in the clear is a small preview card when someone posts a web link — a deliberate, single exception. Everything else, it simply can't read.", [advance(go, "Got it →", () => afterPrivacy(env))], ); } // After privacy, the AI section only appears where a conversational helper is on. function afterPrivacy(env) { if (env.ai) stepAI(env); else stepEnd(env); } function stepAI(env) { const go = onceStep(); const chips = []; // Where we can post as the member (the tour's in the Eleven DM), offer a chip // that actually asks Eleven — a real question, so the AI genuinely replies. if (env.ask) chips.push(side("Tell me about Eleven", () => env.ask("Tell me about Eleven — what makes this app different?"))); chips.push(advance(go, "End tutorial", () => stepEnd(env))); say( env, "One more thing — this space has AI helpers switched on. 🤖\n\n" + "Beyond the quick commands, you can actually chat with a helper like me: ask questions, think something through, get a hand. Try asking me anything about the app!\n\n" + "Soon you'll be able to give helpers skills, and even coordinate a few of them together — that part's just getting started. 👀", chips, ); } function stepEnd(env) { const name = env.space.name || "here"; say( env, `That's the tour! 🎉\n\n` + `You're all set. Type \`/help\` whenever you'd like a reminder of what I can do — or just say hi. Enjoy ${name}!`, // A gentle ending — no more chips to tap. ); }