web: Open in the Eleven app — the "Continue on web" regret path (#1177)
The space menu (Mac desktop browsers only — the Mac app is the one scheme receiver today) gains "Open in the Eleven app": mints the same one-time sign-in link as Add a device and fires it over the app scheme; the app pre-fills its Add-from-link confirm and this browser never shows the secret. A page can't know whether anything caught the scheme, so the sheet says what should have happened and keeps a copyable fallback. appSchemeFrom moves to ui.js (pure, tested) and join.js shares it. Changelog held: the feature lights up when the native release carrying the Mac receiver (#1173) mints — that release's entry covers the pair.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+109
−17
test/ui.test.mjs+11 −0web/pages/index.html+3 −0web/static/adddevice.js+64 −5web/static/join.js+7 −11web/static/shell.js+11 −1web/static/ui.js+13 −0
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index adfd3af..68ee131 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -28,6 +28,7 @@ import { |
| TEXT_SIZES, |
| DEFAULT_TEXT_SIZE, |
| appReloadMessage, |
| + appSchemeFrom, |
| } from "../web/static/ui.js"; |
| let failures = 0; |
| @@ -436,5 +437,15 @@ await test("appReloadMessage names what actually changed, not a blanket 'new app |
| assert.equal(appReloadMessage(undefined), "Apps changed."); // older server, no kind sent |
| }); |
| +await test("appSchemeFrom: the shipped scheme and dev alts pass, junk falls back", () => { |
| + assert.equal(appSchemeFrom("elevenmessenger"), "elevenmessenger"); |
| + assert.equal(appSchemeFrom("elevenmessenger.alt-dek"), "elevenmessenger.alt-dek"); |
| + assert.equal(appSchemeFrom("elevenmessenger.alt-dev2"), "elevenmessenger.alt-dev2"); |
| + for (const junk of ["", "https", "evil", "elevenmessengerx", "elevenmessenger.alt-", |
| + "elevenmessenger.alt-UPPER", "elevenmessenger.alt-" + "x".repeat(33), null, undefined, 7]) { |
| + assert.equal(appSchemeFrom(junk), "elevenmessenger", `junk ${JSON.stringify(junk)} falls back`); |
| + } |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/pages/index.html b/web/pages/index.html| index df24149..c703d6a 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -150,6 +150,9 @@ |
| <button class="menu-item" id="menu-notif" role="menuitem">Notifications</button> |
| <button class="menu-item" id="menu-theme" role="menuitem">Theme</button> |
| <button class="menu-item" id="menu-add-device" role="menuitem">Add a device / back up</button> |
| + <!-- Mac desktop browsers only (shell.js unhides): the one |
| + platform with a scheme receiver today; iOS follows. --> |
| + <button class="menu-item" id="menu-open-app" role="menuitem" hidden>Open in the Eleven app</button> |
| <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button> |
| <button class="menu-item" id="menu-upgrade" role="menuitem" hidden>Upgrade</button> |
| <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button> |
diff --git a/web/static/adddevice.js b/web/static/adddevice.js| index 71d9333..6965fb1 100644 |
| --- a/web/static/adddevice.js |
| +++ b/web/static/adddevice.js |
| @@ -1,17 +1,22 @@ |
| // Add a device — mints a key-invite for this member and shows the sign-in |
| // link + QR (the fragment carries the one-time secret; the server never sees |
| -// it). Extracted verbatim from shell.js. |
| +// it). Extracted verbatim from shell.js. openInApp reuses the same mint to |
| +// hand this space straight to the installed app (issue #1089's handoff |
| +// cousin): an add-device link fired over the app scheme, claimed natively. |
| import { verbs } from "/static/shellverbs.js"; |
| import { session, brandingB } from "/static/shellstate.js"; |
| import { buildInviteFragment } from "/static/invite.js"; |
| import { deriveInvite, randomSecret, encrypt } from "/static/crypto.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| import { launchEnrol } from "/static/backup.js"; |
| +import { appSchemeFrom } from "/static/ui.js"; |
| // ---- add a device ------------------------------------------------------- |
| -export async function addDevice() { |
| - let link; |
| +// Mint a one-time sign-in link for THIS member: the new device gets this |
| +// user's keypair (wrapped under the fragment's secret — the server stores |
| +// only the wrapped blob and a claim hash). null = couldn't reach the server. |
| +async function mintDeviceLink() { |
| try { |
| const T = randomSecret(); |
| const { id, wrapKey, claimHash } = await deriveInvite(T); |
| @@ -24,10 +29,15 @@ export async function addDevice() { |
| }); |
| if (!res.ok) throw new Error("server"); |
| // Neutral slug; the name rides in the #fragment (never sent to the server). |
| - link = `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`; |
| + return `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`; |
| } catch { |
| - return alert("Couldn't create a device link. Try again."); |
| + return null; |
| } |
| +} |
| + |
| +export async function addDevice() { |
| + const link = await mintDeviceLink(); |
| + if (!link) return alert("Couldn't create a device link. Try again."); |
| const backdrop = verbs.modal(); |
| const card = backdrop.querySelector(".sheet"); |
| const h = document.createElement("h2"); |
| @@ -90,3 +100,52 @@ export async function addDevice() { |
| card.appendChild(box); |
| card.appendChild(row); |
| } |
| + |
| +// ---- open in the app ------------------------------------------------------ |
| + |
| +// The "Continue on web" regret path: hand this space to the installed app. |
| +// Same one-time link as addDevice, fired over the app scheme instead of shown |
| +// — the app pre-fills its Add-from-link confirm and this browser never shows |
| +// the secret. A page can't know whether anything caught the scheme, so the |
| +// sheet says what should have happened and keeps a copyable fallback. |
| +export async function openInApp() { |
| + const link = await mintDeviceLink(); |
| + if (!link) return alert("Couldn't create a sign-in link. Try again."); |
| + let scheme = "elevenmessenger"; |
| + try { |
| + scheme = appSchemeFrom(localStorage.getItem("lchat_app_scheme") || ""); |
| + } catch { /* no storage — the shipped app */ } |
| + // Assembled without a literal slash-pair: module-refs.test.mjs strips |
| + // comments before strings, so "//" inside a string desyncs its scan. |
| + location.href = scheme + ":" + "/".repeat(2) + "add?url=" + encodeURIComponent(link); |
| + |
| + const backdrop = verbs.modal(); |
| + const card = backdrop.querySelector(".sheet"); |
| + const h = document.createElement("h2"); |
| + h.textContent = "Handing over to the Eleven app"; |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.textContent = "The app opens with a one-time sign-in for this space — confirm it there and you're in as you. Nothing happened? You need the Eleven app (or its latest update); or copy the link below and paste it into the app's Add from link."; |
| + const copy = document.createElement("button"); |
| + copy.className = "secondary"; |
| + copy.textContent = "Copy the sign-in link"; |
| + copy.onclick = async () => { |
| + try { |
| + await navigator.clipboard.writeText(link); |
| + copy.textContent = "Link copied ✓"; |
| + } catch { |
| + copy.textContent = "Couldn't copy — use Add a device instead"; |
| + } |
| + setTimeout(() => (copy.textContent = "Copy the sign-in link"), 2000); |
| + }; |
| + const done = document.createElement("button"); |
| + done.textContent = "Done"; |
| + done.onclick = () => backdrop.remove(); |
| + const row = document.createElement("div"); |
| + row.className = "row"; |
| + row.appendChild(copy); |
| + row.appendChild(done); |
| + card.appendChild(h); |
| + card.appendChild(note); |
| + card.appendChild(row); |
| +} |
diff --git a/web/static/join.js b/web/static/join.js| index 4f5413b..ffd6494 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -14,24 +14,20 @@ import { |
| wrapThreadKey, |
| } from "/static/crypto.js"; |
| import { parseInviteFragment } from "/static/invite.js"; |
| +import { appSchemeFrom } from "/static/ui.js"; |
| const SESSION_KEY = "lchat_session"; |
| const card = document.getElementById("card"); |
| const loader = document.getElementById("app-loading"); |
| -// Which app scheme "Open in the app" fires. A page cannot DETECT installed |
| -// apps (deliberately — anti-fingerprinting), so the shipped scheme is the |
| -// only product answer; dev alts register suffixed schemes |
| -// (elevenmessenger.alt-<slug>, ios/project.yml) and a developer aims the |
| -// button at one with |
| -// localStorage.lchat_app_scheme = "elevenmessenger.alt-dek" |
| -// — validated to our prefix, anything else falls back to the shipped app. |
| +// Which app scheme "Open in the app" fires — ui.js's appSchemeFrom (the |
| +// dev-alt override rides localStorage.lchat_app_scheme; see that helper). |
| function appScheme() { |
| try { |
| - const s = localStorage.getItem("lchat_app_scheme") || ""; |
| - if (/^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(s)) return s; |
| - } catch { /* no storage — the shipped app */ } |
| - return "elevenmessenger"; |
| + return appSchemeFrom(localStorage.getItem("lchat_app_scheme") || ""); |
| + } catch { |
| + return "elevenmessenger"; // no storage — the shipped app |
| + } |
| } |
| // The ONE loading state: the same full-screen loader the app uses. It covers |
diff --git a/web/static/shell.js b/web/static/shell.js| index fda76d9..363039b 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -146,7 +146,7 @@ import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avata |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; |
| import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js"; |
| -import { addDevice } from "/static/adddevice.js"; |
| +import { addDevice, openInApp } from "/static/adddevice.js"; |
| import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal, billingDate, levelPrice, ensureTierKeys } from "/static/billingui.js"; |
| import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js"; |
| import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md) |
| @@ -2641,6 +2641,16 @@ function renderApp() { |
| else document.getElementById("menu-apps").style.display = "none"; |
| wireItem("menu-theme", showThemePicker); |
| wireItem("menu-add-device", addDevice); |
| + // Open in the Eleven app — the "Continue on web" regret path (an |
| + // add-device link fired over the app scheme; adddevice.js). Mac desktop |
| + // browsers only: the Mac app is the one scheme receiver today (iOS parity |
| + // tracked in the handoff plan), !isCoarse keeps iPads out (Safari there |
| + // reports MacIntel), and inside a native webview the item is noise. |
| + if (/Mac/i.test(navigator.platform || "") && !isCoarse |
| + && !document.documentElement.classList.contains("native")) { |
| + document.getElementById("menu-open-app").hidden = false; |
| + wireItem("menu-open-app", openInApp); |
| + } |
| wireItem("menu-rename", editProfile); |
| wireItem("menu-leave", leave); |
diff --git a/web/static/ui.js b/web/static/ui.js| index 29938ff..bce1233 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -44,6 +44,19 @@ export function normalizeTextSize(v) { |
| return TEXT_SIZES.includes(n) ? n : DEFAULT_TEXT_SIZE; |
| } |
| +// appSchemeFrom picks the custom scheme an app handoff fires. The shipped |
| +// app registers the plain scheme; dev alts suffix it |
| +// (elevenmessenger.alt-<slug> — ios/project.yml). A page cannot DETECT |
| +// installed apps (deliberately — anti-fingerprinting), so a developer aims |
| +// handoffs at an alt by storing the alt's scheme (callers read |
| +// localStorage.lchat_app_scheme); anything outside our prefix falls back to |
| +// the shipped app. Pure — callers own the storage read. |
| +export function appSchemeFrom(raw) { |
| + return typeof raw === "string" && /^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(raw) |
| + ? raw |
| + : "elevenmessenger"; |
| +} |
| + |
| // shouldSend decides whether a keydown in the composer should send the message. |
| // |
| // Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline. |