eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The join page (see docs/threads-and-dms.md). An invite link is /join/<label>#<T>
2// (optionally #<T>~<name> for an add-device link). The fragment is never sent to
3// the server. Joining sets up the user's keypair — generated fresh for a new
4// person, or unwrapped from the link for an added device — registers the public
5// key in the roster, claims a device token, and stores the session.
6import {
7 decrypt,
8 decryptBytes,
9 deriveBrandingKey,
10 b64urlToBytes,
11 generateUserKeypair,
12 deriveInvite,
13 bindMac,
14 wrapThreadKey,
15 bytesToB64url,
16} from "/static/crypto.js";
17import { parseInviteFragment } from "/static/invite.js";
18import { appSchemeFrom } from "/static/ui.js";
19import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js";
20import { INVITER_HINT_KEY, inviterHintStash } from "/static/invitehint.js";
21import { launchEnrol } from "/static/backup.js";
22
23const SESSION_KEY = "lchat_session";
24const card = document.getElementById("card");
25const loader = document.getElementById("app-loading");
26
27// Which app scheme "Open in the app" fires — ui.js's appSchemeFrom (the
28// dev-alt override rides localStorage.lchat_app_scheme; see that helper).
29function appScheme() {
30 try {
31 return appSchemeFrom(localStorage.getItem("lchat_app_scheme") || "");
32 } catch {
33 return "elevenmessenger"; // no storage — the shipped app
34 }
35}
36
37// The ONE loading state: the same full-screen loader the app uses. It covers
38// the page until a card has real content, and again while a claim is in
39// flight — the card and the loader are never shown together.
40function showLoader() {
41 if (loader) loader.classList.remove("hidden");
42 card.classList.add("hidden");
43}
44function showCard() {
45 if (loader) loader.classList.add("hidden");
46 card.classList.remove("hidden");
47}
48const isStandalone =
49 window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
50
51// The pre-join sign-in round trip returns here as /join/<label>?su=<nonce>
52// with NO fragment — the invite secret parked in this origin's localStorage
53// before leaving (it never travels to the home origin). Verify the nonce,
54// recover the fragment (restoring it to the URL so "Open in the app" still
55// hands over a complete link), and scrub the marker.
56const prejoin = (() => {
57 let stored = "";
58 try { stored = localStorage.getItem(PREJOIN_STASH_KEY) || ""; } catch {}
59 const r = readPrejoinReturn({ search: location.search, storedJSON: stored, now: Date.now() });
60 if (r.signedUp) {
61 try { localStorage.removeItem(PREJOIN_STASH_KEY); } catch {}
62 try {
63 if (!location.hash && r.frag) history.replaceState(null, "", location.pathname + "#" + r.frag);
64 } catch {}
65 }
66 return r;
67})();
68const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1) || prejoin.frag);
69
70// ?src=home marks a link opened from the person's Eleven sign-in page (the
71// create-a-space flow). Remember it on this origin so the setup conversation's
72// key step can say "add to your passkey" instead of pitching passkeys from
73// scratch. A hint only — nothing downstream trusts it for access.
74if (new URLSearchParams(location.search).get("src") === "home") {
75 try { localStorage.setItem("lchat_from_home", "1"); } catch { /* storage denied: the generic copy shows */ }
76}
77
78// Single-fire guard for the claim: whatever path calls it (button, double tap,
79// Enter), only the first attempt proceeds; the rest are ignored.
80let claimStarted = false;
81function onceOnly(fn) {
82 return (...a) => {
83 if (claimStarted) return;
84 claimStarted = true;
85 return fn(...a);
86 };
87}
88
89init();
90
91function init() {
92 // Already installed? The link in this URL is likely spent — go to the app,
93 // which signs in via paste-to-join.
94 if (isStandalone && !window.__lchatEmbed) return location.replace("/");
95 if (!T) return showError("This link is missing its secret.", { signin: true });
96 start();
97}
98
99// The claim secret derived from T (proves knowledge of the invite secret when
100// claiming/adopting). Set once in start(); read by finish()/adoptExisting().
101let inviteClaimSecret = "";
102// The key-transparency binding key derived from T (#573): the joiner MACs its
103// published public key under it so the inviter (a T-holder) can verify the
104// roster key wasn't substituted. Set in start(), read by finish().
105let inviteBindKey = "";
106// The inviter's display name from the sealed payload (#1436), stashed at claim
107// time so the app labels them before the instance key arrives. Set in start().
108let inviterFrom = "";
109
110// Whether this instance has a branding photo. Fetched in start() and shown on
111// the onboarding cards in place of the generic emoji. On a sealed instance
112// (docs/no-plaintext.md) the photo is ciphertext: serverPhotoURL becomes a
113// decrypted blob: URL when the link carries the branding key, else the photo
114// (and the readable name/description) simply doesn't show — the link still
115// claims fine. See docs/onboarding-and-home-server.md.
116let serverHasAvatar = false;
117let serverAvatarAlt = "";
118let serverPhotoURL = "/api/server-avatar";
119// The sealed branding name, when this link's fragment can open it — the lead
120// card's h1 was server-baked with the generic fallback and needs the real name.
121let brandingName = "";
122
123async function start() {
124 let id, wrapKey, info;
125 try {
126 const derived = await deriveInvite(T);
127 id = derived.id;
128 wrapKey = derived.wrapKey;
129 inviteClaimSecret = derived.claimSecret;
130 inviteBindKey = derived.bindKey;
131 info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json();
132 } catch {
133 return showError("Couldn't reach the server. Try again.");
134 }
135 if (!info.ok) {
136 // A dead link in an existing member's hands is just a stale bookmark —
137 // they're already in, so go to the chat instead of an error card telling
138 // them to ask for an invite they don't need. liveSession (not a raw
139 // localStorage read) so a token from a reset instance still falls through
140 // to the honest error. Embed keeps the error: the native add-chat webview
141 // owns navigation, and its shell expects a join, not a redirect.
142 const existing = window.__lchatEmbed ? null : await liveSession();
143 if (existing) return location.replace("/");
144 return showError("This invite has been used or is no longer valid. Ask for a new one.", { signin: true });
145 }
146
147 // The space's description rides on the invite lookup (holders of a valid link
148 // only — it's never on the public read), so the welcome card can say what
149 // this place is before the joiner holds any key.
150 let spaceDesc = typeof info.description === "string" ? info.description : "";
151 // A cohort invite names the access level the joiner lands in (plaintext by
152 // design, like the description — served to link-holders only).
153 cohortName = typeof info.tier_name === "string" ? info.tier_name : "";
154
155 // Note whether this instance has a branding photo (a public flag), so the
156 // onboarding cards below can show it instead of the placeholder emoji.
157 let groupInfo = {};
158 try {
159 groupInfo = await (await fetch("/api/group")).json();
160 serverHasAvatar = !!groupInfo.avatar;
161 serverAvatarAlt = groupInfo.avatar_alt || "";
162 } catch {}
163
164 // Sealed branding: this link's fragment carries the branding key, so open
165 // the blob and render the real name/description/photo in place of the
166 // defaults. A link without the key (minted before sealing) still claims —
167 // the card just stays generic. Decrypt failures are treated the same way.
168 if (info.branding_blob && info.branding_salt && fragB) {
169 try {
170 const bKey = await deriveBrandingKey(fragB, info.branding_salt);
171 const branding = await decrypt(bKey, info.branding_blob);
172 if (branding.name) {
173 brandingName = branding.name;
174 document.title = branding.name;
175 // The h1 was server-baked with the generic fallback (the server can't
176 // read the sealed name); the standard join card never re-sets it, so
177 // fill it here. The device/adopt/admin kinds overwrite it later.
178 const h1 = document.querySelector("#welcome-title");
179 if (h1) h1.textContent = `You're invited to ${branding.name}`;
180 }
181 if (branding.description) spaceDesc = branding.description;
182 serverAvatarAlt = branding.avatar_alt || serverAvatarAlt;
183 // Post-claim, the app bootstraps the branding key from here (it also
184 // has the instance-key-sealed copy as a fallback). Stash it before the
185 // photo work below: a failed photo decrypt must not cost the handoff.
186 try { localStorage.setItem("lchat_branding_b", fragB); } catch {}
187 if (serverHasAvatar && groupInfo.sealed) {
188 try {
189 const sealedBytes = await (await fetch("/api/server-avatar")).text();
190 const photo = await decryptBytes(bKey, b64urlToBytes(sealedBytes));
191 serverPhotoURL = URL.createObjectURL(new Blob([photo], { type: branding.avatar_mime || "image/jpeg" }));
192 } catch {
193 serverHasAvatar = false; // ciphertext isn't an image — show the placeholder
194 }
195 }
196 } catch { /* stay generic */ }
197 } else if (groupInfo.sealed && serverHasAvatar) {
198 // Sealed photo but no key in this link: the ciphertext isn't an image.
199 serverHasAvatar = false;
200 }
201
202 // Everything below only *decides* what continuing means for this link — the
203 // claim itself waits for an explicit choice on the welcome card. Claiming on
204 // page-load burned the one-time secret in whatever context rendered the link
205 // first (a hidden webview, a second tab) — see docs/onboarding-and-home-server.md.
206 let payload = null;
207 if (info.wrapped) {
208 try {
209 payload = await decrypt(wrapKey, info.wrapped);
210 } catch {
211 return showError("Couldn't unlock this link (wrong or corrupt secret).", { signin: true });
212 }
213 }
214
215 if (payload && payload.priv) {
216 // Add-device link: it carries this user's keypair (and name) — signing in
217 // is the only possible action, but it still waits for the click.
218 return showWelcome("device", () => {
219 showStatus("Signing you in…");
220 finish(id, fragName || "Me", payload.pub, payload.priv);
221 });
222 }
223 if (payload && payload.threadKey) {
224 // The inviter's display name rides the sealed payload (#1436): remember it
225 // so the app's first paint can label them before the instance key lands.
226 inviterFrom = typeof payload.from === "string" ? payload.from.trim().slice(0, 64) : "";
227 // User invite: it drops the joiner into a DM with the person who invited them.
228 // In the native embed the shell owns identities (Keychain) — never adopt a
229 // stray webview session; always run the fresh-person flow and hand back.
230 const existing = window.__lchatEmbed ? null : await liveSession();
231 if (existing && existing.token && existing.userPub && existing.userPriv) {
232 // Already a member on this device → join that DM as your existing self.
233 return showWelcome("adopt", () => {
234 showStatus("Opening your chat…");
235 adoptExisting(id, payload.threadKey, existing);
236 });
237 }
238 // New person: chat-first — claim nameless and let Eleven's welcome
239 // conversation ask the name in-chat (the inviter's suggestion rides along
240 // in the pending marker). The native add-chat webview keeps the form: it
241 // hands a NAMED identity straight back to the shell.
242 if (window.__lchatEmbed) {
243 return showWelcome(
244 "join",
245 () =>
246 showForm(fragName, async (name) => {
247 showStatus("Setting up…");
248 const kp = await generateUserKeypair();
249 const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
250 finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
251 }),
252 spaceDesc,
253 );
254 }
255 return showWelcome(
256 "join",
257 async () => {
258 showStatus("Setting up…");
259 const kp = await generateUserKeypair();
260 const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
261 markWelcomePending(fragName);
262 finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf);
263 },
264 spaceDesc,
265 );
266 }
267
268 // Setup invite (admin-granting): if already logged in, promote the existing
269 // account. Otherwise, create a new admin account.
270 if (info.grants_admin) {
271 const existing = window.__lchatEmbed ? null : await liveSession();
272 if (existing && existing.token && existing.userPub && existing.userPriv) {
273 // Already a member: promote to admin on existing account.
274 return showWelcome("adopt-admin", () => {
275 showStatus("Promoting to admin…");
276 adoptAdminExisting(id, existing);
277 });
278 }
279 // New person becoming admin: check if this instance's setup is already complete.
280 // If so, skip the "setup" flow label and just proceed normally — the app won't
281 // trigger setup conversation if setup_complete is true.
282 let setupNeeded = true;
283 try {
284 const me = await (await fetch("/api/me")).json();
285 setupNeeded = !me.setup_complete;
286 } catch {}
287 const setupLabel = setupNeeded ? "setup" : "join";
288 return showWelcome(setupLabel, async () => {
289 showStatus("Setting up…");
290 const kp = await generateUserKeypair();
291 finish(id, "", kp.publicKey, kp.privateKey);
292 });
293 }
294
295 // New person (or unknown payload shape): if already logged in, adopt this
296 // invite as your existing self. Otherwise, start a new account.
297 const existing = window.__lchatEmbed ? null : await liveSession();
298 if (existing && existing.token && existing.userPub && existing.userPriv) {
299 // Already a member: adopt this invite as your existing self (no wrapped key needed).
300 return showWelcome("adopt", () => {
301 showStatus("Opening your chat…");
302 adoptInviteExisting(id, existing);
303 });
304 }
305 // New person: same chat-first dance — the name is Eleven's first question,
306 // not a form (native add-chat keeps its form).
307 if (window.__lchatEmbed) {
308 return showWelcome(
309 "join",
310 () =>
311 showForm(fragName, async (name) => {
312 showStatus("Setting up…");
313 const kp = await generateUserKeypair();
314 finish(id, name, kp.publicKey, kp.privateKey);
315 }),
316 spaceDesc,
317 );
318 }
319 showWelcome(
320 "join",
321 async () => {
322 showStatus("Setting up…");
323 const kp = await generateUserKeypair();
324 markWelcomePending(fragName);
325 finish(id, "", kp.publicKey, kp.privateKey);
326 },
327 spaceDesc,
328 );
329}
330
331// The pending marker hands the joiner to Eleven's welcome conversation on the
332// app's first load (shell.js maybeStartWelcome), carrying the inviter's
333// suggested name. Local-only — the name suggestion never touches the server.
334// A new invite pasted into an already-open join tab changes only the URL
335// fragment — a same-document navigation, so nothing re-runs and the old
336// card (or error) just sits there. Reload to process the new link.
337addEventListener("hashchange", () => location.reload());
338
339function markWelcomePending(suggested) {
340 try {
341 localStorage.setItem("lchat_welcome_pending", JSON.stringify({ suggested: suggested || "" }));
342 } catch {}
343}
344
345// The welcome card: the invite is inspected (a non-consuming GET) but nothing is
346// claimed until a button is pressed. In the native add-chat flow (__lchatEmbed)
347// the person already chose explicitly in the app, so continue straight on.
348let cohortName = ""; // cohort invite: the level the joiner will be part of
349
350// `spaceDesc` is the space's admin-set description (from the invite lookup).
351function showWelcome(kind, onContinue, spaceDesc = "", opts = {}) {
352 if (window.__lchatEmbed) return onContinue();
353 // Signup-first: a fresh joiner leads with the sign-in door, not the app/web
354 // choice (skippable; established identities and native contexts never see it).
355 if (!opts.skipPrejoin && offerPrejoin({
356 kind,
357 homeOrigin: window.__homeOrigin || "",
358 embed: !!window.__lchatEmbed,
359 native: document.documentElement.classList.contains("native"),
360 signedUp: prejoin.signedUp,
361 })) {
362 return showPrejoin(kind, onContinue, spaceDesc);
363 }
364 card.classList.add("onboard");
365 card.classList.remove("status");
366 card.innerHTML = "";
367 showCard();
368 const tpl = document.getElementById("welcome-tpl");
369 card.appendChild(tpl.content.cloneNode(true));
370 applyServerPhoto(card);
371 if (spaceDesc) {
372 const d = card.querySelector("#welcome-desc");
373 d.textContent = spaceDesc.slice(0, 600);
374 d.classList.remove("hidden");
375 }
376 if (cohortName && kind !== "device") {
377 // The cohort line sits under the subtitle: arriving somewhere and being
378 // told which circle you're joining is half the welcome.
379 const c = document.createElement("p");
380 c.className = "sub";
381 c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
382 const sub = card.querySelector("#welcome-sub");
383 if (sub) sub.after(c);
384 }
385 // Back from the pre-join sign-in: the browser-only caveat is no longer true —
386 // their access rides their account (finish() saves the session to it).
387 if (prejoin.signedUp) {
388 const cav = card.querySelector("#welcome-caveat");
389 if (cav) cav.textContent =
390 "Your access will be saved to your Eleven account — you can get back in from any device.";
391 }
392 if (kind === "device") {
393 // document.title carries the substituted group name.
394 card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
395 card.querySelector("#welcome-sub").textContent = "This link signs this device in as you.";
396 card.querySelector("#welcome-caveat").remove();
397 card.querySelector("#welcome-web").textContent = "Sign in here in the browser";
398 } else if (kind === "adopt") {
399 const groupName = document.title.replace("You're invited to ", "");
400 card.querySelector("#welcome-title").textContent = `You're already in ${groupName}`;
401 card.querySelector("#welcome-sub").textContent =
402 "Continue to open the new conversation on this device.";
403 card.querySelector("#welcome-caveat").remove();
404 card.querySelector("#welcome-web").textContent = "Continue";
405 } else if (kind === "adopt-admin") {
406 card.querySelector("#welcome-title").textContent = "Become admin";
407 card.querySelector("#welcome-sub").textContent =
408 "You're already a member on this device. Continue to accept the admin role.";
409 card.querySelector("#welcome-caveat").remove();
410 card.querySelector("#welcome-web").textContent = "Accept";
411 } else if (kind === "setup") {
412 card.querySelector("#welcome-title").textContent = "Welcome to your new space";
413 card.querySelector("#welcome-sub").textContent =
414 "This one-time link makes you its administrator. Continue, and we'll set things up together.";
415 card.querySelector("#welcome-caveat").remove();
416 card.querySelector("#welcome-web").textContent = "Let's get started";
417 }
418 // "Open in the app" hands the full link — fragment included — to the installed
419 // app via its scheme; the scheme URL never reaches any server. There's no way
420 // to detect the app from here: if nothing claims the scheme this is a no-op
421 // and the other buttons stand. Inside the Mac app's own webview it's noise.
422 const appBtn = card.querySelector("#welcome-app");
423 if (document.documentElement.classList.contains("native")) {
424 appBtn.closest(".row").remove();
425 } else {
426 appBtn.onclick = () => {
427 location.href = appScheme() + "://add?url=" + encodeURIComponent(location.href);
428 };
429 }
430 // "Get the app" is per-instance config (LCHAT_APP_STORE_URL); hidden until set.
431 const storeURL = tpl.dataset.storeUrl || "";
432 const storeBtn = card.querySelector("#welcome-store");
433 if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener");
434 else storeBtn.closest(".row").remove();
435 const webBtn = card.querySelector("#welcome-web");
436 const go = onceOnly(onContinue);
437 webBtn.onclick = () => {
438 webBtn.disabled = true; // instant feedback — no dead-feeling first tap
439 go();
440 };
441}
442
443// The signup-first lead card: sign in with Eleven BEFORE anything is claimed.
444// The tap parks the invite fragment (and a fresh nonce) on THIS origin and
445// heads to the home server's doors; home returns to /join/<label>?su=<nonce>.
446// Skipping falls through to the classic welcome card — zero-friction stays.
447function showPrejoin(kind, onContinue, spaceDesc) {
448 card.classList.add("onboard");
449 card.classList.remove("status");
450 card.innerHTML = "";
451 showCard();
452 card.appendChild(document.getElementById("prejoin-tpl").content.cloneNode(true));
453 applyServerPhoto(card);
454 // The h1 was server-baked with the generic fallback on sealed instances.
455 if (brandingName) card.querySelector("#prejoin-title").textContent = `You're invited to ${brandingName}`;
456 if (kind === "setup") {
457 card.querySelector("#prejoin-title").textContent = "Welcome to your new space";
458 card.querySelector("#prejoin-sub").textContent =
459 "This one-time link makes you its administrator. Sign in first, and your access is saved from the start.";
460 }
461 if (spaceDesc) {
462 const d = card.querySelector("#prejoin-desc");
463 d.textContent = spaceDesc.slice(0, 600);
464 d.classList.remove("hidden");
465 }
466 if (cohortName) {
467 const c = document.createElement("p");
468 c.className = "sub";
469 c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
470 card.querySelector("#prejoin-sub").after(c);
471 }
472 card.querySelector("#prejoin-signin").onclick = () => {
473 const b = new Uint8Array(16);
474 crypto.getRandomValues(b);
475 const nonce = bytesToB64url(b);
476 try {
477 localStorage.setItem(PREJOIN_STASH_KEY, prejoinStash({ frag: location.hash.slice(1), nonce, now: Date.now() }));
478 } catch {
479 // No storage: the fragment can't survive the round trip — fall through to
480 // the classic card rather than strand the secret.
481 return showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
482 }
483 location.assign(homeSigninURL(window.__homeOrigin, location.origin, location.pathname, nonce));
484 };
485 card.querySelector("#prejoin-skip").onclick = () =>
486 showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
487}
488
489async function finish(id, name, pub, priv, wrappedSelf = "") {
490 let claim;
491 try {
492 claim = await (
493 await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
494 method: "POST",
495 headers: { "Content-Type": "application/json" },
496 // The screen name is never sent to the server — it's published encrypted
497 // (POST /api/label) once the app loads and has the instance key.
498 body: JSON.stringify({ wrapped: wrappedSelf, claim_secret: inviteClaimSecret }),
499 })
500 ).json();
501 } catch {
502 localStorage.removeItem("lchat_welcome_pending"); // the claim didn't take — drop the marker
503 return showError("Couldn't reach the server. Try again.");
504 }
505 if (!claim.ok || !claim.token) {
506 localStorage.removeItem("lchat_welcome_pending");
507 return showError("This invite was just used. Ask for a new one.");
508 }
509 // Key transparency (#573): MAC our public key under the T-derived bind key so
510 // the inviter can verify it's the key WE published. Computed OUTSIDE the
511 // publish try — a bind-MAC hiccup must never skip the load-bearing pubkey
512 // registration (that would leave a claimed-but-keyless broken join).
513 let bindMacVal = "";
514 try {
515 if (inviteBindKey) bindMacVal = await bindMac(inviteBindKey, pub);
516 } catch {}
517 try {
518 await fetch("/api/pubkey", {
519 method: "POST",
520 headers: { "Content-Type": "application/json" },
521 body: JSON.stringify({ token: claim.token, public_key: pub, bind_mac: bindMacVal }),
522 });
523 } catch {}
524 if (!window.__lchatEmbed) {
525 localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
526 // Land straight in the DM this invite created (the app opens it on boot).
527 if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
528 // First-paint label for the inviter (#1436) — provisional until their real
529 // label decrypts; shell.js applies and clears it.
530 if (claim.thread_id && inviterFrom) {
531 try {
532 localStorage.setItem(INVITER_HINT_KEY, inviterHintStash({ thread: claim.thread_id, name: inviterFrom, now: Date.now() }));
533 } catch {}
534 }
535 }
536 if (window.__lchatEmbed) {
537 // Native add-instance flow: hand the resolved identity back to the shell, which
538 // moves the private key into the Keychain. Don't navigate — the shell tears the
539 // one-shot join webview down once it has this.
540 try {
541 window.webkit.messageHandlers.lchat.postMessage({
542 joined: { token: claim.token, name, userPub: pub, userPriv: priv },
543 });
544 } catch {}
545 return;
546 }
547 // Signed up before joining: save this brand-new session to the account NOW,
548 // via the /enroll round trip. The home-tab session from the sign-in moments
549 // ago is still live, so enrolment completes hands-free (payload.auto) and
550 // lands back at "/" with the standard nonce marker — the joiner arrives in
551 // the chat already backed up, and Eleven never has to ask.
552 if (prejoin.signedUp && (window.__homeOrigin || "").startsWith("http")) {
553 return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true });
554 }
555 await plugIn();
556 location.href = "/"; // drop the fragment from the URL bar
557}
558
559// adoptExisting joins the invite's pending DM as the member already signed in on
560// this device (rather than creating a new person). We wrap the DM key to our
561// existing public key and let the server swap us in for the placeholder.
562async function adoptExisting(id, threadKeyB64, existing) {
563 try {
564 const wrapped = await wrapThreadKey(threadKeyB64, existing.userPub);
565 const r = await (
566 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
567 method: "POST",
568 headers: { "Content-Type": "application/json" },
569 body: JSON.stringify({ token: existing.token, wrapped, claim_secret: inviteClaimSecret }),
570 })
571 ).json();
572 if (!r.ok) {
573 return showError("This invite has been used or is no longer valid. Ask for a new one.");
574 }
575 if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
576 await plugIn();
577 location.href = "/";
578 } catch {
579 showError("Couldn't reach the server. Try again.");
580 }
581}
582
583// adoptAdminExisting claims an admin-granting invite on an existing account,
584// promoting the existing member to admin without creating a new user account.
585async function adoptAdminExisting(id, existing) {
586 try {
587 const r = await (
588 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
589 method: "POST",
590 headers: { "Content-Type": "application/json" },
591 body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
592 })
593 ).json();
594 if (!r.ok) {
595 return showError("This invite has been used or is no longer valid. Ask for a new one.");
596 }
597 // Token stays the same (existing session is preserved). Just redirect back to app.
598 await plugIn();
599 location.href = "/";
600 } catch {
601 showError("Couldn't reach the server. Try again.");
602 }
603}
604
605// adoptInviteExisting claims a generic invite (no wrapped payload) on an existing
606// account, letting them adopt it as their existing self without creating a new user.
607async function adoptInviteExisting(id, existing) {
608 try {
609 const r = await (
610 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
611 method: "POST",
612 headers: { "Content-Type": "application/json" },
613 body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
614 })
615 ).json();
616 if (!r.ok) {
617 return showError("This invite has been used or is no longer valid. Ask for a new one.");
618 }
619 if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
620 await plugIn();
621 location.href = "/";
622 } catch {
623 showError("Couldn't reach the server. Try again.");
624 }
625}
626
627// liveSession: loadSession, but verified against the server. A stored session
628// can outlive its account — an instance reset (or this device's removal)
629// leaves a token the server no longer knows, and trusting it routes a fresh
630// invite down the "you're already in" adopt path, which then dies with a
631// misleading "invite no longer valid". Drop the dead session and fall through
632// to the fresh-person flow ONLY on a definitive 403 — a network failure keeps
633// the session (dropping keys because wifi blipped would be catastrophic for a
634// real member), and the adopt path then fails loudly as before.
635async function liveSession() {
636 const existing = loadSession();
637 if (!existing || !existing.token || !existing.userPub || !existing.userPriv) return null;
638 try {
639 const r = await fetch(`/api/me?token=${encodeURIComponent(existing.token)}`);
640 if (r.status === 403) {
641 localStorage.removeItem(SESSION_KEY);
642 return null;
643 }
644 } catch {}
645 return existing;
646}
647
648function loadSession() {
649 try {
650 return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
651 } catch {
652 return null;
653 }
654}
655
656// ---- views --------------------------------------------------------------
657
658// Reveal the instance branding photo on a freshly-rendered onboarding card (and
659// hide the placeholder emoji) when one is set. The <img> src is our own
660// same-origin endpoint — never fragment or user input.
661function applyServerPhoto(root) {
662 if (!serverHasAvatar) return;
663 const img = root.querySelector(".onboard-photo");
664 if (!img) return;
665 img.src = serverPhotoURL; // same-origin endpoint, or a decrypted blob: URL when sealed
666 img.alt = serverAvatarAlt; // the admin's alt text travels with the photo
667 img.classList.remove("hidden");
668 const emoji = root.querySelector(".onboard-icon");
669 if (emoji) emoji.classList.add("hidden");
670}
671
672function showForm(defaultName, onSubmit) {
673 card.classList.remove("onboard", "status");
674 card.innerHTML = "";
675 card.appendChild(document.getElementById("form-tpl").content.cloneNode(true));
676 showCard();
677 applyServerPhoto(card);
678 const input = card.querySelector("#name");
679 if (defaultName) input.value = defaultName;
680 input.focus();
681 input.select();
682 card.querySelector("#join-form").onsubmit = (e) => {
683 e.preventDefault();
684 const name = input.value.trim();
685 if (name) onSubmit(name);
686 };
687}
688
689// In-flight states ("Setting up…", "Signing you in…") all render as the one
690// full-screen loader — the message argument is accepted for call-site clarity
691// but the loader speaks for itself.
692function showStatus(_msg) {
693 showLoader();
694}
695
696// The beat between "claimed" and the app: flash "Plugging in…" under the
697// spinner, fade it out, then navigate — the amp knob fades in on the far side.
698async function plugIn() {
699 if (!loader) return;
700 const msg = document.createElement("div");
701 msg.className = "plugging-in";
702 msg.textContent = "Plugging in…";
703 loader.appendChild(msg);
704 requestAnimationFrame(() => msg.classList.add("show"));
705 await new Promise((r) => setTimeout(r, 1000));
706 msg.classList.remove("show");
707 await new Promise((r) => setTimeout(r, 300));
708}
709
710function showError(msg, opts = {}) {
711 card.classList.remove("onboard", "status");
712 card.innerHTML = "";
713 card.appendChild(document.getElementById("error-tpl").content.cloneNode(true));
714 card.querySelector("#error-msg").textContent = msg;
715 // A dead invite can be a returning member's stale bookmark on a device whose
716 // session is gone — they don't need a new invite, they need the sign-in door
717 // (their account restores their spaces from home). Opt-in per call site so
718 // unrelated errors ("couldn't reach the server") don't grow a misleading CTA.
719 // The startsWith guard also covers a dev page served without substitution.
720 const home = window.__homeOrigin || "";
721 if (opts.signin && !window.__lchatEmbed && home.startsWith("http")) {
722 const btn = card.querySelector("#error-signin");
723 btn.classList.remove("hidden");
724 btn.onclick = () => { location.href = home; };
725 }
726 showCard();
727}
728
729