eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
16import { 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.
20registerFloorCommand("/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).
31export 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.
73function 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.
85const advance = (go, label, fn) => ({ label, stale: () => go.taken, run: () => go(fn) });
86const side = (label, fn) => ({ label, run: fn });
87
88function say(env, text, actions) {
89 env.respond({ sender: env.sender, text, actions });
90}
91
92function 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.
108function 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
120function 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
135function 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
146function 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
157function 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
171function 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.
186function afterPrivacy(env) {
187 if (env.ai) stepAI(env);
188 else stepEnd(env);
189}
190
191function 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
207function 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