| 1 | // /tutorial — Eleven's guided tour. A client-driven, in-Eleven's-voice onboarding |
| 2 | // flow: a sequence of private "only visible to you" notices with tap-to-advance |
| 3 | // chips, rendered right where you are. It's the interactive successor to the |
| 4 | // static welcome DM (the welcome now offers a "Take the tour" chip that starts it). |
| 5 | // |
| 6 | // Why client-side rather than a bot conversation: the tour must work with NO AI |
| 7 | // (it's the app basics) and even with no bot at all; it's stateful and interactive; |
| 8 | // and two of its behaviours — advancing on a tap, ending with a flourish — are |
| 9 | // inherently client concerns. Eleven still owns the voice (the notices are |
| 10 | // attributed to it), and a member with conversational AI on can always just ask. |
| 11 | // |
| 12 | // Nothing here is sent to the server or to other members — notices are local and |
| 13 | // ephemeral (see buildLocalNotice). The tour reads the group name + optional |
| 14 | // administrator identity via spaceInfo(), and shows the AI section only when a |
| 15 | // conversational helper is present. |
| 16 | import { registerFloorCommand, defaultBot, openAppThread, spaceInfo, elevenTour } from "./apps.js"; |
| 17 | |
| 18 | // The tour is entered two ways — typing /tutorial, and the welcome DM's "Take the |
| 19 | // tour" chip — so registration lives here and startTour is exported for the chip. |
| 20 | registerFloorCommand("/tutorial", { |
| 21 | desc: "Take a quick guided tour", |
| 22 | run: (_args, _threadId, reply) => startTour(reply), |
| 23 | }); |
| 24 | |
| 25 | // startTour(respond, typing, opts): respond(spec) appends one Eleven notice to |
| 26 | // the current feed. Both entry points supply their own respond bound to the |
| 27 | // thread you're in. opts.skipIntro jumps past the tour's own "Hi, fancy a tour?" |
| 28 | // greeting straight into it — used when a conversation's "Take the tour" chip |
| 29 | // already asked (the user has just been talking to Eleven; a fresh /tutorial |
| 30 | // keeps the greeting). |
| 31 | export function startTour(respond, typing, opts = {}) { |
| 32 | const bot = defaultBot(); |
| 33 | // With a typing hook (the setup/welcome conversations pass theirs), each tour |
| 34 | // reply gets a short beat first — same voice, same rhythm as Eleven's script. |
| 35 | let q = Promise.resolve(); |
| 36 | const respondWithBeat = !typing |
| 37 | ? respond |
| 38 | : (spec) => { |
| 39 | q = q.then(async () => { |
| 40 | if (spec && spec.text) { |
| 41 | typing(true); |
| 42 | await new Promise((r) => setTimeout(r, Math.min(1400, 550 + (spec.text.length || 0) * 3))); |
| 43 | typing(false); |
| 44 | } |
| 45 | respond(spec); |
| 46 | }); |
| 47 | return q; |
| 48 | }; |
| 49 | const env = { |
| 50 | respond: respondWithBeat, |
| 51 | // Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise |
| 52 | // still speak as "Eleven" the guide — the tour is a client feature, not a bot. |
| 53 | sender: bot || { name: "Eleven", kind: "bot" }, |
| 54 | ai: !!bot && bot.kind === "ai", |
| 55 | space: {}, |
| 56 | }; |
| 57 | // Fetch the space name + administrator identity, then open — but never block the |
| 58 | // tour on it: if the read fails, we degrade to a nameless "this space". |
| 59 | // skipIntro drops straight into the tour proper (in the Eleven DM, exactly as |
| 60 | // the greeting's "Show me around" would have); otherwise it opens with the |
| 61 | // greeting. |
| 62 | spaceInfo() |
| 63 | .then((info) => { |
| 64 | env.space = info || {}; |
| 65 | }) |
| 66 | .catch(() => {}) |
| 67 | .finally(() => (opts.skipIntro ? toElevenDm(env, () => stepSpace(env)) : stepWelcome(env))); |
| 68 | } |
| 69 | |
| 70 | // A per-step guard: the first advancing tap wins, later taps are ignored. The |
| 71 | // notice (and its chips) are rebuilt on every feed refresh, so without this a |
| 72 | // second tap on a stale "Next" would append the same step twice. |
| 73 | function onceStep() { |
| 74 | const go = (fn) => { |
| 75 | if (go.taken) return; |
| 76 | go.taken = true; |
| 77 | fn(); |
| 78 | }; |
| 79 | go.taken = false; |
| 80 | return go; |
| 81 | } |
| 82 | |
| 83 | // advance = a chip that moves the tour on (guarded); side = a chip that does |
| 84 | // something without advancing (open an app), so it stays tappable if you return. |
| 85 | const advance = (go, label, fn) => ({ label, stale: () => go.taken, run: () => go(fn) }); |
| 86 | const side = (label, fn) => ({ label, run: fn }); |
| 87 | |
| 88 | function say(env, text, actions) { |
| 89 | env.respond({ sender: env.sender, text, actions }); |
| 90 | } |
| 91 | |
| 92 | function stepWelcome(env) { |
| 93 | const go = onceStep(); |
| 94 | say( |
| 95 | env, |
| 96 | "Hi — I'm Eleven, your guide to this app (yes, I'm named after it 🙂).\n\n" + |
| 97 | "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. " + |
| 98 | "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" + |
| 99 | "Fancy a quick tour? I'll run it here in our chat.", |
| 100 | [advance(go, "Show me around →", () => toElevenDm(env, () => stepSpace(env)))], |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | // The tour belongs in your DM with Eleven. "Show me around" moves it there (from |
| 105 | // wherever it was started) and continues; from then on every step renders in that |
| 106 | // chat, so you can wander off to try something and come back to it. If there's no |
| 107 | // Eleven to DM, we just carry on in place. |
| 108 | function toElevenDm(env, next) { |
| 109 | elevenTour() |
| 110 | .then((tour) => { |
| 111 | if (tour) { |
| 112 | env.respond = tour.notice; // render the rest of the tour into the Eleven DM |
| 113 | env.ask = tour.ask; // and let the AI step ask Eleven a real question there |
| 114 | } |
| 115 | }) |
| 116 | .catch(() => {}) |
| 117 | .finally(next); |
| 118 | } |
| 119 | |
| 120 | function stepSpace(env) { |
| 121 | const go = onceStep(); |
| 122 | const name = env.space.name || "this space"; |
| 123 | let text = `You're in ${name}. `; |
| 124 | if (env.space.adminName) { |
| 125 | text += `It's looked after by ${env.space.adminName}`; |
| 126 | text += env.space.adminEmail ? ` — you can reach them at ${env.space.adminEmail}.` : "."; |
| 127 | } else { |
| 128 | text += "It's privately run — no company in the middle, just the people here."; |
| 129 | } |
| 130 | text += |
| 131 | "\n\nEvery space is its own separate, isolated world: its own members, its own keys, nothing shared with anywhere else."; |
| 132 | say(env, text, [advance(go, "Next →", () => stepTodo(env))]); |
| 133 | } |
| 134 | |
| 135 | function stepTodo(env) { |
| 136 | const go = onceStep(); |
| 137 | say( |
| 138 | env, |
| 139 | "Let me show you two handy built-ins.\n\n" + |
| 140 | "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" + |
| 141 | "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.", |
| 142 | [side("Open To-do", () => openAppThread("todo")), advance(go, "Next →", () => stepRemind(env))], |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | function stepRemind(env) { |
| 147 | const go = onceStep(); |
| 148 | say( |
| 149 | env, |
| 150 | "And Reminders — for nudging yourself later. Try typing `/remind me to call mum in 10 minutes` (or `/remind standup at 9:00`). " + |
| 151 | "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" + |
| 152 | "Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.", |
| 153 | [side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))], |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | function stepPrivacy(env) { |
| 158 | const go = onceStep(); |
| 159 | say( |
| 160 | env, |
| 161 | "Now the part that matters most: privacy.\n\n" + |
| 162 | "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" + |
| 163 | "Want the slightly more technical version?", |
| 164 | [ |
| 165 | side("Tell me about security", () => go(() => stepSecurity(env))), |
| 166 | side("Sounds secure enough to me!", () => go(() => afterPrivacy(env))), |
| 167 | ], |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | function stepSecurity(env) { |
| 172 | const go = onceStep(); |
| 173 | say( |
| 174 | env, |
| 175 | "Here's how it works:\n\n" + |
| 176 | "• You have a personal key that never leaves your device.\n" + |
| 177 | "• Every chat has its own key, locked to each member's key — so only its members can open it.\n" + |
| 178 | "• Photos are shrunk and stripped of location data before they're encrypted and uploaded.\n" + |
| 179 | "• Invite links carry a one-time secret in the part of the address your browser never sends to the server.\n\n" + |
| 180 | "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.", |
| 181 | [advance(go, "Got it →", () => afterPrivacy(env))], |
| 182 | ); |
| 183 | } |
| 184 | |
| 185 | // After privacy, the AI section only appears where a conversational helper is on. |
| 186 | function afterPrivacy(env) { |
| 187 | if (env.ai) stepAI(env); |
| 188 | else stepEnd(env); |
| 189 | } |
| 190 | |
| 191 | function stepAI(env) { |
| 192 | const go = onceStep(); |
| 193 | const chips = []; |
| 194 | // Where we can post as the member (the tour's in the Eleven DM), offer a chip |
| 195 | // that actually asks Eleven — a real question, so the AI genuinely replies. |
| 196 | if (env.ask) chips.push(side("Tell me about Eleven", () => env.ask("Tell me about Eleven — what makes this app different?"))); |
| 197 | chips.push(advance(go, "End tutorial", () => stepEnd(env))); |
| 198 | say( |
| 199 | env, |
| 200 | "One more thing — this space has AI helpers switched on. 🤖\n\n" + |
| 201 | "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" + |
| 202 | "Soon you'll be able to give helpers skills, and even coordinate a few of them together — that part's just getting started. 👀", |
| 203 | chips, |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | function stepEnd(env) { |
| 208 | const name = env.space.name || "here"; |
| 209 | say( |
| 210 | env, |
| 211 | `That's the tour! 🎉\n\n` + |
| 212 | `You're all set. Type \`/help\` whenever you'd like a reminder of what I can do — or just say hi. Enjoy ${name}!`, |
| 213 | // A gentle ending — no more chips to tap. |
| 214 | ); |
| 215 | } |
| 216 | |