eleven / messenger Public

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>
Paul Campbell 69c71a5a192c33beb649f2bf20f3b21384344d06 parent 3dec683
6 files changed, +109 −17
  • test/ui.test.mjs +11 −0
  • web/pages/index.html +3 −0
  • web/static/adddevice.js +64 −5
  • web/static/join.js +7 −11
  • web/static/shell.js +11 −1
  • web/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.