eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1// The multi-thread chat app (see docs/threads-and-dms.md). One instance holds
2// many threads — DMs, groups, and "Notes" (self). Each thread has its own AES
3// key, wrapped to each member's user public key; this browser unwraps with the
4// session's user private key. The server only ever relays opaque ciphertext.
5//
6// MVP scope: thread list + DMs/groups + text messaging + roster picker, with a
7// master-detail layout (desktop) and list↔thread navigation (mobile). Reactions,
8// attachments, edits, link cards, and push are deferred to a follow-up.
9import {
10 importKey,
11 encrypt,
12 decrypt,
13 encryptBytes,
14 decryptBytes,
15 generateKey,
16 generateUserKeypair,
17 wrapThreadKey,
18 unwrapThreadKey,
19 sealForPub,
20 openSealed,
21 pkcePair,
22 bytesToB64url,
23 b64urlToBytes,
24 randomSecret,
25 deriveInvite,
26 bindMac,
27 verifyBindMac,
28 mintBrandingKey,
29 deriveBrandingKey,
30} from "/static/crypto.js";
31import {
32 shouldSend,
33 fileAllowed,
34 fileMime,
35 fileIcon,
36 TEXT_SIZES,
37 composerToken,
38 pinnedFirst,
39 ownMessage,
40 sanitizeActions,
41 memberCountLabel,
42 onlineFilterKeeps,
43 chatCategory,
44 presenceLabel,
45 displayNameFor,
46 renameNotice,
47 translateMentions,
48 capAllows,
49 appReloadMessage,
50} from "/static/ui.js";
51import { richTokens, blockTokens, firstLink, replySnippet, reactionLine, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js";
52import { emojify, emojiMatches } from "/static/emoji.js";
53import { startBootScene, finishBootScene } from "/static/bootscene.js";
54import { processImage, videoMeta } from "/static/media.js";
55import { plainText as mdPlainText } from "/static/markdown.js";
56import { createIntegrity } from "/static/integrity.js";
57import {
58 registerFloorCommand,
59 setEmitTransport,
60 setScheduleTransport,
61 setEphemeralTransport,
62 setStateSaver,
63 setCurrentUser,
64 setAppThreadResolver,
65 setToast,
66 setDefaultBot,
67 setThreadHasBot,
68 setThreadIsGroup,
69 setThreadOwner,
70 setThreadOpener,
71 setHostBridge,
72 setSpaceInfo,
73 spaceInfo,
74 setElevenTour,
75 setSetupBridge,
76 setupBridge,
77 runCommand,
78 parseCommand,
79 commandCatalog,
80 buildLocalNotice,
81 isAppType,
82 foldEvent,
83 foldHostEvent,
84 optimisticFold,
85 deliverEphemeral,
86 appPreview,
87 appUnread,
88 resetThread,
89 seedThread as seedAppThread,
90 takeSnapshot,
91 notifyLive,
92 renderApp as renderMiniApp,
93 appByName,
94 appCatalogReport,
95 appIsQuiet,
96 appForType,
97 appWantsChatView,
98 appDefaultView,
99 appChatOnly,
100 appStateFor,
101 groupCapableApps,
102 groupAppsFor,
103 installGroupApp,
104 installSpaceApp,
105 spaceAppsFor,
106 setSpaceThread,
107 setSpaceThreadResolver,
108 setSpaceInstallPolicy,
109 setInstanceOpener,
110 mentionableApps,
111 emitAppEvent,
112 messageActionsFor,
113 renderInline as renderAppLine,
114 setCommandPanelObserver,
115 closeCommandPanel,
116 setMemoryStore,
117 appGlyphInto,
118 addCommandPanelObserver,
119 spaceRowTitle,
120} from "/static/apps.js";
121// Register the mini-apps (side-effect imports). Each is a single self-contained
122// file (JS + its own CSS). Personal apps get their own sidebar thread; shared
123// apps run inside a chat; system apps (the chat feed) are mounted explicitly.
124import "/static/apps/todo.js";
125import "/static/apps/remind.js";
126import "/static/apps/bookmarks.js";
127import "/static/apps/poll.js";
128import "/static/apps/vault.js";
129import "/static/apps/board.js";
130import "/static/apps/connect.js";
131import "/static/apps/dnsimple.js";
132import "/static/apps/stripe.js";
133import "/static/apps/hubspot.js";
134import "/static/apps/github.js";
135import "/static/apps/linear.js";
136import "/static/apps/webhook-monitor.js";
137import { openThreadView } from "/static/apps/views.js"; // views: bot-created data surfaces (docs/views.md); also registers the app
138import "/static/apps/emoji.js"; // /emoji — the command-panel reference app
139import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)
140import { startTour } from "/static/tutorial.js"; // also registers the /tutorial floor command
141import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app)
142import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app)
143import { planQuickForm } from "/static/quickform.js"; // "Fill in a form instead" — values → verbs+records
144import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip)
145import "/static/apps/webhooks.js"; // incoming-webhooks management (admin-only; /webhooks — docs/webhooks.md)
146import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md)
147import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md)
148import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf } from "/static/apps/chat.js";
149import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
150import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
151import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js";
152import { editGroup, showMembers, viewProfile, confirmRemoveFromSpace, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
153import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js";
154import { addDevice, openInApp } from "/static/adddevice.js";
155import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal, billingDate, levelPrice, ensureTierKeys } from "/static/billingui.js";
156import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js";
157import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
158import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
159import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
160import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
161import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS, PLACES_UI, threadApp, tiers, setTiers, tierKeys, tierKeyB64, pendingJoinThread, setPendingJoinThread, ACTIVE_KEY, MAX_UPLOAD, pinned, muted, sealedBranding, setSealedBranding, brandingB, setBrandingB } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
162
163import { openLightbox } from "/static/lightbox.js";
164import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js";
165import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
166import { qrCanvas } from "/static/qrcode.js";
167import { initBackupBanner, canRestore, autoRestoreRequested, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
168import {
169 needsHomeScreen,
170 pushAvailable,
171 pushEnabled,
172 pushDenied,
173 registerSW,
174 subscribePush,
175 unsubscribePush,
176 isSubscribed,
177 idbSet,
178} from "/static/push.js";
179// The local message store (#457): raw server responses cached in IndexedDB so
180// reloads and thread switches paint instantly (and, later, search works). See
181// store.js's header for the trust-boundary rationale (ciphertext only).
182import {
183 SNAP_VERSION,
184 snapshotUsable,
185 staleThreadIds,
186 getSnapshot as storeGetSnapshot,
187 putSnapshot as storePutSnapshot,
188 getRecentMessages as storeRecentMessages,
189 putMessages as storePutMessages,
190 applyEditRow as storeApplyEdit,
191 deleteMessageRow as storeDeleteMessage,
192 clearThread as storeClearThread,
193 cachedThreadIds as storeCachedThreadIds,
194 wipeStore,
195 allMessages as storeAllMessages,
196 getBackfillMark as storeBackfillMark,
197 runBackfill as storeRunBackfill,
198} from "/static/store.js";
199import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js";
200
201// Populate the behaviour seam: the core verbs extracted modules call through
202// (function declarations hoist, so these references are valid here).
203Object.assign(verbs, {
204 openThread, loadThreads, renderThreadList, renderActiveSurfaces,
205 paintThreadHeader, modal, toast, displayName, threadAvatarFor,
206 announceJoin, announceLeave, announceRemove, sendChatText,
207 loadBlobBytes, chatAppFor, chatAppLoadOlder, stashAppPane,
208 resolveRename, showList, paintHeaderAvatar,
209 loadTiers, setNickname, syncThreadKeysToSW,
210 inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed,
211 decryptRosterLabels, openThreadById, seedAppState, welcomePending, isAdmin,
212 removeFromSpace, banFromSpace,
213 cacheTiersRaw,
214 manageGroupApps, openSpaceApp,
215});
216
217onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
218wireSearch({ openThread, chatAppFor, chatAppLoadOlder, threadAvatar: threadRowAvatarEl }); // navigation + thread predicate + scroll-back paging + row avatars
219
220// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the
221// envelope's `ephemeral` flag asks the server to relay-and-forget — no store,
222// no push, no message id. Presence traffic only.
223setEphemeralTransport((threadId, event) => {
224 const key = threadKeys[threadId];
225 if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false);
226 const payload = { ...event, name: session.name, ts: Date.now() };
227 if (myUserId) payload.uid = myUserId;
228 return encrypt(key, payload).then((blob) => {
229 ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob, ephemeral: true }));
230 return true;
231 });
232});
233
234// A mute folds bubbles in the open thread; a block also re-badges the list.
235onModerationChange((threadsToo) => {
236 renderActiveSurfaces();
237 if (threadsToo) renderThreadList();
238});
239
240const SESSION_KEY = "lchat_session";
241const isCoarse = matchMedia("(pointer: coarse)").matches;
242// Video clips get a larger ceiling (Signal-style send-and-play). Matches the
243// server's default LCHAT_MAX_UPLOAD_BYTES; a lower per-instance cap just means
244// the server rejects the upload and the composer shows a "couldn't add" message.
245const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024;
246
247// ---- split view divider ----------------------------------------------------
248// Desktop-only Chat|Interface split (see the [data-app-view="split"] CSS):
249// #app-split-divider lets a member drag the boundary between #app-pane and
250// #interface-pane. The ratio is a local preference, same pattern as the theme
251// and text size — applied as a CSS custom property the split CSS reads.
252const SPLIT_RATIO_KEY = "lchat_split_ratio";
253const SPLIT_RATIO_MIN = 0.2;
254const SPLIT_RATIO_MAX = 0.8;
255const clampSplitRatio = (r) => Math.min(SPLIT_RATIO_MAX, Math.max(SPLIT_RATIO_MIN, r));
256const currentSplitRatio = () => {
257 const v = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY));
258 return Number.isFinite(v) ? clampSplitRatio(v) : 0.5;
259};
260function applySplitRatio(ratio) {
261 document.documentElement.style.setProperty("--split-ratio", String(ratio));
262 const divider = document.getElementById("app-split-divider");
263 if (divider) divider.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
264}
265function setSplitRatio(ratio) {
266 ratio = clampSplitRatio(ratio);
267 localStorage.setItem(SPLIT_RATIO_KEY, String(ratio));
268 applySplitRatio(ratio);
269}
270// #app-split-divider lives inside #app-tpl (cloned into the DOM by renderApp,
271// below), so it doesn't exist yet at module load — applySplitRatio's element
272// lookup is a no-op until renderApp calls wireSplitDivider().
273function wireSplitDivider() {
274 const divider = document.getElementById("app-split-divider");
275 const container = document.getElementById("app-split");
276 if (!divider || !container) return;
277 applySplitRatio(currentSplitRatio());
278 const step = 0.02;
279 let startX = 0;
280 let startRatio = 0.5;
281 divider.addEventListener("pointerdown", (e) => {
282 divider.setPointerCapture?.(e.pointerId);
283 startX = e.clientX;
284 startRatio = currentSplitRatio();
285 divider.classList.add("dragging");
286 });
287 divider.addEventListener("pointermove", (e) => {
288 if (!divider.classList.contains("dragging")) return;
289 const rect = container.getBoundingClientRect();
290 if (rect.width) setSplitRatio(startRatio + (e.clientX - startX) / rect.width);
291 });
292 const endDrag = (e) => {
293 divider.classList.remove("dragging");
294 divider.releasePointerCapture?.(e.pointerId);
295 };
296 divider.addEventListener("pointerup", endDrag);
297 divider.addEventListener("pointercancel", endDrag);
298 divider.addEventListener("dblclick", () => setSplitRatio(0.5));
299 divider.addEventListener("keydown", (e) => {
300 if (e.key === "ArrowLeft") setSplitRatio(currentSplitRatio() - step);
301 else if (e.key === "ArrowRight") setSplitRatio(currentSplitRatio() + step);
302 else if (e.key === "Home") setSplitRatio(SPLIT_RATIO_MIN);
303 else if (e.key === "End") setSplitRatio(SPLIT_RATIO_MAX);
304 else if (e.key === "Enter") setSplitRatio(0.5);
305 else return;
306 e.preventDefault();
307 });
308}
309
310// ---- action chips ---------------------------------------------------------
311// One-tap buttons a helper attaches to its reply ("Switch to dark" etc.).
312// Consent-based by design: nothing runs until the member taps — payloads
313// aren't sender-signed, so a chip is a *suggestion*, and the whitelist in
314// ui.js keeps every runnable action harmless and reversible.
315const CHIP_RUNNERS = {
316 set_theme: (a) => {
317 setTheme(a.theme);
318 return `${THEME_META[a.theme].name} ✓`;
319 },
320 set_appearance: (a) => {
321 setAppearance(a.appearance);
322 return { light: "Light ✓", dark: "Dark ✓", system: "Following your device ✓" }[a.appearance];
323 },
324 open_theme_picker: () => (showThemePicker(), null),
325 open_profile: () => (editProfile(), null),
326 // Start Eleven's guided tour in the thread the chip was tapped in (the welcome
327 // DM). The tour renders as private notices via chat.js's appendNotice; the chip
328 // stays as-is (returns null), since the tour appears below it in the feed.
329 open_tutorial: () => (startTour((spec) => appendNotice(activeId, spec)), null),
330 open_thread: (a) => (openThreadById(a.thread), null),
331 // Open a bot-created view in this chat (docs/views.md): pins the view's
332 // panel above the feed from state already folded here. Returns the in-place
333 // line when the view isn't in the fold (yet) — never throws.
334 open_view: (a) => openThreadView(activeId, a.view) || null,
335 // The memory scratchpad, for the thread the chip was tapped in (the chip is
336 // sidecar-attached on every remember/forget write — transparency + the door
337 // in one tap). Returns null: the chip keeps its label for re-taps.
338 open_memory: () => (openMemoryPanel(activeId), null),
339 // Connected-service op: the tap IS the consent (a write chip's label carries
340 // the manifest's confirm line). Lock the chip immediately so a double-tap
341 // can't run the op twice; the run is async and its outcome lands as a private
342 // notice in the thread the chip was tapped in.
343 service_op: (a) => {
344 const threadId = activeId;
345 requestOp(a.service, a.op, a.args, a.access, 120000, "chip").then((res) =>
346 // On failure: a plain-words line with the raw error QUOTED under it
347 // (never dumped into the prose), and the bot follows up in-thread with
348 // an explanation (the src:"chip" tag).
349 appendNotice(
350 threadId,
351 res.ok ? { text: summarizeOpResult(a, res) } : { text: "That didn't work.", quote: res.error }
352 )
353 );
354 return a.access === "write" ? "Confirmed ✓" : "Requested ✓";
355 },
356 // Run a slash command on THIS device — the tap is the permission ("add that
357 // reminder"), and the command runs exactly where the member's apps live, so
358 // /remind's time phrases parse in THEIR timezone. APP commands only: a bot
359 // command or unknown verb is refused at the tap (parseCommand must resolve
360 // to an owning app), and the app's own private reply ("I'll remind you…")
361 // is the confirmation, rendered where the chip was tapped.
362 run_command: (a) => {
363 const threadId = activeId;
364 const cmd = parseCommand(a.command, threadId);
365 if (!cmd || !cmd.app) return "Not available here";
366 runCommand(a.command, threadId, (r) => appendNotice(threadId, r));
367 return "Done ✓";
368 },
369 // Research session: the tap consents to a short READ-ONLY look across the
370 // named connected services; the bot's answer arrives as a normal reply in
371 // this thread, so success needs no notice — only a failure to start does.
372 service_research: (a) => {
373 const threadId = activeId;
374 requestResearch(a.services, a.goal).then((res) => {
375 if (!res.ok) appendNotice(threadId, { text: "That didn't work.", quote: res.error });
376 });
377 return "Looking ✓";
378 },
379};
380
381// A small, safe line for a completed service op. res.data can be a large JSON
382// string from the provider — surface at most a name/id from it, never dump it.
383function summarizeOpResult(a, res) {
384 let text = `Done — ${a.op} on ${a.service} succeeded.`;
385 if (typeof res.data === "string" && res.data) {
386 try {
387 const parsed = JSON.parse(res.data);
388 const d = parsed && parsed.data ? parsed.data : parsed;
389 const v = d && !Array.isArray(d) && (d.name || d.title || d.id);
390 if (typeof v === "string" || typeof v === "number") text += ` (${String(v).slice(0, 200)})`;
391 if (Array.isArray(d)) text = `Done — ${a.op} on ${a.service} returned ${d.length} item${d.length === 1 ? "" : "s"}.`;
392 } catch {}
393 }
394 return text;
395}
396const chipLabel = (a) =>
397 a.label ||
398 {
399 set_theme: `Switch to ${(THEME_META[a.theme] || {}).name || a.theme}`,
400 set_appearance: { light: "Switch to light", dark: "Switch to dark", system: "Follow my device" }[a.appearance],
401 open_theme_picker: "Open theme picker",
402 open_profile: "Edit my profile",
403 open_tutorial: "Take the tour",
404 open_thread: "Open the chat",
405 open_view: "Open the view",
406 open_memory: "See what I remember",
407 service_op: `Run ${a.op} on ${a.service}`,
408 service_research: `Look at ${(a.services || []).join(" & ")} (read-only)`,
409 // The command IS the label (ui.js strips any free label) — a run_command
410 // chip can never claim one thing and run another.
411 run_command: a.command,
412 }[a.type];
413
414function buildActionChips(actions) {
415 const row = document.createElement("div");
416 row.className = "chip-row";
417 for (const a of actions) {
418 const b = document.createElement("button");
419 b.className = "action-chip";
420 b.textContent = chipLabel(a);
421 b.onclick = () => {
422 const done = CHIP_RUNNERS[a.type](a);
423 if (done) {
424 b.textContent = done; // applied in place — show it stuck
425 b.disabled = true;
426 }
427 };
428 row.appendChild(b);
429 }
430 return row;
431}
432
433// Theme picker sheet (kebab → Theme): an appearance segmented control + a grid
434// of colour swatches. Both apply live and persist; nothing leaves the browser.
435function showThemePicker() {
436 const backdrop = modal();
437 const card = backdrop.querySelector(".sheet");
438
439 const h = document.createElement("h2");
440 h.textContent = "Theme";
441 card.appendChild(h);
442
443 const apLabel = document.createElement("label");
444 apLabel.className = "field-label";
445 apLabel.textContent = "Appearance";
446 card.appendChild(apLabel);
447
448 const seg = document.createElement("div");
449 seg.className = "appearance-seg";
450 const appBtns = {};
451 for (const [val, lab] of [
452 ["light", "Light"],
453 ["system", "System"],
454 ["dark", "Dark"],
455 ]) {
456 const b = document.createElement("button");
457 b.type = "button";
458 b.textContent = lab;
459 b.setAttribute("aria-pressed", String(currentAppearance() === val));
460 b.onclick = () => {
461 setAppearance(val);
462 for (const [v, el] of Object.entries(appBtns)) el.setAttribute("aria-pressed", String(v === val));
463 };
464 appBtns[val] = b;
465 seg.appendChild(b);
466 }
467 card.appendChild(seg);
468
469 const thLabel = document.createElement("label");
470 thLabel.className = "field-label";
471 thLabel.textContent = "Scheme";
472 thLabel.style.marginTop = "14px";
473 card.appendChild(thLabel);
474
475 const grid = document.createElement("div");
476 grid.className = "theme-grid";
477 const swatches = {};
478 for (const id of THEME_ORDER) {
479 const b = document.createElement("button");
480 b.type = "button";
481 b.className = "theme-swatch" + (currentTheme() === id ? " active" : "");
482 const disc = document.createElement("span");
483 disc.className = "disc";
484 disc.style.background = THEME_META[id].disc;
485 const nm = document.createElement("span");
486 nm.textContent = THEME_META[id].name;
487 b.appendChild(disc);
488 b.appendChild(nm);
489 b.onclick = () => {
490 setTheme(id);
491 for (const [v, el] of Object.entries(swatches)) el.classList.toggle("active", v === id);
492 };
493 swatches[id] = b;
494 grid.appendChild(b);
495 }
496 card.appendChild(grid);
497
498 // Text size (desktop only — touch follows the system setting): the iMessage
499 // slider, A…A over the sizes in ui.js. Applies live so the feed behind the
500 // sheet previews the choice.
501 if (!isCoarse) {
502 const tsLabel = document.createElement("label");
503 tsLabel.className = "field-label";
504 tsLabel.textContent = "Text size";
505 tsLabel.style.marginTop = "14px";
506 card.appendChild(tsLabel);
507
508 const row = document.createElement("div");
509 row.className = "text-size-row";
510 const small = document.createElement("span");
511 small.className = "text-size-a small";
512 small.textContent = "A";
513 const slider = document.createElement("input");
514 slider.type = "range";
515 slider.min = "0";
516 slider.max = String(TEXT_SIZES.length - 1);
517 slider.step = "1";
518 slider.value = String(TEXT_SIZES.indexOf(currentTextSize()));
519 slider.setAttribute("aria-label", "Text size");
520 slider.oninput = () => setTextSize(TEXT_SIZES[+slider.value]);
521 const big = document.createElement("span");
522 big.className = "text-size-a big";
523 big.textContent = "A";
524 row.append(small, slider, big);
525 card.appendChild(row);
526 }
527
528 // Composer preferences (#531): "Format as you type" applies live to every
529 // mounted composer; "Classic composer" (the plain-textarea escape hatch)
530 // takes effect on the next thread open — reopening the current one here so
531 // the choice is visible immediately.
532 const composerLabel = document.createElement("label");
533 composerLabel.className = "field-label";
534 composerLabel.textContent = "Composer";
535 composerLabel.style.marginTop = "14px";
536 card.appendChild(composerLabel);
537 const toggleRow = (labelText, checked, onchange, hintText) => {
538 const row = document.createElement("label");
539 row.className = "composer-pref-row";
540 const box = document.createElement("input");
541 box.type = "checkbox";
542 box.checked = checked;
543 box.onchange = () => onchange(box.checked);
544 const txt = document.createElement("span");
545 txt.textContent = labelText;
546 row.append(box, txt);
547 card.appendChild(row);
548 if (hintText) {
549 const hint = document.createElement("div");
550 hint.className = "composer-pref-hint";
551 hint.textContent = hintText; // literal markers on purpose — the hint teaches the syntax
552 card.appendChild(hint);
553 }
554 return row;
555 };
556 toggleRow(
557 "Convert common Markdown formatting as you type",
558 formatAsYouTypePref(),
559 (on) => setFormatAsYouType(on),
560 "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~",
561 );
562 toggleRow("Classic plain-text composer", classicComposerPref(), (on) => {
563 setClassicComposer(on);
564 // Remount the open thread's pane so the switch is immediate. Parked panes
565 // rebuild on their next open (appPaneCache entries are dropped).
566 for (const k of Object.keys(appPaneCache)) delete appPaneCache[k];
567 const active = localStorage.getItem(ACTIVE_KEY);
568 if (active && typeof openThreadById === "function") openThreadById(active);
569 });
570
571 const foot = document.createElement("div");
572 foot.className = "sheet-foot";
573 const done = document.createElement("button");
574 done.textContent = "Done";
575 done.onclick = () => backdrop.remove();
576 foot.appendChild(done);
577 card.appendChild(foot);
578}
579
580const preMountStaged = []; // native-staged files awaiting the chat app's composer (flushed by registerNative)
581const blobURLs = {}; // blob id -> decrypted object URL (cache)
582
583// ---- sealed branding (docs/no-plaintext.md) -------------------------------
584// On a sealed instance the space's name/description/photo/admin identity live
585// in one server-held ciphertext blob, opened with the link-carried branding
586// key B. Members hold B here (localStorage after a link or an instance-key
587// unwrap); every minted invite link carries it in the #fragment.
588let brandingSalt = ""; // PBKDF2 salt for deriving from B (not secret)
589let serverBrandingSealed = false;
590
591// bootstrapBranding: after keys are up, learn whether this instance is sealed
592// and open the blob. B comes from localStorage (a link stashed it), else from
593// the copy sealed under the instance key — so members never re-visit a link.
594async function bootstrapBranding() {
595 let g;
596 try {
597 g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
598 } catch {
599 return;
600 }
601 if (!g) return;
602 // The space's chosen name + photo also brand the sidebar's space rows (the
603 // space band names each copy after the space — spaceRowTitle — and its tile
604 // wears the space photo). Stash them here in the unsealed case; the sealed
605 // path below stashes from the opened blob instead. name_set separates a
606 // chosen name from the compiled-in default: a default never brands a row.
607 if (!g.sealed) {
608 if (g.name_set && g.name) spaceBrandName = g.name;
609 if (g.avatar) spaceAvatarSrc = "/api/server-avatar";
610 return;
611 }
612 if (!g.branding_blob) return;
613 serverBrandingSealed = true;
614 brandingSalt = g.branding_salt || "";
615 try {
616 setBrandingB(localStorage.getItem("lchat_branding_b") || null);
617 } catch {}
618 if (!brandingB && g.branding_key_sealed && instanceKey) {
619 try {
620 const opened = await decrypt(instanceKey, g.branding_key_sealed);
621 if (opened && opened.b) {
622 setBrandingB(opened.b);
623 try {
624 localStorage.setItem("lchat_branding_b", brandingB);
625 } catch {}
626 }
627 } catch {}
628 }
629 if (!brandingB) return;
630 try {
631 const bKey = await deriveBrandingKey(brandingB, brandingSalt);
632 setSealedBranding(await decrypt(bKey, g.branding_blob));
633 } catch {
634 // Stale cached B — an unseal→re-seal mints a fresh key, and a device
635 // still holding the old one would otherwise render generic forever (and
636 // an admin device couldn't edit or unseal). The instance-key copy always
637 // holds the CURRENT B: fall back to it and repair the cache.
638 setSealedBranding(null);
639 if (g.branding_key_sealed && instanceKey) {
640 try {
641 const opened = await decrypt(instanceKey, g.branding_key_sealed);
642 if (opened && opened.b && opened.b !== brandingB) {
643 setBrandingB(opened.b);
644 try {
645 localStorage.setItem("lchat_branding_b", brandingB);
646 } catch {}
647 const bKey = await deriveBrandingKey(brandingB, brandingSalt);
648 setSealedBranding(await decrypt(bKey, g.branding_blob));
649 }
650 } catch {
651 setSealedBranding(null); // still undecryptable — surfaces stay generic
652 }
653 }
654 }
655 if (sealedBranding && sealedBranding.name) document.title = sealedBranding.name;
656 paintSpaceName();
657 // The sealed counterparts of the plaintext stash above (the space band's
658 // name + photo). The seal captured whatever name the space had, chosen or
659 // not, so there's no name_set gate to apply here.
660 if (sealedBranding) {
661 if (sealedBranding.name) spaceBrandName = sealedBranding.name;
662 spaceAvatarSrc = (await sealedServerAvatarURL()) || "";
663 }
664}
665
666// paintSpaceName: the sidebar heading IS the space's name (it ellipsizes, so
667// the sidebar width never depends on it). document.title is the one source of
668// the name on this page (served substituted, corrected at runtime when sealed
669// branding decrypts), so the heading just mirrors it — call after every
670// document.title update.
671function paintSpaceName() {
672 const el = document.getElementById("side-space-name");
673 if (el) el.textContent = document.title;
674}
675
676// The space's display name + photo, stashed by bootstrapBranding for the
677// sidebar's space rows (before the space thread ever loads, so the band never
678// paints generic first). Empty = unbranded: spaceRowTitle falls back to
679// "Space <app>", the tile to the plain glyph.
680let spaceBrandName = "";
681let spaceAvatarSrc = "";
682
683// resealBranding: the sealed counterpart of saveGroupSettings — merge the
684// patch into the decrypted set, re-encrypt, and store. The plaintext setters
685// 409 while sealed, so every edit routes through here.
686async function resealBranding(patch) {
687 if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return "error";
688 // Mirror the plaintext setter's 1–60 rule — the server can't see inside the
689 // blob to enforce it, so the sealed path validates here.
690 if (patch.name !== undefined && (!patch.name.trim() || patch.name.length > 60)) return "invalid";
691 const next = { ...sealedBranding, ...patch };
692 try {
693 const bKey = await deriveBrandingKey(brandingB, brandingSalt);
694 const body = {
695 branding_blob: await encrypt(bKey, next),
696 branding_salt: brandingSalt,
697 branding_key_sealed: await encrypt(instanceKey, { b: brandingB }),
698 };
699 const r = await fetch("/api/admin/branding/seal", {
700 method: "POST",
701 headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
702 body: JSON.stringify(body),
703 });
704 if (r.ok) {
705 setSealedBranding(next);
706 if (next.name) {
707 document.title = next.name;
708 paintSpaceName();
709 }
710 }
711 return r.ok ? "ok" : r.status === 400 ? "invalid" : "error";
712 } catch {
713 return "error";
714 }
715}
716
717// sealServerPhoto: change the space photo while sealed — the plaintext upload
718// endpoint 409s (deliberately), so the new bytes are encrypted under the
719// branding key and ride a re-seal (which also carries the new mime + alt
720// inside the blob). The sealed counterpart of pickServerPhoto's POST.
721async function sealServerPhoto(bytes, mime, alt) {
722 if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return false;
723 try {
724 const bKey = await deriveBrandingKey(brandingB, brandingSalt);
725 const next = { ...sealedBranding, avatar_mime: mime || "image/jpeg" };
726 if (alt !== undefined) next.avatar_alt = alt || "";
727 const r = await fetch("/api/admin/branding/seal", {
728 method: "POST",
729 headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
730 body: JSON.stringify({
731 branding_blob: await encrypt(bKey, next),
732 branding_salt: brandingSalt,
733 branding_key_sealed: await encrypt(instanceKey, { b: brandingB }),
734 avatar_sealed: bytesToB64url(await encryptBytes(bKey, bytes)),
735 }),
736 });
737 if (r.ok) sealedBranding = next;
738 return r.ok;
739 } catch {
740 return false;
741 }
742}
743
744// sealedServerAvatarURL: a displayable URL for the space photo when sealed —
745// /api/server-avatar serves ciphertext, so fetch + decrypt to an object URL.
746// Returns null when there's no photo or no key to open it.
747async function sealedServerAvatarURL() {
748 if (!serverBrandingSealed || !sealedBranding || !brandingB) return null;
749 try {
750 const resp = await fetch("/api/server-avatar");
751 if (!resp.ok) return null;
752 const bKey = await deriveBrandingKey(brandingB, brandingSalt);
753 const photo = await decryptBytes(bKey, b64urlToBytes(await resp.text()));
754 return URL.createObjectURL(new Blob([photo], { type: sealedBranding.avatar_mime || "image/jpeg" }));
755 } catch {
756 return null;
757 }
758}
759
760// sealBrandingNow: flip this instance to sealed branding. Mints B, gathers the
761// current plaintext set (including the photo bytes, re-encrypted under B),
762// stores the blob + the instance-key-sealed copy of B, and the server clears
763// every plaintext counterpart in the same request. Admin-gated server-side.
764async function sealBrandingNow() {
765 if (!instanceKey) return "error";
766 try {
767 const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
768 if (g.sealed) return "ok";
769 const b = mintBrandingKey();
770 const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
771 const bKey = await deriveBrandingKey(b, salt);
772 const branding = {
773 name: g.name || "",
774 description: g.description || "",
775 admin_name: g.admin_name || "",
776 admin_email: g.admin_email || "",
777 avatar_alt: g.avatar_alt || "",
778 avatar_mime: "",
779 };
780 const body = {
781 branding_salt: salt,
782 branding_key_sealed: await encrypt(instanceKey, { b }),
783 };
784 if (g.avatar) {
785 // The photo must ride the seal: the server clears a plaintext photo it
786 // gets no sealed replacement for (it must never serve readable bytes
787 // behind the sealed flag). So a failed fetch aborts — nothing has been
788 // written yet, and the admin just retries.
789 const resp = await fetch("/api/server-avatar");
790 if (!resp.ok) return "error";
791 branding.avatar_mime = resp.headers.get("Content-Type") || "image/jpeg";
792 const bytes = new Uint8Array(await resp.arrayBuffer());
793 body.avatar_sealed = bytesToB64url(await encryptBytes(bKey, bytes));
794 }
795 body.branding_blob = await encrypt(bKey, branding);
796 const r = await fetch("/api/admin/branding/seal", {
797 method: "POST",
798 headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
799 body: JSON.stringify(body),
800 });
801 if (!r.ok) return r.status === 409 ? "ok" : "error";
802 setBrandingB(b);
803 brandingSalt = salt;
804 setSealedBranding(branding);
805 serverBrandingSealed = true;
806 try {
807 localStorage.setItem("lchat_branding_b", b);
808 } catch {}
809 return "ok";
810 } catch {
811 return "error";
812 }
813}
814
815// unsealBrandingNow: back to plaintext branding — the server can't decrypt, so
816// we hand it the values we just decrypted. The sealed photo is cleared
817// server-side (its bytes are useless in plaintext mode); the admin re-uploads.
818async function unsealBrandingNow() {
819 if (!serverBrandingSealed || !sealedBranding) return "error";
820 try {
821 const r = await fetch("/api/admin/branding/unseal", {
822 method: "POST",
823 headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
824 body: JSON.stringify({
825 name: sealedBranding.name || "",
826 description: sealedBranding.description || "",
827 admin_name: sealedBranding.admin_name || "",
828 admin_email: sealedBranding.admin_email || "",
829 avatar_alt: sealedBranding.avatar_alt || "",
830 }),
831 });
832 if (!r.ok) return "error";
833 serverBrandingSealed = false;
834 setSealedBranding(null);
835 setBrandingB(null);
836 try {
837 localStorage.removeItem("lchat_branding_b");
838 } catch {}
839 return "ok";
840 } catch {
841 return "error";
842 }
843}
844// Tiers — "access levels" in every UI surface: admin-curated member sets,
845// each with its own key on the instance-key pattern (docs/community-and-tiers.md).
846
847// The fixed reaction set (mirrors allowedReactions in hub.go).
848const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
849const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it
850
851// Open a burst with a centered time separator when consecutive messages are more
852// than this far apart (or land on a different day), rather than stamping each one.
853// An exact per-message time is still available on hover (desktop).
854const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000;
855
856// The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md).
857import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js";
858
859
860const app = document.getElementById("app");
861// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with
862// everything needed to render ONE thread: {token, name, myUserId, threadId,
863// threadKey, instanceKey?}. In embed mode we render just that thread's feed — the
864// app draws its own sidebar, title bar and composer. The user *private* key is
865// never handed to the webview: native unwraps the thread key and passes only it,
866// so a single thread's page can't reach any other thread's content.
867setSession(embed ? { token: embed.token, name: embed.name } : loadSession());
868
869// Post a message back to the native shell's "lchat" handler (no-op in a browser).
870function nativePost(msg) {
871 try {
872 if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat) {
873 window.webkit.messageHandlers.lchat.postMessage(msg);
874 }
875 } catch {}
876}
877// Surface a problem to the user. In the webview an alert() would freeze the page
878// (a modal dialog blocks the native message channel), so route errors to the shell.
879function notifyError(msg) {
880 if (embed) nativePost({ error: msg });
881 else alert(msg);
882}
883
884// Native dropdown menus (macOS app only). The Mac shell registers an "lchatMenu"
885// handler; when present, a kebab renders as a real NSMenu instead of the in-page
886// HTML menu. We mirror the live DOM menu — each visible .menu-item becomes a
887// native item — and picking one just .click()s the real button, so there's a
888// single source of truth for what every option does and says.
889const nativeMenu =
890 (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchatMenu) || null;
891let nativeMenuSeq = 0;
892const nativeMenuPending = new Set();
893
894// Pop up `menuEl`'s items as a native menu, anchored under `anchorEl`. Skips
895// items the web layer has hidden — inline display:none (group-only entries,
896// the notifications toggle where push is unusable) or the `hidden` attribute
897// (Open in the app, Upgrade) — so the native menu matches exactly what the
898// HTML one would show. Missing the `hidden` half of this used to leak
899// Upgrade into the native NSMenu, which the caller deliberately keeps out of
900// native for App Store rules (see the Upgrade wiring below).
901function openNativeMenu(menuEl, anchorEl) {
902 const items = [...menuEl.querySelectorAll(".menu-item")]
903 .filter((el) => el.style.display !== "none" && !el.hidden)
904 .map((el) => ({ id: el.id, label: el.textContent.trim(), danger: el.classList.contains("danger") }));
905 if (!items.length) return;
906 const token = ++nativeMenuSeq;
907 nativeMenuPending.add(token);
908 const r = anchorEl.getBoundingClientRect();
909 nativeMenu.postMessage({ token, items, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } });
910}
911
912// Called by the Mac shell after the user picks an item (id) or dismisses the
913// menu (id null). Clicking the real button runs the identical handler the HTML
914// item would have. Idempotent: an unknown/spent token is ignored.
915window.__lchatMenuPick = (token, id) => {
916 if (!nativeMenuPending.delete(token) || !id) return;
917 const item = document.getElementById(id);
918 if (item) item.click();
919};
920
921// App state.
922// A thread's chosen VIEW. "chat" (a DM-style conversation) is always the default
923// view; an app-thread also offers the "interface" view (its interactive UI). More
924// views (custom, per-thread) are planned — a view dropdown, not just this toggle.
925// Sticks per thread for the session. Only meaningful when threadApp[id] is set.
926const appThreadView = {}; // thread id -> "chat" | "interface"
927// For a GROUP chat with apps added (see groupAppsFor): which added app its
928// Interface pane shows. Sticks per thread for the session, like appThreadView.
929const groupAppView = {}; // thread id -> app name
930const threadPreview = {}; // thread id -> last-message snippet
931// The decrypted payload each snippet came from, so previews can re-derive when
932// names arrive/change (boot computes them before labels + nicknames decrypt).
933const previewPayload = {};
934// Pending-invite links, keyed by the (pending DM) thread id. Only known in the
935// session that minted them — the secret never reaches the server, so it can't be
936// recovered after a reload (the pending chat offers "re-send" to mint a fresh one).
937const inviteLinks = {};
938// thread id -> read watermark (the newest message time we've read, in server
939// seconds). Synced from the server so "read on one device" clears the dot on
940// the others; advanced locally first so the UI never waits on the POST.
941const readMarks = {};
942let chatFilter = ""; // lowercased substring filtering the thread list (search box)
943// The funnel menu beside search: online-only plus category checkboxes. All
944// categories on + online off = the unfiltered default (funnel unhighlighted).
945const chatFilters = { online: false, people: true, bots: true, apps: true };
946let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect)
947let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting)
948let pingTimer = null; // keepalive ping interval while the socket is open
949let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip
950
951// Custom-app import race: this const must initialize BEFORE route() below runs
952// boot() synchronously, or loadCustomApps hits its own const in the temporal
953// dead zone and every custom app silently skips on a same-tick boot.
954const CUSTOM_APPS_TIMEOUT_MS = 4000;
955
956// The boot loading scene (smoke, dust, and an unseen meter climbing to
957// eleven) lives in bootscene.js — boot() starts it, hideLoading() finishes it.
958// Paths that never start it (embed, signed-out, A2HS) get 0 back and just fade.
959
960// If we've just returned from the home server's /restore page, adopt the
961// recovered session before routing (checkRestoreReturn writes it to storage).
962// Otherwise route straight away — the extra tick only happens on a real return.
963if (!embed && !session && location.hash.includes("lchat_restore=")) {
964 checkRestoreReturn().then((restored) => {
965 if (restored) setSession(loadSession());
966 route();
967 });
968} else {
969 route();
970}
971
972function consumeAutoRestoreMarker() {
973 const clean = new URL(location.href);
974 clean.searchParams.delete("restore");
975 history.replaceState(null, "", clean.pathname + clean.search + clean.hash);
976}
977
978function route() {
979 if (embed) return bootEmbed();
980 if (!session) return showNoInvite();
981 // An already-signed-in space needs no handoff, but still consumes home's
982 // one-shot marker so signing out later cannot unexpectedly trigger one.
983 if (autoRestoreRequested(location.search)) consumeAutoRestoreMarker();
984 if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") {
985 return showA2HS(boot);
986 }
987 boot();
988}
989
990// Custom apps: modules the server sideloads (LCHAT_APPS_DIR) register through
991// the same defineApp() the compiled-in apps use — the /api/apps manifest lists
992// them and each is dynamic-imported here, BEFORE the first render, so
993// registration (typeOwner, personalApps) is complete when folding starts. A
994// broken or slow custom app must never block or white-screen boot: every
995// import failure is contained to a console.error, and the whole phase races a
996// short timeout so boot proceeds regardless. (Its timeout const is declared up
997// by route()'s invocation — see the TDZ note there.)
998async function loadCustomApps() {
999 try {
1000 const load = (async () => {
1001 const r = await fetch("/api/apps");
1002 if (!r.ok) return;
1003 const { apps } = await r.json();
1004 await Promise.all(
1005 (apps || []).map((a) =>
1006 import(a.url).catch((e) => console.error(`custom app "${a.name}" failed to load:`, e)),
1007 ),
1008 );
1009 })();
1010 await Promise.race([load, new Promise((res) => setTimeout(res, CUSTOM_APPS_TIMEOUT_MS))]);
1011 } catch (e) {
1012 console.error("custom apps skipped:", e);
1013 }
1014}
1015
1016// Member-built apps (sandbox.js): fetch + decrypt + register the sandboxed
1017// proxies. Time-boxed like loadCustomApps — registration is a boot dependency
1018// (thread rendering + live folds need the proxies), but a slow fetch must
1019// never hold the whole app hostage. No-op until the instance key is here.
1020// Give the sandbox host what it needs from the shell: seed an app-thread's
1021// snapshot before an off-thread command runs (so the frame's getState is
1022// accurate), and repaint surfaces after companion chat projections fold in.
1023setMemberSeeder(seedAppState);
1024setSurfaceRefresh((tid) => {
1025 if (tid === activeId) renderActiveSurfaces();
1026});
1027// App Studio's privileged context: the token + instance key it needs to
1028// encrypt/deploy member apps, plus the openers its place-aware "Open" button
1029// routes through (appOpenPlan in ui.js). An accessor, so it always reads the
1030// live instance key (which arrives after boot).
1031setMemberAdmin(() => ({
1032 token: session.token,
1033 instanceKey,
1034 uid: myUserId,
1035 name: session.name,
1036 // In a native embed this page IS one thread's pane — navigating it here
1037 // would render the app inside App Studio's own window. Flagged so the
1038 // studio offers only what works, and openApp hands the gesture to the
1039 // native shell instead ({openAppThread}), which opens — or creates — the
1040 // app's thread as its own native surface.
1041 embedded: !!embed,
1042 openApp: async (name) => {
1043 if (embed) {
1044 nativePost({ openAppThread: { app: name, title: (appByName(name) || {}).title || name } });
1045 return;
1046 }
1047 const id = await resolveAppThread(name);
1048 if (id) openThread(id);
1049 },
1050 // The group chats this member could add an app to (the studio's "which
1051 // group?" picker). Groups whose key hasn't arrived can't take the install
1052 // event yet, so they're left out rather than offered and failed.
1053 groups: () =>
1054 threads
1055 .filter((t) => t.kind === "group" && threadKeys[t.id])
1056 .map((t) => ({ id: t.id, title: threadName[t.id] || "Group" })),
1057 // Add a shared copy to a group chat and land there — the same groupapps.add
1058 // every Apps sheet emits, so the add is an event in the group's own log.
1059 openAppInGroup: async (name, tid) => {
1060 const app = appByName(name);
1061 if (!app || !threadKeys[tid]) return false;
1062 if (!(await installGroupApp(tid, app))) return false;
1063 openThread(tid);
1064 return true;
1065 },
1066 // Open the space-wide copy (its sidebar home), installing it first when it
1067 // isn't there yet. installSpaceApp enforces the install policy — false
1068 // means "admins only" here, and the studio says so instead of half-opening.
1069 openAppForSpace: async (name) => {
1070 const app = appByName(name);
1071 if (!app || !app.render) return false;
1072 if (!spaceAppsFor().some((a) => a.name === name) && !(await installSpaceApp(app))) return false;
1073 openSpaceApp(name);
1074 return true;
1075 },
1076}));
1077
1078async function loadMemberAppsNow() {
1079 try {
1080 const load = loadMemberApps({
1081 token: session.token,
1082 instanceKey,
1083 authorName: (uid) => {
1084 const l = rosterLabel(uid);
1085 return l === "Someone" ? "" : l;
1086 },
1087 });
1088 await Promise.race([load, new Promise((res) => setTimeout(res, CUSTOM_APPS_TIMEOUT_MS))]);
1089 } catch (e) {
1090 console.error("member apps skipped:", e);
1091 }
1092}
1093
1094// ---- the space thread: space-wide apps (docs/space-apps.md) ----------------
1095// One hidden, all-members thread backs every "installed for everyone" app: the
1096// installs registry and each installed app's shared instance fold from ITS
1097// encrypted log. The thread never appears in /api/threads (no membership rows
1098// — membership is implicit server-side) and has no chat surface at all: each
1099// space app surfaces as its own SIDEBAR ROW (the space band, renderThreadList)
1100// opening the app full-pane — place = location, so the space's copies never
1101// ride other chats' widget bars. Its key rides open_key — encrypted under the
1102// instance key, like an open group's — so this no-ops until the instance key
1103// arrives (onInstanceKeyReady retries).
1104let spaceThreadId = null;
1105let spaceAppOpen = null; // the space app rendered full-pane (while activeId === spaceThreadId)
1106const spaceAppPreview = {}; // appName → last activity line, for its sidebar row
1107const spaceAppUnread = {}; // appName → unseen activity (local, like the rows it badges)
1108
1109// loadSpaceThread fetches + unwraps the space thread and folds its state
1110// (registry + every space app), so command routing and the sidebar's space
1111// band see the installs. Safe to call repeatedly — re-seeding + catch-up
1112// converge.
1113async function loadSpaceThread() {
1114 // Embeds included, when the native shell seeded the instance key (the
1115 // phase-4 lift, docs/native-apps.md): a command host that knows the space
1116 // thread routes space-installed apps' commands correctly — /gif works
1117 // natively where installed. Without the key (not granted, or an old
1118 // native shell), the bail stands and space commands answer "isn't
1119 // available here yet", exactly as before.
1120 if (!instanceKey) return;
1121 try {
1122 const r = await (await fetch(`/api/space-thread?token=${encodeURIComponent(session.token)}`)).json();
1123 if (!r.id || !r.open_key) return; // no space installs yet
1124 if (!threadKeys[r.id]) {
1125 const b64 = (await decrypt(instanceKey, r.open_key)).k;
1126 threadKeys[r.id] = await importKey(b64);
1127 threadKeyB64[r.id] = b64;
1128 }
1129 spaceThreadId = r.id;
1130 await seedAppState(r.id, { full: true }); // snapshot + catch-up → installs + app states
1131 renderThreadList(); // the space band reflects the installs
1132 renderSpaceAppPane(); // a reconnect while the space home is open repaints it
1133 } catch (e) {
1134 console.error("space thread skipped:", e);
1135 }
1136}
1137
1138// resolveSpaceThread finds-or-creates the space thread — the first space-wide
1139// install creates it: mint a fresh AES key, wrap it under the instance key
1140// (the server can't open it), and POST. A racing creator wins harmlessly: the
1141// server returns the stored row and we unwrap THEIR key. In-flight guard like
1142// resolveAppThread, so a double-tap can't post twice.
1143let spaceThreadCreating = null;
1144function resolveSpaceThread() {
1145 if (spaceThreadId) return Promise.resolve(spaceThreadId);
1146 if (!instanceKey) return Promise.resolve(null); // keys still syncing — callers answer honestly
1147 if (!spaceThreadCreating) {
1148 spaceThreadCreating = (async () => {
1149 try {
1150 const { b64 } = await generateKey();
1151 const open_key = await encrypt(instanceKey, { k: b64 });
1152 const r = await (
1153 await fetch("/api/space-thread", {
1154 method: "POST",
1155 headers: { "Content-Type": "application/json" },
1156 body: JSON.stringify({ token: session.token, open_key }),
1157 })
1158 ).json();
1159 if (!r.id || !r.open_key) return null;
1160 // The STORED open_key is authoritative (a racing creator's key, when we
1161 // lost) — never assume our candidate key was the one that stuck.
1162 const stored = (await decrypt(instanceKey, r.open_key)).k;
1163 threadKeys[r.id] = await importKey(stored);
1164 threadKeyB64[r.id] = stored;
1165 spaceThreadId = r.id;
1166 await seedAppState(r.id, { full: true });
1167 return r.id;
1168 } catch (e) {
1169 console.error("space thread create failed:", e);
1170 return null;
1171 } finally {
1172 spaceThreadCreating = null;
1173 }
1174 })();
1175 }
1176 return spaceThreadCreating;
1177}
1178setSpaceThread(() => spaceThreadId);
1179setSpaceThreadResolver(resolveSpaceThread);
1180// The space-install policy (docs/space-apps.md § controls): a plaintext admin
1181// setting — served as page meta, default "everyone" — plus roster roles. Read
1182// fresh on every call: roles and the roster move under a live session. Note
1183// the raw meta read (not gateMeta): this flag defaults OFF.
1184setSpaceInstallPolicy(() => {
1185 const adminsOnly = ((document.querySelector('meta[name="space-installs-admins"]') || {}).content || "0") === "1";
1186 const roleOf = (uid) => ((roster || []).find((e) => e.user_id === uid) || {}).role || "";
1187 return {
1188 adminsOnly,
1189 canInstall: !adminsOnly || roleOf(myUserId) === "admin",
1190 adderOK: (uid) => roleOf(uid) === "admin",
1191 };
1192});
1193
1194// ---- local-store boot cache (#457) ----------------------------------------
1195// The raw server responses the last boot saw, cloned at fetch time (before any
1196// apply-side decryption touches them) and persisted as one snapshot. On the
1197// next load, hydrateFromCache() replays them through the same apply pipeline —
1198// instant paint, zero network — then the real fetches reconcile.
1199const bootRaw = { threads: null, roster: null, nick: null, tiers: null, instance: null };
1200// Cache the raw tier rows into the boot store (billingui calls this via the
1201// verbs seam — the store cache is shell's, not billing's).
1202function cacheTiersRaw(rows) {
1203 bootRaw.tiers = structuredClone(rows);
1204 scheduleStoreSnapshot();
1205}
1206let cachePainted = false; // a cached paint happened; network failures must not blank it
1207let snapTimer = null;
1208function scheduleStoreSnapshot() {
1209 if (embed || !bootRaw.threads || !bootRaw.roster) return;
1210 clearTimeout(snapTimer);
1211 snapTimer = setTimeout(() => {
1212 storePutSnapshot({
1213 v: SNAP_VERSION,
1214 ts: Date.now(),
1215 // Bind the snapshot to this session's identity: a claim flow can replace
1216 // lchat_session without a sign-out (add-device, paste-to-join), and the
1217 // next boot must not hydrate the previous person's cache.
1218 owner: session.userPub || "",
1219 threads: bootRaw.threads,
1220 roster: bootRaw.roster,
1221 nick: bootRaw.nick,
1222 tiers: bootRaw.tiers,
1223 instance: bootRaw.instance,
1224 }).catch(() => {});
1225 }, 1500);
1226}
1227
1228// Paint the whole app from the cached snapshot: same apply pipeline, no
1229// network. Returns true when the shell painted (the boot scene then stays
1230// down and the reveal has already happened). Any failure means "no cache" —
1231// boot proceeds exactly as it always has.
1232async function hydrateFromCache() {
1233 if (embed) return false;
1234 let snap = null;
1235 try {
1236 snap = await storeGetSnapshot();
1237 } catch {}
1238 if (!snapshotUsable(snap)) return false;
1239 // A different member's leftovers (session replaced by a claim flow, no
1240 // sign-out): their ciphertext is useless to this key anyway — wipe, don't
1241 // hydrate a cache that can only render undecryptable rows.
1242 if (snap.owner !== (session.userPub || "")) {
1243 wipeStore().catch(() => {});
1244 return false;
1245 }
1246 try {
1247 await applyRoster(snap.roster);
1248 if (snap.instance && snap.instance.wrapped) {
1249 try {
1250 const b64 = await unwrapThreadKey(snap.instance.wrapped, session.userPriv);
1251 setInstanceKey(await importKey(b64), b64);
1252 } catch {}
1253 }
1254 if (snap.nick) {
1255 const n = await applyNicknamesBody(snap.nick).catch(() => null);
1256 if (n) setNicknames(n);
1257 }
1258 if (Array.isArray(snap.tiers)) await applyTiers(snap.tiers, false);
1259 await applyThreads(snap.threads);
1260 await decryptRosterLabels();
1261 await recomputeThreadNames();
1262 const opened = renderApp(); // the initial thread's feed folds from the store
1263 connectWS(); // live frames flow into the cached paint immediately
1264 // A failed initial open (e.g. offline + a never-cached thread) still leaves
1265 // a usable shell — reveal rather than fall back to the boot scene.
1266 await Promise.resolve(opened).catch(() => {});
1267 cachePainted = true;
1268 hideLoading();
1269 return true;
1270 } catch (e) {
1271 console.error("hydrate from cache", e);
1272 return cachePainted;
1273 }
1274}
1275
1276async function boot() {
1277 try {
1278 // An instant paint from the local snapshot, when one exists. The boot
1279 // scene is first-run theatre: it only shows when there's nothing to paint.
1280 const painted = await hydrateFromCache();
1281 if (!painted) startBootScene();
1282 if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
1283 await loadCustomApps();
1284 await ensurePubkey();
1285 // Role + first-run state, fetched in parallel with the heavy loads:
1286 // maybeStartSetup awaits it at the end, and on first run the loading cover
1287 // holds until Eleven's conversation is open (no flash of an empty home).
1288 // Time-boxed: a stalled /api/me must never hold the reveal hostage for
1289 // everyone — on timeout the app opens normally and setup retries next load.
1290 const mePromise = Promise.race([
1291 fetch(`/api/me?token=${encodeURIComponent(session.token)}`)
1292 .then((r) => r.json())
1293 .catch(() => null),
1294 new Promise((resolve) => setTimeout(() => resolve(null), 4000)),
1295 ]);
1296 await loadRoster();
1297 // Nicknames need only our own keypair (not the instance key), so they load
1298 // alongside — before thread titles are derived below. Time-boxed: a stalled
1299 // /api/nicknames must not hold the reveal hostage — on timeout the app
1300 // opens with published names, and repaints when the fetch finally lands
1301 // (the thread-list check skips the repaint when it landed pre-paint).
1302 const nickLoad = loadNicknames().then(() => {
1303 if (document.getElementById("thread-list")) refreshNamesUI();
1304 });
1305 // Muted people ride the same sealed-blob shape; a late arrival just means
1306 // a few bubbles unfold on the repaint that follows.
1307 loadModPrefs().then(() => {
1308 syncThreadKeysToSW();
1309 renderActiveSurfaces();
1310 });
1311 loadBlocks(); // the profile card's Block/Unblock state
1312
1313 await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]);
1314 await bootstrapBranding(); // sealed instances: open the branding blob (needs the instance key for first-time B)
1315 await loadTiers(); // before the re-fold: gated groups unwrap via tier keys
1316 // The loads race: when the thread list landed first, any open group (key
1317 // under the instance key or a tier key) was skipped — fold it in now.
1318 if (threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads();
1319 // Member-built apps are sealed under the instance key, so they can only
1320 // register now (a late-arriving key retries from onInstanceKeyReady).
1321 // Before renderApp + connectWS on purpose: their threads and live events
1322 // need the proxies in the registry.
1323 await loadMemberAppsNow();
1324 // The space thread (space-wide apps) unwraps via the instance key too —
1325 // after the member apps, so their proxies exist before their space state
1326 // folds. Before connectWS, so live space events find the key in place.
1327 await loadSpaceThread();
1328 // Names are encrypted under the instance key, which is only ready now — decrypt
1329 // the roster and publish our own (new joiner, or migrating an old plaintext name).
1330 await decryptRosterLabels();
1331 await recomputeThreadNames(); // titles were cached (by loadThreads) before the key was ready
1332 syncThreadKeysToSW(); // and so were the SW's notification titles
1333 await publishMyLabel();
1334 if (!painted) {
1335 const opened = renderApp(); // paints the shell; opens the initial thread (async)
1336 connectWS();
1337 // Reveal only once the initial feed is loaded + scrolled — so the first
1338 // paint is the finished layout, not an empty feed that then jumps to the
1339 // bottom.
1340 await opened;
1341 } else {
1342 // The shell painted from the snapshot before these loads ran — refresh
1343 // its surfaces with server truth (folds dedupe; renders are idempotent).
1344 // No catch-up here: the cached open already reconciled the active feed
1345 // (openAppThreadView fires one on its cache path, and its network path
1346 // IS a fresh fetch) — a second identical fetch would be pure waste.
1347 renderThreadList();
1348 refreshNamesUI();
1349 // Member/custom apps register only in the loads above — a cached open of
1350 // one of their threads mounted BEFORE registration and drew nothing
1351 // (renderMiniApp on an unknown app is a no-op over a cleared pane).
1352 // An empty pane with an active thread is exactly that case: mount it
1353 // again now the registry is complete.
1354 const pane = document.getElementById("app-pane");
1355 if (activeId && pane && !pane.childNodes.length && renderAppFor(activeId)) {
1356 delete appPaneCache[activeId];
1357 await openAppThreadView(activeId, ++openSeq, renderAppFor(activeId), threadApp[activeId] || null);
1358 }
1359 }
1360 registerSW(session); // best-effort; subscribes if notifications already on
1361 startHeartbeat();
1362 // The backup banner is initialised inside maybeStartSetup (below): while the
1363 // setup conversation runs, backup messaging is Eleven's — one voice.
1364 // Tapping a notification while a tab is already open posts us the thread to
1365 // open (the SW can't navigate a live SPA) — jump straight to it.
1366 if (navigator.serviceWorker) {
1367 navigator.serviceWorker.addEventListener("message", (e) => {
1368 if (e.data && e.data.type === "open-thread" && e.data.thread) openThreadById(e.data.thread);
1369 });
1370 }
1371 // First-run: open the admin's setup conversation before the reveal, so the
1372 // first thing an admin ever sees IS the chat with Eleven (no-op otherwise).
1373 await maybeStartSetup(mePromise);
1374 // A freshly-joined member gets Eleven's welcome instead (mutually exclusive
1375 // with admin setup — a setup claim never sets the welcome marker).
1376 await maybeStartWelcome();
1377 // A tapped "Copy link" permalink (#l=, issue #893): resolve + jump last, so
1378 // it always wins over whatever thread the normal boot flow landed in.
1379 await openInitialMessageLink();
1380 } catch (e) {
1381 console.error(e);
1382 // A failed network boot over a successful cached paint is the OFFLINE case
1383 // working as designed — the app stays readable; never blank it.
1384 if (!cachePainted) {
1385 app.innerHTML =
1386 `<div class="center"><div class="card"><h1>Something went wrong</h1>` +
1387 `<p>Couldn't load your chats. Try reloading.</p></div></div>`;
1388 }
1389 } finally {
1390 hideLoading();
1391 }
1392}
1393
1394// Fade out and remove the full-screen loading cover (shown until the app's first
1395// meaningful paint). Idempotent — safe to call from any entry path. The cover
1396// assembles in stages (bootscene.js), and the reveal waits out whatever it had
1397// time to show: a posed brand gets its beat, a quote its read, the full scene
1398// its punch to eleven.
1399function hideLoading() {
1400 const el = document.getElementById("app-loading");
1401 if (!el) return;
1402 const clicked = finishBootScene(); // the dwell the staged cover is owed — let it land
1403 setTimeout(() => {
1404 // cover-out, NOT the global .hidden — that's display:none !important and
1405 // would snap the cover off with no cross-fade.
1406 el.classList.add("cover-out");
1407 setTimeout(() => el.remove(), 400); // after the CSS fade (0.35s with .scene-out)
1408 }, clicked);
1409}
1410
1411// Embed bootstrap: render a single thread's feed for the native shell. Skips the
1412// normal roster/instance/thread *unwrap* path (the webview has no user private key)
1413// — native already unwrapped this thread's key and handed it over. Everything past
1414// that (feed render, send, reactions, edits, typing, attachments) is reused as-is.
1415async function bootEmbed() {
1416 document.body.classList.add("native-embed");
1417 // Command-host pages collapse to just the panel (style.css .command-host
1418 // rules) — classed before the first render so the feed never flashes.
1419 if (embed.mode === "command") document.body.classList.add("command-host");
1420 try {
1421 setMyUserId(embed.myUserId || "");
1422 setCurrentUser(myUserId, session.name || "New member");
1423 // The instance key first (a local unwrap, no network): applyRoster can
1424 // then decrypt labels in its first pass, and member apps below never hit
1425 // their no-key no-op.
1426 if (embed.instanceKey) {
1427 try {
1428 setInstanceKey(await importKey(embed.instanceKey), embed.instanceKey);
1429 } catch {}
1430 }
1431 // The boot round-trips are independent of each other, so they run
1432 // together rather than as a waterfall — this pane is behind the native
1433 // loading veil, and every serialized fetch here holds the veil longer.
1434 // The native shell can land straight in a custom app's thread, so
1435 // registration must still be complete before the render below.
1436 const customAppsLoad = loadCustomApps();
1437 const rosterLoad = loadRoster(); // token-only; gives sender labels + profile photos
1438 // Thread objects for kind/members/name_blob, but only the active thread
1439 // has a key — the others can't be unwrapped here without the private key.
1440 const threadsLoad = fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)
1441 .then((r) => r.json())
1442 .catch(() => ({}));
1443 // Per-viewer names (#577): the embed never holds userPriv, so it can't
1444 // open the nicknames blob — the native shell (which can) hands the
1445 // RESOLVED uid → name map over the bridge instead. Fold it in as
1446 // display-only overrides (seen = name: names render; the rename prompt
1447 // stays native chrome's job — updateRenameBanner bails in embeds).
1448 const applyEmbedNames = (map) => {
1449 for (const [uid, name] of Object.entries(map || {})) {
1450 if (name) nicknames[uid] = { name, seen: name };
1451 }
1452 };
1453 applyEmbedNames(embed.names);
1454 // The seed is baked at webview creation and parked webviews outlive a
1455 // rename — the native shell pushes fresh maps here after each fold.
1456 window.__lchatSetNames = (map) => applyEmbedNames(map);
1457 const tid = embed.threadId;
1458 threadKeyB64[tid] = embed.threadKey;
1459 threadKeys[tid] = await importKey(embed.threadKey);
1460 // Member apps read roster labels for their bylines, so they start once
1461 // the roster lands — still overlapping the custom-app phase, so the two
1462 // load timeouts run in the same window instead of back to back. The
1463 // native shell can land straight in a member app's thread too — the
1464 // sandboxed proxies must be registered before the render below.
1465 await rosterLoad;
1466 await Promise.all([customAppsLoad, loadMemberAppsNow()]);
1467 const r = await threadsLoad;
1468 setThreads(r.threads || []);
1469 if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] });
1470 threadName[tid] = await displayName(threads.find((t) => t.id === tid));
1471 // 🤖 The native shell can ask for a specific view — its companion split
1472 // renders chat NATIVELY and embeds this page purely as the app's
1473 // interface, so without this the pane would default to the chat view and
1474 // show a second, redundant web chat beside the native one. Chat-only apps
1475 // (the welcome conversation) have no Interface — the chat view stands.
1476 if (embed.view && threadApp[tid] && !appChatOnly(threadApp[tid])) appThreadView[tid] = embed.view;
1477 // 🤖 A GROUP's shared app pane: the native shell names which added app to
1478 // show (its own header switch renders chat natively, so this page is
1479 // purely that app's interface). Assigned directly — the groupapps fold
1480 // hasn't run yet, so setAppThreadView's guard would reject it here; the
1481 // open below folds state and refreshGroupAppsUI applies the view.
1482 if (embed.app && !embed.space) {
1483 groupAppView[tid] = embed.app;
1484 appThreadView[tid] = "interface";
1485 }
1486 renderApp();
1487 if (embed.space && embed.app) {
1488 // 🤖 A SPACE APP's surface (docs/native-apps.md phase 4): this page is
1489 // that app rendered full-pane over the space thread — the web's own
1490 // openSpaceApp path, never a chat (the space thread has no chat
1491 // surface; without the flag the dm-fallback row above would mount the
1492 // conversation feed). The seed's thread IS the space thread, so its
1493 // key is already in place; fold its state, then open the app.
1494 spaceThreadId = tid;
1495 await seedAppState(tid, { full: true });
1496 openSpaceApp(embed.app);
1497 } else {
1498 await openThread(tid);
1499 }
1500 connectWS();
1501 installNativeBridge();
1502 // Space-installed apps' commands route in this host too (phase 4): learn
1503 // the space thread when the native shell seeded the instance key.
1504 // Deliberately not awaited — {ready} must not wait on it; a command
1505 // racing the load still answers honestly ("isn't available here yet").
1506 if (!embed.space) loadSpaceThread();
1507 // Cross-thread command routing (docs/native-apps.md phase 3): resolving
1508 // an app's personal thread needs decrypted name_blobs this page can't
1509 // open — route the resolution through the native shell instead of the
1510 // shell.js resolver (which would create a duplicate thread here).
1511 setAppThreadResolver(embedResolveAppThread);
1512 // Command-host mode (the Mac shell's panel webview): this page exists to
1513 // RUN slash commands and show their panels — the native feed renders the
1514 // chat itself. See installCommandHost.
1515 if (embed.mode === "command") installCommandHost(tid);
1516 startHeartbeat();
1517 // The pane has real content now — the native shell drops its loading
1518 // veil on this (didFinish fires long before the app has painted).
1519 nativePost({ ready: true });
1520 } catch (e) {
1521 console.error(e);
1522 nativePost({ error: "Couldn't open this chat." });
1523 } finally {
1524 hideLoading();
1525 }
1526}
1527
1528// ---- app-host key requests (docs/native-apps.md phase 3) -------------------
1529// A command typed in one thread can legitimately land in another — a personal
1530// /todo typed in a group routes to the todo app-thread (instanceThread). The
1531// embed page holds a single thread's key, so that emit would silently fail;
1532// instead it asks the NATIVE shell, which can unwrap exactly the threads this
1533// member is in and hands back one narrow thread key — never the private key.
1534// Two requests: {needKey: threadId} when the id is known, and
1535// {needAppThread: {app, title}} when the app→thread resolution itself needs
1536// native (the embed can't decrypt other threads' name_blobs, so resolving
1537// here would mint a DUPLICATE app thread). Native answers by calling
1538// __lchatProvideKey / __lchatProvideAppThread; a missing answer times out to
1539// the same graceful failure as today.
1540const nativeKeyWaits = new Map(); // threadId -> [resolve(bool)]
1541const nativeAppThreadWaits = new Map(); // appName -> [resolve(threadId|null)]
1542window.__lchatProvideKey = async (threadId, keyB64) => {
1543 try {
1544 if (threadId && keyB64 && !threadKeys[threadId]) {
1545 threadKeys[threadId] = await importKey(keyB64);
1546 threadKeyB64[threadId] = keyB64;
1547 }
1548 } catch {}
1549 const waits = nativeKeyWaits.get(threadId) || [];
1550 nativeKeyWaits.delete(threadId);
1551 waits.forEach((r) => r(!!threadKeys[threadId]));
1552};
1553window.__lchatProvideAppThread = async (appName, threadId, keyB64) => {
1554 await window.__lchatProvideKey(threadId, keyB64);
1555 if (threadId && threadKeys[threadId]) threadApp[threadId] = appName;
1556 const waits = nativeAppThreadWaits.get(appName) || [];
1557 nativeAppThreadWaits.delete(appName);
1558 waits.forEach((r) => r(threadId && threadKeys[threadId] ? threadId : null));
1559};
1560function requestNativeKey(threadId) {
1561 if (!embed || !threadId) return Promise.resolve(false);
1562 if (threadKeys[threadId]) return Promise.resolve(true);
1563 return new Promise((resolve) => {
1564 const waits = nativeKeyWaits.get(threadId) || [];
1565 if (!waits.length) nativePost({ needKey: threadId }); // first asker posts; the rest share the answer
1566 let done = false;
1567 const once = (ok) => {
1568 if (!done) {
1569 done = true;
1570 resolve(ok);
1571 }
1572 };
1573 waits.push(once);
1574 nativeKeyWaits.set(threadId, waits);
1575 setTimeout(() => once(false), 4000);
1576 });
1577}
1578function requestNativeAppThread(appName, title) {
1579 if (!embed || !appName) return Promise.resolve(null);
1580 return new Promise((resolve) => {
1581 const waits = nativeAppThreadWaits.get(appName) || [];
1582 if (!waits.length) nativePost({ needAppThread: { app: appName, title: title || appName } });
1583 let done = false;
1584 const once = (id) => {
1585 if (!done) {
1586 done = true;
1587 resolve(id);
1588 }
1589 };
1590 waits.push(once);
1591 nativeAppThreadWaits.set(appName, waits);
1592 // Creation is server round-trips on the native side — wait longer than needKey.
1593 setTimeout(() => once(null), 8000);
1594 });
1595}
1596// The embed's app→thread resolver: the active thread if it IS the app, else
1597// ask native (which opens-or-creates and answers with id + key). Never create
1598// from here — a duplicate app thread is worse than an honest miss.
1599async function embedResolveAppThread(appName) {
1600 for (const id in threadApp) {
1601 if (threadApp[id] === appName && threadKeys[id]) return id;
1602 }
1603 const title = (appByName(appName) || {}).title || appName;
1604 return requestNativeAppThread(appName, title);
1605}
1606
1607// The bridge the native composer drives: forward typed text and staged photos into
1608// the one crypto/WebSocket path (shell.js). Mirrors the hooks ChatView's WebBridge
1609// calls; staged-attachment state + errors are posted back on the "lchat" handler.
1610function installNativeBridge() {
1611 window.lchatNative = {
1612 // Returns whether the text was accepted (socket open + something to send), so
1613 // the native field knows whether to clear itself. When the chat app drives
1614 // this thread, the app's composer handlers take over (same contract).
1615 sendText(text) {
1616 if (!ws || ws.readyState !== WebSocket.OPEN) return false;
1617 if (chatAppNative) return chatAppNative.sendText(text);
1618 return false;
1619 },
1620 // The native composer's keystrokes: beat "typing" to the thread while there's
1621 // text, stop when it empties. The chat app owns the beats (chat.typing,
1622 // relay-only ephemeral); before it has mounted there's no one to show a
1623 // beat to, so pre-mount keystrokes are simply dropped.
1624 typing(hasText) {
1625 if (chatAppNative) chatAppNative.typing(hasText);
1626 },
1627 // Stage a photo picked by the native + button (base64, standard alphabet). The
1628 // web composer only handles images; anything else is rejected with an error.
1629 stageAttachment(att) {
1630 try {
1631 if (!att || !att.type || !att.type.startsWith("image/")) {
1632 nativePost({ error: "Only photos can be attached." });
1633 return;
1634 }
1635 const bin = atob(att.b64);
1636 const bytes = new Uint8Array(bin.length);
1637 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1638 const file = new File([bytes], att.name || "photo.jpg", { type: att.type });
1639 if (chatAppNative) chatAppNative.stage(file);
1640 // Pre-mount: hold the file until the chat app registers — the legacy
1641 // staging this used to fall back to had no send path left (a black
1642 // hole). registerNative flushes the queue.
1643 else preMountStaged.push(file);
1644 } catch {
1645 nativePost({ error: "Couldn't attach that photo." });
1646 }
1647 },
1648 // The native reply banner's ✕ — clears the staged reply on both sides
1649 // (cancelReply posts {replying: null} back).
1650 cancelReply() {
1651 if (chatAppNative) return chatAppNative.cancelReply();
1652 },
1653 // The native edit banner's ✕ — drops edit mode (cancelEdit posts
1654 // {editing: null} back so the native banner + prefilled composer clear).
1655 cancelEdit() {
1656 if (chatAppNative) chatAppNative.cancelEdit();
1657 },
1658 // Switch a companion app-thread between its Chat and Interface views —
1659 // the native header's toggle drives this (the web's own header toggle
1660 // isn't rendered in embed, so without it the second view is unreachable
1661 // on iOS). No-op for non-app threads (setAppThreadView guards).
1662 setAppView(view) {
1663 const tid = embed && embed.threadId;
1664 if (!tid) return;
1665 setAppThreadView(tid, view === "interface" ? "interface" : "chat");
1666 },
1667 // Swap which of a GROUP's added apps the Interface pane shows — the native
1668 // header's app dropdown drives this without a page reload (the pane
1669 // re-renders from already-folded state). Unknown names stay inert:
1670 // renderGroupAppPane resolves via groupAppsFor, which filters to the
1671 // catalog of group-capable apps.
1672 setGroupApp(app) {
1673 const tid = embed && embed.threadId;
1674 if (!tid || typeof app !== "string" || !app) return;
1675 groupAppView[tid] = app;
1676 appThreadView[tid] = "interface";
1677 refreshGroupAppsUI(tid);
1678 },
1679 // The app-host protocol (docs/native-apps.md): the native shell pulls the
1680 // registered-app catalogue — every app this page loaded across all three
1681 // tiers (built-in, admin custom, member), with places / presentation /
1682 // commands — to drive its own registry, pickers, and surface routing.
1683 // Pull-based on purpose: an evaluateJavaScript call returns the plain
1684 // JSON directly, and native re-pulls on {catalogChanged} posts.
1685 appCatalog() {
1686 return appCatalogReport();
1687 },
1688 // This thread's command rows — the same commandCatalog(threadId) that
1689 // feeds the web "/" typeahead and /help, place-stamped and with the
1690 // install-flag alt rows, so the native composer can list and rank exactly
1691 // what the web would. Rows change as installs fold, so native re-pulls
1692 // per composer session rather than caching long.
1693 commandItems() {
1694 return embed && embed.threadId !== undefined ? commandCatalog(embed.threadId) : commandCatalog();
1695 },
1696 // The app-host protocol, phase 5 (docs/native-apps.md): project ONE app
1697 // event into its chat line for the NATIVE feed — the web's chatMessage /
1698 // groupFeedMessage answered by the engine that owns them. The event is
1699 // the decrypted payload (native already holds the thread key; no key
1700 // material rides here). Ownership resolves via appForType — the same
1701 // registry the web folds use, so families like webhooks.* land on their
1702 // app. Mirrors foldGroupAppEventBubble/foldAppEventBubble: a group feed
1703 // prefers groupFeedMessage, else chatMessage({group:true}); a personal
1704 // companion projects through chatMessage. null = silent by the app's own
1705 // choice, an unknown type, or a member app (its hook runs only in the
1706 // sandbox, never here). `title` rides along so native names the bubble
1707 // without re-deriving it.
1708 // The app-host protocol, voice surfaces: the sidebar line for one app
1709 // event — the app's own preview hook (opts.you = the actor is the
1710 // reader), defaulting to the actor-prefixed chat projection (appPreview's
1711 // chain). null = leave the row's line alone.
1712 previewEvent(event, you) {
1713 try {
1714 if (!event || typeof event.type !== "string") return null;
1715 const text = appPreview(event, { you: !!you });
1716 return text ? { text } : null;
1717 } catch {
1718 return null;
1719 }
1720 },
1721 projectEvent(event, group) {
1722 try {
1723 if (!event || typeof event.type !== "string") return null;
1724 const app = appForType(event.type);
1725 if (!app) return null;
1726 let line = null;
1727 if (group) {
1728 if (typeof app.groupFeedMessage === "function") line = app.groupFeedMessage(event);
1729 else if (typeof app.chatMessage === "function") line = app.chatMessage(event, { group: true });
1730 } else if (typeof app.chatMessage === "function") {
1731 line = app.chatMessage(event);
1732 }
1733 if (!line) return null;
1734 const text = typeof line === "string" ? line : typeof line.text === "string" ? line.text : "";
1735 if (!text) return null;
1736 return {
1737 text,
1738 action: typeof line === "object" && !!line.action,
1739 title: app.title || app.name,
1740 };
1741 } catch {
1742 return null;
1743 }
1744 },
1745 // Composer typeahead for the native input: same token detection + ranking
1746 // as the web menu (composerToken + typeaheadItems). caret is a UTF-16
1747 // offset, matching JS string indexing. Returns {start, items} or null.
1748 typeahead(text, caret) {
1749 const tok = composerToken(text, caret);
1750 if (!tok) return null;
1751 const items = typeaheadItems(tok).map((it) => ({
1752 label: it.glyph ? `${it.glyph} :${it.code}:` : it.name,
1753 insert: it.insert,
1754 }));
1755 return items.length ? { start: tok.start, items } : null;
1756 },
1757 // Called when this thread's (possibly cached) webview lands on screen —
1758 // a notification tap, foregrounding the app, or returning from another
1759 // chat. A parked page keeps receiving messages, but with rendering
1760 // suspended their smooth-scroll pinning never runs, so the feed resumes
1761 // shy of the newest message. Re-pin via the settle loop — a one-shot
1762 // scrollTop write here gets clamped by WebKit's resume-time layout —
1763 // unless the user had deliberately scrolled up into history.
1764 //
1765 // `force` (a notification tap) overrides that hold: the message you were
1766 // pinged about is the newest one, so jump to it even if the parked feed
1767 // had been scrolled up.
1768 didAttach(force) {
1769 if (chatAppNative) chatAppNative.didAttach(force);
1770 },
1771 };
1772}
1773
1774// ---- command host (embed.mode === "command") -------------------------------
1775//
1776// The native Mac shell renders chat NATIVELY, so it has no JS command engine —
1777// this page is that engine, one per thread: the shell keeps it hidden, forwards
1778// a typed slash command to window.lchatNative.runCommand, and reveals the
1779// webview only while a panel is showing (body.command-host CSS collapses
1780// everything except the .cmd-panel, so the webview IS the panel). Private
1781// replies post back as {notice} for the shell to render inline; panel
1782// visibility and height post as {panel}; a panel's insertText already posts
1783// {composerInsert} (chat.js) for the shell's own composer.
1784function installCommandHost(tid) {
1785 // Content height for the shell's frame. scrollHeight is the content EXTENT,
1786 // immune to the max-height clamp and to the webview being 0-tall while
1787 // hidden — the one measurement that works before the shell reveals it.
1788 const measure = () => {
1789 const body = document.querySelector(".cmd-panel-body");
1790 const head = document.querySelector(".cmd-panel-head");
1791 return Math.min(600, (body ? body.scrollHeight : 200) + (head ? head.offsetHeight : 26) + 12);
1792 };
1793 const postPanel = (spec) => {
1794 if (!spec) return nativePost({ panel: null });
1795 // Measure synchronously — scrollHeight forces layout, and this page is
1796 // DETACHED from the native view hierarchy until the shell reveals it, so
1797 // a requestAnimationFrame here never fires (WebKit suspends rendering
1798 // callbacks for unparented webviews) and the panel would deadlock hidden.
1799 // Timers still run detached, so the late re-measure (fonts, images, and
1800 // the real width once the shell attaches the frame) uses setTimeout.
1801 nativePost({
1802 panel: {
1803 title: spec.title || (spec.sender && spec.sender.name) || "",
1804 height: measure(),
1805 // The app's animation choice (animate: false = appear instantly) —
1806 // in command-host mode the NATIVE shell owns the reveal, so the flag
1807 // rides the post instead of easing in-page (see panelEases, chat.js).
1808 animate: spec.animate !== false,
1809 },
1810 });
1811 setTimeout(() => nativePost({ panelHeight: measure() }), 350);
1812 };
1813 setCommandPanelObserver((threadId, spec) => {
1814 if (threadId === tid) postPanel(spec);
1815 });
1816 // The shell can't see this page's console, so surface page-side failures as
1817 // {error} posts — they land in the native unified log (CommandHost).
1818 window.addEventListener("error", (e) => nativePost({ error: "page: " + (e.message || "error") }));
1819 window.addEventListener("unhandledrejection", (e) =>
1820 nativePost({ error: "page: " + ((e.reason && (e.reason.message || e.reason)) || "unhandled rejection") }));
1821 const origConsoleError = console.error.bind(console);
1822 console.error = (...args) => {
1823 origConsoleError(...args);
1824 try {
1825 nativePost({ error: "console: " + args.map((a) => (a && a.message) || String(a)).join(" ").slice(0, 300) });
1826 } catch {}
1827 };
1828 // Returns whether the text was a command this engine answered (panel or
1829 // reply) — false lets the shell send it as a normal message (bot commands).
1830 window.lchatNative.runCommand = (text) =>
1831 runCommand(text, tid, (r) =>
1832 nativePost({ notice: { from: (r && r.sender && r.sender.name) || "", text: (r && r.text) || "" } }));
1833 // Escape in the native composer (and the shell's own chrome) dismisses.
1834 window.lchatNative.dismissPanel = () => closeCommandPanel(tid);
1835 nativePost({ commandHostReady: true });
1836}
1837
1838// ---- data ---------------------------------------------------------------
1839
1840function ensurePubkey() {
1841 return fetch("/api/pubkey", {
1842 method: "POST",
1843 headers: { "Content-Type": "application/json" },
1844 body: JSON.stringify({ token: session.token, public_key: session.userPub }),
1845 });
1846}
1847
1848async function loadRoster() {
1849 const r = await (await fetch(`/api/roster?token=${encodeURIComponent(session.token)}`)).json();
1850 // Snapshot the response BEFORE apply: decryptRosterLabels writes decrypted
1851 // names onto these entries, and plaintext must never reach the store.
1852 if (Array.isArray(r.roster)) bootRaw.roster = structuredClone(r);
1853 await applyRoster(r);
1854 scheduleStoreSnapshot();
1855}
1856
1857// The apply half of loadRoster — also run over the cached response on boot.
1858async function applyRoster(r) {
1859 setRoster(r.roster || []);
1860 // In embed mode the shell tells us our user id up front (it can't be matched by
1861 // public key — the webview never holds one). Otherwise find ourselves by pubkey.
1862 if (!myUserId) {
1863 const me = roster.find((e) => e.public_key === session.userPub);
1864 setMyUserId(me ? me.user_id : "");
1865 }
1866 // The "New member" fallback (here and on every outbound payload): setup and
1867 // welcome claimers are signed in nameless until Eleven's first question — if
1868 // they send anything before answering, recipients never see a blank sender.
1869 setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote")
1870 if (instanceKey) await decryptRosterLabels();
1871 applyInviterNameHint();
1872 verifyInviteBindings(); // KT: check any invitees we hold bind keys for (#573)
1873}
1874
1875// The joiner's first paint (#1436): their invite's sealed payload carried the
1876// inviter's display name. While the instance key (and so the inviter's real
1877// label) hasn't reached this fresh device, fill the hinted DM's other member
1878// with that name instead of "Someone" — and the moment the real label has
1879// decrypted, drop the hint for good. Scoping (only that one member, never a
1880// bot, never over a decrypted label) lives in inviterHintTarget.
1881function applyInviterNameHint() {
1882 let hint = null;
1883 try { hint = readInviterHint(localStorage.getItem(INVITER_HINT_KEY) || "", Date.now()); } catch {}
1884 if (!hint) return;
1885 const t = inviterHintTarget(hint, threads, roster, myUserId);
1886 if (!t) return;
1887 if (t.resolved) {
1888 try { localStorage.removeItem(INVITER_HINT_KEY); } catch {}
1889 return;
1890 }
1891 const e = roster.find((x) => x.user_id === t.userId);
1892 if (e) e.label = t.name;
1893}
1894
1895// ---- key transparency: verify-before-wrap (advisory, #573) ----------------
1896// The inviter is the one member who holds T for a joiner, so only they can
1897// check the bind-MAC. We stash each invitee's bind key at invite time, and
1898// once they've joined + published a key, confirm the roster key is the one
1899// THEY published — not a substitute the server slipped in. Advisory by design
1900// (Paul's call): a mismatch records an integrity alert (raising the banner),
1901// never blocks a wrap. A verified binding is dropped (the key can't change —
1902// setUserPublicKey never overwrites it). Absence of a bind-MAC (a pre-KT
1903// member) is "unverified", not an alarm.
1904// Only 1:1 single-use invites (inviteSomeone) stash a bind key: their T is
1905// known to exactly the inviter + joiner, so the MAC is unforgeable. Reusable
1906// admin/cohort invites share T among every link holder, so their bind-MAC
1907// proves nothing — NEVER call rememberInviteBindKey for those.
1908const INVITE_BINDS_KEY = "lchat_invite_binds";
1909const INVITE_BINDS_MAX = 500;
1910let bindUnverifiedCount = 0; // invited members keyed WITHOUT a MAC (rollout / downgrade); shown in /integrity
1911function loadInviteBinds() {
1912 try {
1913 return JSON.parse(localStorage.getItem(INVITE_BINDS_KEY) || "{}") || {};
1914 } catch {
1915 return {};
1916 }
1917}
1918function rememberInviteBindKey(uid, bindKey) {
1919 if (!uid || !bindKey) return;
1920 const m = loadInviteBinds();
1921 m[uid] = bindKey;
1922 localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(m));
1923}
1924async function verifyInviteBindings() {
1925 const m = loadInviteBinds();
1926 const uids = Object.keys(m);
1927 if (!uids.length) return;
1928 let unverified = 0;
1929 const next = {}; // rebuilt so members who've LEFT the roster are pruned (bounded growth)
1930 for (const uid of uids) {
1931 const e = roster.find((x) => x.user_id === uid);
1932 if (!e) continue; // member gone (removed) — drop the stale stash
1933 // Keep the bind key as long as the member is present and re-verify on EVERY
1934 // roster load, NOT once: a compromised server can substitute the key at any
1935 // later wrap (a new group, a tier re-key), so a one-shot check would miss it.
1936 next[uid] = m[uid];
1937 if (!e.public_key) continue; // hasn't published a key yet — check next time
1938 if (!e.bind_mac) {
1939 // Keyed but no MAC. Benign during rollout (a pre-KT joiner), but for
1940 // someone WE invited post-rollout it's a possible downgrade — surface it
1941 // in /integrity (advisory), don't banner (rollout would be too noisy).
1942 unverified++;
1943 continue;
1944 }
1945 let ok = false;
1946 try {
1947 ok = await verifyBindMac(m[uid], e.public_key, e.bind_mac);
1948 } catch {
1949 continue;
1950 }
1951 if (!ok) {
1952 integrity.recordAlert("key-substitution", uid);
1953 renderIntegrityBanner();
1954 }
1955 }
1956 // Hard backstop on growth (object keeps insertion order → drop the oldest).
1957 const keep = Object.keys(next);
1958 if (keep.length > INVITE_BINDS_MAX) {
1959 for (const k of keep.slice(0, keep.length - INVITE_BINDS_MAX)) delete next[k];
1960 }
1961 bindUnverifiedCount = unverified;
1962 localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(next));
1963}
1964
1965// People's display names arrive encrypted under the instance key (label_blob).
1966// Decrypt them into .label so the synchronous rosterLabel() can read them. Bots
1967// carry a plaintext .label and no blob; a not-yet-migrated person keeps whatever
1968// plaintext .label they still have as a fallback.
1969async function decryptRosterLabels() {
1970 if (!instanceKey) return;
1971 const before = new Set(roster.map((e) => e.label).filter(Boolean));
1972 await Promise.all(
1973 roster.map(async (e) => {
1974 if (e.label_blob) {
1975 try {
1976 e.label = await decrypt(instanceKey, e.label_blob);
1977 } catch {}
1978 }
1979 if (e.avatar_alt_blob) {
1980 try {
1981 e.avatarAlt = await decrypt(instanceKey, e.avatar_alt_blob);
1982 } catch {}
1983 }
1984 if (e.description_blob) {
1985 try {
1986 e.about = await decrypt(instanceKey, e.description_blob);
1987 } catch {}
1988 }
1989 }),
1990 );
1991 backfillLegacyLabels();
1992 // A chat message is scanned for @mentions against host.mentionNames() only
1993 // once, when it first renders (chat.js's keyedList never revisits an
1994 // unchanged row) — so a message mentioning someone who wasn't yet
1995 // mentionable at render time stays plain, unbolded text forever unless
1996 // something repaints it. A roster label newly resolving here — a fresh
1997 // joiner, or a name that just finished decrypting — is exactly that
1998 // moment; hand the newly-known names to chat.js so it can repaint just the
1999 // messages that actually reference one (issue #249).
2000 const newNames = [];
2001 for (const e of roster) if (e.label && !before.has(e.label)) newNames.push(e.label);
2002 if (newNames.length) repaintMentionsOf(newNames);
2003}
2004
2005// Seal legacy plaintext names (docs/no-plaintext.md): a person whose name
2006// predates label_blob still has it readable at rest until THEY next load the
2007// app — which an inactive member never does. We hold the instance key, so seal
2008// it for them; the server's gate is strictly fill-once (people only, plaintext
2009// still present, no existing blob), so this client only re-encodes the name
2010// the server already stored. (The server can't verify blob content — the
2011// honest-member assumption here is stated at postLabelBackfill in main.go.)
2012// Fire-and-forget.
2013const labelBackfillSent = new Set();
2014function backfillLegacyLabels() {
2015 if (!instanceKey) return;
2016 for (const e of roster) {
2017 if (e.is_agent || e.label_blob || !e.label || labelBackfillSent.has(e.user_id)) continue;
2018 labelBackfillSent.add(e.user_id);
2019 encrypt(instanceKey, e.label)
2020 .then((blob) =>
2021 fetch("/api/label/backfill", {
2022 method: "POST",
2023 headers: { "Content-Type": "application/json" },
2024 body: JSON.stringify({ token: session.token, user_id: e.user_id, label_blob: blob }),
2025 }),
2026 )
2027 .catch(() => {});
2028 }
2029}
2030
2031// The name outgoing payloads stamp. session.name is THIS DEVICE's copy, and it
2032// is routinely empty: the chat-first join claims nameless and lets Eleven ask
2033// in-chat (loadSession says so), and a member who answered on another device
2034// leaves this one blank forever. Everything stamped in that window reads as
2035// "Someone" wherever the payload's own name is the only source (sidebar
2036// previews, push copy). So fall back to my PUBLISHED roster label — never a
2037// nickname: this rides where other members read it. Local name first, so a
2038// rename takes effect before its label finishes publishing.
2039function stampName() {
2040 if (session.name) return session.name;
2041 const me = roster.find((e) => e.user_id === myUserId);
2042 return (me && me.label) || "";
2043}
2044
2045// Publish our own display name, encrypted under the instance key — for a fresh
2046// joiner, and to migrate anyone whose name is still stored in plaintext. The
2047// server only ever stores the blob.
2048async function publishMyLabel() {
2049 if (!instanceKey || !myUserId || !session.name) return;
2050 const me = roster.find((e) => e.user_id === myUserId);
2051 if (me && me.label_blob && me.label === session.name) return; // already correct
2052 try {
2053 const blob = await encrypt(instanceKey, session.name);
2054 await fetch("/api/label", {
2055 method: "POST",
2056 headers: { "Content-Type": "application/json" },
2057 body: JSON.stringify({ token: session.token, label_blob: blob }),
2058 });
2059 if (me) {
2060 me.label = session.name;
2061 me.label_blob = blob;
2062 }
2063 } catch {}
2064}
2065
2066// Publish our "about me" text, encrypted under the instance key ("" clears it).
2067// The server stores only the blob and serves it to our connections, who decrypt
2068// it like the display name.
2069async function publishMyDescription(text) {
2070 if (!instanceKey || !myUserId) return;
2071 try {
2072 const blob = text ? await encrypt(instanceKey, text) : "";
2073 await fetch("/api/description", {
2074 method: "POST",
2075 headers: { "Content-Type": "application/json" },
2076 body: JSON.stringify({ token: session.token, description_blob: blob }),
2077 });
2078 const me = roster.find((e) => e.user_id === myUserId);
2079 if (me) {
2080 me.about = text;
2081 me.description_blob = blob;
2082 }
2083 } catch {}
2084}
2085
2086// ---- per-viewer names (#577) ---------------------------------------------
2087// What *I* call people, independent of the name they publish: uid → { name,
2088// seen } (see ui.js displayNameFor/renameNotice for the two rules). The whole
2089// map is one blob sealed to MY OWN public key (sealForPub) — deliberately
2090// stricter than the instance-key blobs: my names for people are mine, not the
2091// group's, so other members can't open it. Devices share the keypair, so it
2092// syncs across my devices for free. The native embed never holds userPriv, so
2093// there this stays empty and published names render (native parity follows).
2094// Ops this device has made that the server copy may not have yet: uid → the
2095// entry to write, or null for "cleared". Saves re-read the server blob and
2096// replay only these on top — so a stale tab never clobbers names set on
2097// another device, and a boot whose load failed can't turn the next save into
2098// a wipe of everything stored. Ops stay queued (and retry) until a save lands.
2099const nicknameOps = new Map();
2100let nickRetryTimer = 0;
2101// The server blob's version at our last read — sent back with each save so a
2102// stale write 409s (compare-and-swap) instead of racing another device.
2103let nicknamesVersion = 0;
2104
2105// The server's current map, or null when it can't be known right now (fetch
2106// failed, blob won't open, no key). null means "don't write" — never guess.
2107async function fetchServerNicknames() {
2108 if (!session.userPriv) return null;
2109 try {
2110 const r = await fetch(`/api/nicknames?token=${encodeURIComponent(session.token)}`);
2111 if (!r.ok) return null;
2112 const body = await r.json();
2113 bootRaw.nick = { version: body.version || 0, nicknames_blob: body.nicknames_blob || "" };
2114 scheduleStoreSnapshot();
2115 return await applyNicknamesBody(body);
2116 } catch {
2117 return null;
2118 }
2119}
2120
2121// Decode a /api/nicknames body (sealed to our own key) — shared with the
2122// cached-snapshot hydrate.
2123async function applyNicknamesBody(body) {
2124 nicknamesVersion = body.version || 0;
2125 if (!body.nicknames_blob) return {};
2126 const obj = await openSealed(body.nicknames_blob, session.userPriv);
2127 return (obj && obj.names) || {};
2128}
2129
2130async function loadNicknames() {
2131 const server = await fetchServerNicknames();
2132 if (server) setNicknames(server);
2133}
2134
2135// Read-modify-write: re-read the server copy and replay only this device's
2136// pending ops on top, then seal + store. Returns false — leaving the ops
2137// queued and a retry scheduled — when the read or the write fails. Saves are
2138// serialized on a chain: two quick renames must not interleave their
2139// fetch/POST pairs (the second's op would be dropped by the first's cleanup).
2140let nickSaveChain = Promise.resolve(true);
2141function saveNicknames() {
2142 nickSaveChain = nickSaveChain.then(saveNicknamesNow, saveNicknamesNow);
2143 return nickSaveChain;
2144}
2145
2146async function saveNicknamesNow() {
2147 if (!session.userPriv || !session.userPub) return true;
2148 if (!nicknameOps.size) return true;
2149 const included = [...nicknameOps.entries()];
2150 const base = await fetchServerNicknames();
2151 if (!base) return queueNickRetry();
2152 const baseVersion = nicknamesVersion; // pin the version this merge is built on
2153 const merged = { ...base };
2154 for (const [uid, entry] of included) {
2155 if (entry) merged[uid] = entry;
2156 else delete merged[uid];
2157 }
2158 try {
2159 const blob = await sealForPub(session.userPub, { v: 1, names: merged });
2160 const r = await fetch("/api/nicknames", {
2161 method: "POST",
2162 headers: { "Content-Type": "application/json" },
2163 body: JSON.stringify({ token: session.token, nicknames_blob: blob, version: baseVersion }),
2164 });
2165 // A 409 means another device wrote between our read and this write — the
2166 // queued ops retry over a fresh read (read-modify-write converges).
2167 if (!r.ok) return queueNickRetry();
2168 nicknamesVersion = ((await r.json()) || {}).version || baseVersion + 1;
2169 setNicknames(merged); // adopt anything another device stored meanwhile too
2170 // Clear only what this save wrote — an op re-set mid-save stays queued.
2171 for (const [uid, entry] of included) if (nicknameOps.get(uid) === entry) nicknameOps.delete(uid);
2172 return true;
2173 } catch {
2174 return queueNickRetry();
2175 }
2176}
2177
2178// A failed save must not be a silent loss: keep trying while ops are queued.
2179// Always returns false so saveNicknames' failure paths read as one-liners.
2180function queueNickRetry() {
2181 clearTimeout(nickRetryTimer);
2182 nickRetryTimer = setTimeout(async () => {
2183 if (nicknameOps.size && (await saveNicknames())) await refreshNamesUI();
2184 }, 5000);
2185 return false;
2186}
2187
2188// Set (or with "" clear) my name for someone, stamping `seen` with their
2189// current published label so a later rename of theirs prompts me exactly once.
2190// Returns whether the save has landed (false = applied locally, still syncing).
2191async function setNickname(uid, name) {
2192 const e = roster.find((x) => x.user_id === uid);
2193 const entry = name ? { name, seen: (e && e.label) || "" } : null;
2194 if (entry) nicknames[uid] = entry;
2195 else delete nicknames[uid];
2196 nicknameOps.set(uid, entry);
2197 const ok = await saveNicknames();
2198 await refreshNamesUI();
2199 return ok;
2200}
2201
2202// Acknowledge a rename: keep my name but stop prompting (keep=true), or drop
2203// my name and follow theirs from now on (keep=false).
2204async function resolveRename(uid, keep) {
2205 const e = roster.find((x) => x.user_id === uid);
2206 const nick = nicknames[uid];
2207 if (!nick) return true;
2208 const entry = keep ? { name: nick.name, seen: (e && e.label) || nick.seen } : null;
2209 if (entry) nicknames[uid] = entry;
2210 else delete nicknames[uid];
2211 nicknameOps.set(uid, entry);
2212 const ok = await saveNicknames();
2213 await refreshNamesUI();
2214 return ok;
2215}
2216
2217
2218// The instance key is a single AES key shared by all members, used only for
2219// all-members content (profile photos). It's wrapped to each member's user
2220// pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if
2221// granted; bootstrap it if nobody has it yet; and grant it to any member missing
2222// it (so it propagates as people join). Never used for messages.
2223// Holder liveness from the last keyless /api/instance-key read (#785): are
2224// any key holders online, and when was one last around. Feeds the honest
2225// invite copy below; null until a keyless read reports it.
2226let keyHolderState = null;
2227
2228async function setupInstanceKey() {
2229 let r;
2230 try {
2231 r = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json();
2232 } catch {
2233 return;
2234 }
2235 if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 };
2236 if (r.wrapped) {
2237 bootRaw.instance = { wrapped: r.wrapped };
2238 scheduleStoreSnapshot();
2239 try {
2240 const b64 = await unwrapThreadKey(r.wrapped, session.userPriv);
2241 setInstanceKey(await importKey(b64), b64);
2242 } catch {
2243 return;
2244 }
2245 await grantInstanceKeyTo(r.have || []);
2246 } else if (!(r.have || []).length) {
2247 const k = await generateKey(); // first member: bootstrap the instance key
2248 setInstanceKey(k.key, k.b64);
2249 await grantInstanceKeyTo([]);
2250 } else {
2251 // It exists but we don't hold a copy yet. Nudge any online holder to wrap it
2252 // to us, then poll to pick up the grant — and KEEP re-nudging (on focus, and
2253 // on a slow timer) until it lands: the first holder to see a nudge may come
2254 // online long after the first poll would have expired, and a keyless member
2255 // renders everyone as "Someone" until this succeeds.
2256 const nudge = () => {
2257 fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
2258 pollForInstanceKey(4);
2259 };
2260 const onFocus = () => (instanceKey ? stopRetry() : nudge());
2261 const timer = setInterval(() => (instanceKey ? stopRetry() : nudge()), 60000);
2262 const stopRetry = () => {
2263 clearInterval(timer);
2264 removeEventListener("focus", onFocus);
2265 };
2266 addEventListener("focus", onFocus);
2267 fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
2268 pollForInstanceKey(24);
2269 }
2270}
2271
2272// After nudging holders, poll for a wrapped instance key; once granted, unwrap it
2273// and re-render avatars. (The open Edit-profile sheet picks it up on next open.)
2274async function pollForInstanceKey(tries = 24) {
2275 for (let i = 0; i < tries && !instanceKey; i++) {
2276 await new Promise((res) => setTimeout(res, 2500));
2277 if (instanceKey) return;
2278 let r;
2279 try {
2280 r = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json();
2281 } catch {
2282 continue;
2283 }
2284 if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 };
2285 if (r.wrapped) {
2286 try {
2287 const b64 = await unwrapThreadKey(r.wrapped, session.userPriv);
2288 setInstanceKey(await importKey(b64), b64);
2289 await onInstanceKeyReady();
2290 } catch {}
2291 return;
2292 }
2293 }
2294}
2295
2296// ---- thread data, instance key & app lifecycle ----------------------------
2297// Flip the open Edit-profile sheet's photo control from "Photo syncing…" to
2298// "Change photo" the moment the instance key arrives (live, no reload needed).
2299function refreshProfilePhotoBtn() {
2300 const b = document.getElementById("me-photo-btn");
2301 if (b && instanceKey) {
2302 b.disabled = false;
2303 b.textContent = "Change photo";
2304 }
2305 // The "About you" field in the same sheet waits on the key too.
2306 const a = document.getElementById("me-about");
2307 if (a && instanceKey && a.disabled) {
2308 a.disabled = false;
2309 a.placeholder = "A line or two about yourself";
2310 const e = roster.find((x) => x.user_id === myUserId);
2311 if (!a.value) a.value = (e && e.about) || "";
2312 }
2313}
2314
2315// Wrap the instance key to every roster member not already in `have`.
2316async function grantInstanceKeyTo(have) {
2317 if (!instanceKeyB64) return;
2318 const haveSet = new Set(have);
2319 const grants = [];
2320 for (const e of roster) {
2321 // Skip keyless members — a pending invitee who hasn't joined yet has no
2322 // public key to wrap to (connections() returns them for pending-DM names).
2323 // Without this guard wrapThreadKey's importKey throws DataError and aborts
2324 // boot for *everyone*; they pick up the key from a holder's backfill once
2325 // they join and publish one.
2326 if (e.public_key && !haveSet.has(e.user_id)) {
2327 grants.push({ user_id: e.user_id, wrapped: await wrapThreadKey(instanceKeyB64, e.public_key) });
2328 }
2329 }
2330 if (grants.length) {
2331 await fetch("/api/instance-key", {
2332 method: "POST",
2333 headers: { "Content-Type": "application/json" },
2334 body: JSON.stringify({ token: session.token, grants }),
2335 });
2336 }
2337}
2338
2339async function loadThreads() {
2340 const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json();
2341 if (Array.isArray(r.threads) && !embed) {
2342 // Snapshot before apply mutates entries (e.g. the open_key "set" gate fix).
2343 bootRaw.threads = structuredClone(r);
2344 scheduleStoreSnapshot();
2345 // Threads we've left / been removed from must not keep readable local rows
2346 // beyond what this device's keys already imply — prune the store to the
2347 // live list (an error body never reaches here, so it can't wipe the cache).
2348 storeCachedThreadIds()
2349 .then((ids) => Promise.all(staleThreadIds(ids, r).map((id) => storeClearThread(id))))
2350 .catch(() => {});
2351 }
2352 await applyThreads(r);
2353}
2354
2355// The apply half of loadThreads (unwrap keys → titles → previews → dots) —
2356// also run over the cached response on boot, so hydration and the network
2357// path are one code path.
2358async function applyThreads(r) {
2359 setThreads(r.threads || []);
2360 // Threads may land after the roster on a fresh boot — re-apply the
2361 // inviter-name hint (#1436) now that the hinted DM's members are known,
2362 // BEFORE titles compute below, so the DM lists under the inviter's name.
2363 applyInviterNameHint();
2364 for (const t of threads) {
2365 if (!threadKeys[t.id] && (t.wrapped || t.open_key)) {
2366 // Two key sources: the per-member wrapped copy, then — for open groups —
2367 // open_key (the thread key encrypted under the instance key). open_key is
2368 // a true fallback, not an else: a bad wrapped blob must not hide an open
2369 // group the instance key can decrypt. If the instance key hasn't arrived
2370 // yet, skip for now; boot / onInstanceKeyReady re-fold the list.
2371 let b64 = null;
2372 if (t.wrapped) {
2373 try {
2374 b64 = await unwrapThreadKey(t.wrapped, session.userPriv);
2375 } catch (e) {
2376 console.error("unwrap thread", t.id, e);
2377 }
2378 }
2379 if (!b64 && t.open_key) {
2380 // The open_key's wrapping key depends on the gate: a level-gated
2381 // group's rides under that tier's key, an ungated one under the
2382 // instance key. Either may not have arrived yet — boot and the
2383 // key-granted prods re-fold when it does.
2384 const src = t.open_tier ? tierKeys[t.open_tier] : instanceKey;
2385 if (src) {
2386 try {
2387 b64 = (await decrypt(src, t.open_key)).k;
2388 } catch (e) {
2389 console.error("unwrap open thread", t.id, e);
2390 }
2391 }
2392 }
2393 if (!b64) continue;
2394 threadKeys[t.id] = await importKey(b64);
2395 threadKeyB64[t.id] = b64;
2396 }
2397 threadName[t.id] = await displayName(t);
2398 hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list
2399 pinned[t.id] = !!t.pinned; // pinned chats (server-synced) float to the top
2400 muted[t.id] = !!t.muted; // muted chats (server-synced) stay quiet
2401 dmClosed[t.id] = !!t.dm_closed; // ended DMs show the closed banner, not a composer
2402 // The server's watermark can trail a POST we just made — never regress ours.
2403 readMarks[t.id] = Math.max(readMarks[t.id] || 0, t.last_read_at || 0);
2404 if (t.last_blob && threadKeys[t.id]) {
2405 try {
2406 const payload = takeGossip(await decrypt(threadKeys[t.id], t.last_blob), false);
2407 // A typed app event (e.g. vault.add) has no .text — previewText would
2408 // read it as an empty chat message and render "Name: " with nothing
2409 // after the colon. Route it through the OWNING APP's preview hook
2410 // instead (same as the live path below), leaving the row's stale
2411 // preview in place when the app declares none (silent, not blank).
2412 if (isAppType(payload.type)) {
2413 const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId });
2414 if (pv) threadPreview[t.id] = pv;
2415 } else {
2416 // Anything else — a chat message, or a trailing reaction event, which
2417 // previewText speaks as "James reacted 👍 to …" rather than the blank
2418 // row it used to leave behind.
2419 previewPayload[t.id] = payload;
2420 threadPreview[t.id] = previewText(t, payload);
2421 }
2422 // Seed the unread dot from the synced watermark, applying the same
2423 // gates as a live frame: your own last message never lights it (you
2424 // sent it, or replying elsewhere means you read it), and app events
2425 // only light it if the app says so. A silent app event neither lights
2426 // nor clears — an earlier lit dot stays lit, matching the live path.
2427 if (t.id !== activeId) {
2428 if ((t.last_at || 0) <= readMarks[t.id] || ownMessage(payload, myUserId, session.name)) {
2429 unread[t.id] = false;
2430 } else if (payload.type === "reaction") {
2431 // Someone reacting never lights the dot (matching the live path);
2432 // an earlier lit dot stays lit.
2433 } else if (!isAppType(payload.type) || appUnread(payload)) {
2434 unread[t.id] = true;
2435 }
2436 }
2437 } catch {}
2438 }
2439 }
2440 syncThreadKeysToSW();
2441}
2442
2443// Advance this user's read watermark for a thread to its newest message — the
2444// server relays it to their other devices so the dot clears everywhere, and
2445// remembers it so a reload doesn't resurrect it. Local-first and deduped: the
2446// mark moves before the POST, and an already-current mark makes this a no-op
2447// (so calling it on every live message while a thread is open stays cheap).
2448function markThreadRead(id) {
2449 const t = threads.find((x) => x.id === id);
2450 const at = (t && t.last_at) || 0;
2451 if (!at || at <= (readMarks[id] || 0)) return;
2452 readMarks[id] = at;
2453 fetch(`/api/threads/${encodeURIComponent(id)}/read`, {
2454 method: "POST",
2455 headers: { "Content-Type": "application/json" },
2456 body: JSON.stringify({ token: session.token, last_read_at: at }),
2457 }).catch(() => {});
2458}
2459
2460// Hand the service worker the per-thread keys (+ name/kind) it needs to decrypt
2461// push payloads. Stored in IndexedDB (the SW can't read localStorage).
2462function syncThreadKeysToSW() {
2463 const map = {};
2464 for (const t of threads) {
2465 if (threadKeyB64[t.id]) {
2466 map[t.id] = { key: threadKeyB64[t.id], name: threadName[t.id] || "Chat", kind: t.kind };
2467 }
2468 }
2469 idbSet("threadKeys", map).catch(() => {});
2470 // The SW also needs to know who "me" is: reaction pushes are shown only to
2471 // the reacted-to message's author (see sw.js), decided after decryption.
2472 if (myUserId) idbSet("myUid", myUserId).catch(() => {});
2473 // Names for the SW's notification text (#577): a sender resolves by uid to
2474 // the name I show for them, and @mentions in the body rewrite to my names.
2475 // Pre-resolved here (the SW holds no keys for the nicknames blob) — kept
2476 // fresh by refreshNamesUI, which calls this on every name change.
2477 const byUid = {};
2478 for (const e of roster) {
2479 const n = displayNameFor(nicknames[e.user_id], e.label);
2480 if (n) byUid[e.user_id] = n;
2481 }
2482 idbSet("memberNames", { byUid, renames: nicknameRenames() }).catch(() => {});
2483 // Mute state for the SW's own notification gate. The server already skips
2484 // pushes for muted THREADS; muted PEOPLE are invisible to it by design, so
2485 // the sender check after decryption is the only place that one can run.
2486 const mutedThreads = Object.keys(muted).filter((id) => muted[id]);
2487 idbSet("muted", { threads: mutedThreads, people: [...mutedPeople] }).catch(() => {});
2488}
2489
2490// The frontend build this page loaded with (server-injected into the HTML).
2491const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).content || "";
2492
2493// Whether ordinary members may invite people (server-injected admin setting). Gates
2494// the invite UI only; the server enforces it regardless. Kept live by
2495// "user_invites_changed" below (issue #346) — an admin flipping the toggle
2496// used to only take effect for connected members after a reload.
2497let USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1";
2498
2499// Per-instance feature gates (server-injected admin settings). Default ON — a
2500// missing meta (an older server) reads as enabled — and, like USER_INVITES,
2501// they only hide entry points; the server enforces each gate. ENABLE_AI has no
2502// web entry point to hide (the sidecar enforces it), but rides along so copy
2503// can consult it.
2504
2505// Whether THIS member may invite: the toggle governs ordinary members, but an
2506// admin always can — otherwise the person who just set the space up couldn't
2507// bring anyone in without a trip to the admin console (a path we're
2508// sunsetting). Live read, so it's correct once the roster (which carries our
2509// role) has loaded.
2510function inviteAllowed() {
2511 if (USER_INVITES) return true;
2512 const me = roster.find((e) => e.user_id === myUserId);
2513 return !!(me && me.role === "admin");
2514}
2515
2516// Whether THIS member holds the admin role — gates the Admin app's entry
2517// points in the pickers. A UI nicety, not security: the Admin app re-checks
2518// the role through its host bridge (/api/me) before doing anything.
2519function isAdmin() {
2520 const me = roster.find((e) => e.user_id === myUserId);
2521 return !!(me && me.role === "admin");
2522}
2523
2524// True when a composer holds unsent text — reload paths check this so picking up
2525// an update never eats someone's draft. Covers every mounted chat-app composer
2526// (Split view shows two).
2527function hasUnsentDraft() {
2528 // The chat composer(s) live behind the composer.js facade — Split view can
2529 // mount two, and the rich backend isn't a textarea. Same semantics as the
2530 // old DOM query: only MOUNTED composers block (parked panes never did).
2531 return anyComposerHasText();
2532}
2533
2534// On returning to the app, ask the server for the current build hash; if it shipped
2535// a newer frontend, reload to pick it up — unless that would clobber an unsent draft.
2536let updateChecking = false;
2537async function checkForUpdate() {
2538 if (!APP_VERSION || updateChecking) return;
2539 updateChecking = true;
2540 try {
2541 const r = await fetch("/api/version", { cache: "no-store" });
2542 if (r.ok) {
2543 const v = (await r.json()).version;
2544 if (v && v !== APP_VERSION && !hasUnsentDraft()) location.reload();
2545 }
2546 } catch {
2547 } finally {
2548 updateChecking = false;
2549 }
2550}
2551
2552// Heartbeat so push is suppressed while the app is open: write a timestamp the SW
2553// checks, and tell the server this device is active (mutes its other devices).
2554function startHeartbeat() {
2555 const beat = () => {
2556 if (document.visibilityState !== "visible") return;
2557 idbSet("lastActiveAt", Date.now()).catch(() => {});
2558 if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "active" }));
2559 };
2560 beat();
2561 setInterval(beat, 30000);
2562 document.addEventListener("visibilitychange", beat);
2563 clearNotifications();
2564 // Returning to the app (tab/window visible again) → check for a newer build,
2565 // and catch up on any messages missed while hidden. This is what fixes a tapped
2566 // notification landing on a stale thread: the socket may still look OPEN (the OS
2567 // froze it without firing onclose), so a reconnect never fires — but refocus
2568 // always does, and re-fetches the missed messages.
2569 document.addEventListener("visibilitychange", () => {
2570 if (document.visibilityState === "visible") {
2571 checkForUpdate();
2572 resyncActive(); // its tail marks the active thread read once caught up
2573 focusChatComposer();
2574 clearNotifications();
2575 }
2576 });
2577 // ...and when the window itself regains focus (clicking back into the app from
2578 // another window/app, where the tab stayed "visible" so the handler above
2579 // doesn't fire). Both routes drop the caret into the composer so you're ready
2580 // to type — the Mac app does the same on didBecomeActive.
2581 window.addEventListener("focus", () => {
2582 focusChatComposer();
2583 onSawActiveThread();
2584 });
2585}
2586
2587// Coming back to the app with a thread open means you've now seen whatever
2588// arrived in it while you were away — advance its synced read watermark
2589// (messages landing in the ACTIVE thread never set the local dot, so without
2590// this the user's other devices would keep showing one).
2591function onSawActiveThread() {
2592 if (activeId) markThreadRead(activeId);
2593}
2594
2595// Being in the app means you've seen what the banners were shouting about: close
2596// any lchat notifications still sitting in the shade. Same sweep a notification
2597// tap does in sw.js — the shared "lchat-message" tag means there's at most one,
2598// covering whichever thread pushed last, so closing all loses nothing.
2599function clearNotifications() {
2600 if (!("serviceWorker" in navigator)) return;
2601 navigator.serviceWorker.getRegistration().then(async (reg) => {
2602 if (!reg) return;
2603 for (const n of await reg.getNotifications()) n.close();
2604 }).catch(() => {});
2605}
2606
2607// Drop the caret into the live chat composer (the id-less chat.js <textarea>
2608// inside .chatapp-root) when the app regains focus. Desktop/Mac only: on touch,
2609// refocusing would pop the on-screen keyboard up on every foreground. Scoped by
2610// selector rather than a stashed reference so it always targets the currently
2611// connected root (survives the instant-restore path, which re-attaches a parked
2612// root without rebuilding). Exposed as a global so the Mac shell can call it too
2613// — it runs the full web UI, not the embed-only window.lchatNative bridge.
2614function focusChatComposer() {
2615 if (isCoarse) return;
2616 const ta = document.querySelector(".chatapp-root .composer .composer-input");
2617 if (!ta || ta === document.activeElement) return;
2618 // Don't yank the caret out of another field the user left mid-edit (search,
2619 // an inline rename, …) — only auto-focus when nothing editable holds focus.
2620 const active = document.activeElement;
2621 if (
2622 active &&
2623 active !== document.body &&
2624 (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)
2625 )
2626 return;
2627 ta.focus();
2628}
2629window.lchatFocusComposer = focusChatComposer;
2630
2631// ⌘K palette bridge (Mac app). The native command palette asks the web layer
2632// what it can offer — the chats on this server plus the actions available to
2633// this member — and calls back with a pick. Labels and behaviour live HERE
2634// (one source of truth, like the native-menu bridge); native only draws rows.
2635// Returns null until the app has booted, so a join/onboarding page offers
2636// nothing. Kept as data-out + id-back so no web internals leak across.
2637window.lchatPaletteItems = () => {
2638 if (!myUserId) return null;
2639 // Same set + order as the sidebar: closed chats dropped, pinned first, then
2640 // most-recent-first — so the palette's top hits mirror the list you know.
2641 const recencyOf = (t) => t.last_at || t.created_at || 0;
2642 const shown = pinnedFirst(
2643 threads.filter((t) => !hidden[t.id]),
2644 (t) => pinned[t.id],
2645 recencyOf,
2646 );
2647 const chats = shown.map((t) => ({
2648 id: t.id,
2649 name: threadName[t.id] || "Chat",
2650 glyph: threadApp[t.id] ? (appByName(threadApp[t.id]) || {}).glyph || "▦" : null,
2651 active: t.id === activeId,
2652 }));
2653 // Only actions that would actually work right now: a group needs someone to
2654 // add, invites need the admin's say-so, apps need at least one registered.
2655 const others = roster.some((e) => e.user_id !== myUserId && e.public_key);
2656 const actions = [{ id: "new-chat", label: "New chat" }];
2657 if (others) actions.push({ id: "new-group", label: "New group" });
2658actions.push({ id: "discover", label: "Discover groups" });
2659 // Membership: only when the space sells any level, or you already subscribe.
2660 if (tiers.some((t) => t.price_cents > 0 || t.status)) actions.push({ id: "membership", label: "Membership" });
2661 if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" });
2662 if (ENABLE_APPS && pickerApps().length) actions.push({ id: "apps", label: "Open apps" });
2663 // Deliberately outside the apps gate: Admin is how an admin would switch
2664 // apps back on, so it must stay reachable when they're off.
2665 if (isAdmin()) actions.push({ id: "admin", label: "Open admin" });
2666 actions.push(
2667 { id: "add-device", label: "Add a device" },
2668 { id: "theme", label: "Theme" },
2669 { id: "edit-profile", label: "Edit profile" },
2670 { id: "leave", label: "Leave", danger: true },
2671 );
2672 return { chats, actions };
2673};
2674window.lchatPaletteRun = (kind, id) => {
2675 if (kind === "chat") return void openThreadById(id);
2676 const run = {
2677 "new-chat": () => showNewChat(),
2678 "new-group": () => showNewChat("group"),
2679 discover: showDiscover,
2680 membership: showMembership,
2681 invite: () => showNewChat("invite"),
2682 apps: showApps,
2683 admin: async () => {
2684 const id = await resolveAppThread("admin");
2685 if (id) openThread(id);
2686 },
2687 "add-device": addDevice,
2688 theme: showThemePicker,
2689 "edit-profile": editProfile,
2690 leave,
2691 }[id];
2692 if (run) run();
2693};
2694
2695// A one-line snippet for the chat list: "Name: text" in groups, just text in DMs.
2696function previewText(t, data) {
2697 // The actor, resolved by uid like the bubble headers (#592) — my name for
2698 // them, their current name, then the payload's snapshot. Both the reaction
2699 // line and the group prefix below name them.
2700 const who = (data.uid && displayNameFor(nicknames[data.uid], (roster.find((x) => x.user_id === data.uid) || {}).label)) || data.name;
2701 // A reaction event: it has no text of its own, so say what happened —
2702 // "James reacted 👍 to “buy milk”" (#520 left this blank). It names the
2703 // actor itself, so it returns before the group prefix below.
2704 if (data.type === "reaction") return reactionLine(data.uid && data.uid === myUserId ? "You" : who, data);
2705 // Through the markdown plain projection, never raw: raw text leaks the
2706 // composer's opt-out escapes as visible backslashes (\*lit\*), and shows
2707 // the marks the bubble renders as styling.
2708 let body = mdPlainText(data.text || "").replace(/\s+/g, " ").trim();
2709 // The feed shows @mentions as the viewer's own names for people (#577); the
2710 // plain-text preview says the same thing (also rides into native notify).
2711 body = translateMentions(body, nicknameRenames());
2712 const atts = data.attachments || [];
2713 if (!body && atts.length) {
2714 const n = atts.length;
2715 if (atts.every((a) => a.type && a.type.startsWith("image/"))) {
2716 body = n > 1 ? `📷 ${n} photos` : "📷 Photo";
2717 } else {
2718 body = n > 1 ? `📎 ${n} files` : "📎 " + ((atts[0] && atts[0].name) || "File");
2719 }
2720 }
2721 // An action line ("Alice waves") already names the actor — skip the group
2722 // prefix so it doesn't double up ("Alice: Alice waves").
2723 return t.kind === "group" && who && !data.action ? `${who}: ${body}` : body;
2724}
2725
2726// Published label → my name for them, for every member I've renamed — the map
2727// translateMentions rewrites plain text with.
2728function nicknameRenames() {
2729 const map = {};
2730 for (const e of roster) {
2731 const nick = nicknames[e.user_id];
2732 if (nick && nick.name && e.label && nick.name !== e.label) map[e.label] = nick.name;
2733 }
2734 return map;
2735}
2736
2737// Native (Eleven) local notification: inside the Mac wrapper, hand a decrypted
2738// preview to the app so it posts a system notification. No-op on the web (there the
2739// service worker handles push). Suppresses your own messages and the thread you're
2740// actively reading in a focused window.
2741function maybeNativeNotify(tid, data, preview) {
2742 const bridge = window.webkit?.messageHandlers?.lchatNotify;
2743 if (!bridge) return;
2744 if (ownMessage(data, myUserId, session.name)) return;
2745 if (tid === activeId && document.hasFocus()) return;
2746 try {
2747 bridge.postMessage({ title: threadName[tid] || "New message", body: preview, thread: tid });
2748 } catch (e) {}
2749}
2750
2751async function displayName(t) {
2752 if (t.kind === "self") {
2753 // A self thread may be a mini-app: its app id rides encrypted in name_blob
2754 // (the server can't tell a todo from a poll from plain "Notes"). Decrypt to
2755 // find out which app to render; a bare self thread is "Notes".
2756 if (t.name_blob && threadKeys[t.id]) {
2757 try {
2758 const d = await decrypt(threadKeys[t.id], t.name_blob);
2759 if (d && d.app) {
2760 threadApp[t.id] = d.app;
2761 const a = appByName(d.app);
2762 return a ? a.title || a.name : d.app;
2763 }
2764 } catch {}
2765 }
2766 return "Notes";
2767 }
2768 if (t.kind === "dm") {
2769 const other = (t.members || []).find((id) => id !== myUserId);
2770 return rosterLabel(other);
2771 }
2772 // group: an explicit name is encrypted in name_blob under the thread key;
2773 // with no name (or if it won't decrypt), fall back to the member names —
2774 // "Paul, Ciara & Luan", collapsing to "Paul, Ciara & 24 more" past three.
2775 const fallback = autoGroupTitle((t.members || []).filter((id) => id !== myUserId).map(rosterLabel));
2776 if (t.name_blob && threadKeys[t.id]) {
2777 try {
2778 return (await decrypt(threadKeys[t.id], t.name_blob)).name || fallback;
2779 } catch {
2780 return fallback;
2781 }
2782 }
2783 return fallback;
2784}
2785
2786// threadName[] is cached from the roster labels available at load time. Those
2787// names are encrypted under the instance key, so a new joiner who receives the
2788// key late (or anyone getting a live label update) has stale titles. Re-derive
2789// them from the current roster labels.
2790async function recomputeThreadNames() {
2791 await Promise.all(threads.map(async (t) => (threadName[t.id] = await displayName(t))));
2792 // Snippets bake names in too (sender prefix, @mentions) — re-derive them
2793 // from their retained payloads with the labels/nicknames now in hand.
2794 for (const t of threads) if (previewPayload[t.id]) threadPreview[t.id] = previewText(t, previewPayload[t.id]);
2795}
2796
2797// Recompute titles and repaint everywhere a name shows — the chat list, the open
2798// thread header, and the service worker's notification titles. For use after the
2799// app shell exists (post-boot: the key arriving, or a live label event).
2800async function refreshNamesUI() {
2801 await recomputeThreadNames(); // also re-derives the baked-name snippets
2802 renderThreadList();
2803 if (activeId) {
2804 const el = document.getElementById("thread-title");
2805 if (el) el.textContent = threadName[activeId] || "Chat";
2806 // A label change may mean a DM partner renamed themself past my nickname
2807 // for them (#577) — offer the keep/adopt choice (or clear a settled one).
2808 updateRenameBanner(threads.find((x) => x.id === activeId));
2809 }
2810 syncThreadKeysToSW();
2811}
2812
2813// The instance key just became available AFTER the app shell was up — a new
2814// joiner received it late (via poll, or a holder granting it over the socket).
2815// Everything gated on the key can now run: decrypt others' names, publish our
2816// own (so the people we're talking to stop seeing "Someone"), and repaint.
2817async function onInstanceKeyReady() {
2818 if (!instanceKey) return;
2819 await decryptRosterLabels();
2820 // Open groups joined on another device were skipped at boot (their key
2821 // travels under the instance key) — re-fold the list now the key is here.
2822 await loadThreads();
2823 renderThreadList();
2824 // Member apps couldn't decrypt at boot without the key — register them now.
2825 await loadMemberAppsNow();
2826 // Same for the space thread: its key travels under the instance key too.
2827 await loadSpaceThread();
2828 await publishMyLabel();
2829 await refreshNamesUI();
2830 refreshAvatars();
2831 refreshProfilePhotoBtn();
2832}
2833
2834// ---- shell --------------------------------------------------------------
2835
2836function renderApp() {
2837 app.innerHTML = "";
2838 app.appendChild(tpl("app-tpl"));
2839 paintSpaceName(); // the template carries the served name; sealed branding may already have corrected the title
2840
2841 document.getElementById("new-chat").onclick = showNewChat;
2842 document.getElementById("back-btn").onclick = showList;
2843 wireSplitDivider();
2844
2845 // Filter the chat list as you type (name match only; never sent anywhere) —
2846 // and search MESSAGES too (#586): matches from the local store render below
2847 // the (filtered) chat list. All local: nothing typed here leaves the device.
2848 const filter = document.getElementById("chat-filter");
2849 if (filter)
2850 filter.oninput = () => {
2851 chatFilter = filter.value.trim().toLowerCase();
2852 renderThreadList();
2853 scheduleMessageSearch(filter.value.trim());
2854 };
2855
2856 // The funnel beside search: a small menu of list filters ("Show online
2857 // only" + People/Bots/Apps). Checkboxes apply live and the panel stays open
2858 // while ticking; it closes on outside click like the kebab. The funnel
2859 // highlights whenever any filter deviates from the show-everything default.
2860 const filterBtn = document.getElementById("filter-btn");
2861 const filterMenu = document.getElementById("filter-menu");
2862 if (filterBtn && filterMenu) {
2863 filterBtn.onclick = (e) => {
2864 e.stopPropagation();
2865 const open = filterMenu.classList.toggle("hidden");
2866 filterBtn.setAttribute("aria-expanded", String(!open));
2867 };
2868 document.addEventListener("click", (e) => {
2869 if (e.target !== filterBtn && !filterBtn.contains(e.target) && !filterMenu.contains(e.target)) {
2870 filterMenu.classList.add("hidden");
2871 filterBtn.setAttribute("aria-expanded", "false");
2872 }
2873 });
2874 for (const key of ["online", "people", "bots", "apps"]) {
2875 const cb = document.getElementById(`flt-${key}`);
2876 if (!cb) continue;
2877 cb.checked = chatFilters[key];
2878 cb.oninput = () => {
2879 chatFilters[key] = cb.checked;
2880 filterBtn.classList.toggle(
2881 "active",
2882 chatFilters.online || !chatFilters.people || !chatFilters.bots || !chatFilters.apps,
2883 );
2884 renderThreadList();
2885 };
2886 }
2887 }
2888
2889 // Kebab menu (beside +): add device / rename / leave.
2890 const menu = document.getElementById("menu");
2891 const menuBtn = document.getElementById("menu-btn");
2892 const closeMenu = () => menu.classList.add("hidden");
2893 menuBtn.onclick = (e) => {
2894 e.stopPropagation();
2895 if (nativeMenu) return openNativeMenu(menu, menuBtn); // native NSMenu on the Mac app
2896 menu.classList.toggle("hidden");
2897 };
2898 document.addEventListener("click", (e) => {
2899 if (e.target !== menuBtn && !menu.contains(e.target)) closeMenu();
2900 });
2901 const wireItem = (id, fn) =>
2902 (document.getElementById(id).onclick = () => {
2903 closeMenu();
2904 fn();
2905 });
2906 wireItem("menu-about", showSpaceInfo);
2907 // The Apps directory from the SPACE's own menu — reachable without standing
2908 // in any particular chat. An OPEN chat still counts as "This chat" in the
2909 // sheet (you're looking at it); with none open, that place dims and says so.
2910 // Part of the places UI (§ The pullback): with it hidden, the + picker
2911 // (personal copies) and each chat's ⋮ → Apps (this chat) are the only
2912 // doors, so the item hides entirely.
2913 if (PLACES_UI) wireItem("menu-apps", () => manageGroupApps({ space: true }));
2914 else document.getElementById("menu-apps").style.display = "none";
2915 wireItem("menu-theme", showThemePicker);
2916 wireItem("menu-add-device", addDevice);
2917 // Open in the Eleven app — the "Continue on web" regret path (an
2918 // add-device link fired over the app scheme; adddevice.js). Mac desktop
2919 // browsers only: the Mac app is the one scheme receiver today (iOS parity
2920 // tracked in the handoff plan), !isCoarse keeps iPads out (Safari there
2921 // reports MacIntel), and inside a native webview the item is noise.
2922 if (/Mac/i.test(navigator.platform || "") && !isCoarse
2923 && !document.documentElement.classList.contains("native")) {
2924 document.getElementById("menu-open-app").hidden = false;
2925 wireItem("menu-open-app", openInApp);
2926 }
2927 wireItem("menu-rename", editProfile);
2928 wireItem("menu-leave", leave);
2929
2930 // Upgrade — revealed only on a free cloud-provisioned space in a real
2931 // browser. The checkout lives on the home page (this server has no billing
2932 // surface), and the native wrappers must not link out to external purchases
2933 // (App Store rules), so they stay quiet. Self-hosted instances have no
2934 // __entitlement and never see this.
2935 const inNativeWrapper =
2936 document.documentElement.classList.contains("native") ||
2937 !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat);
2938 if (window.__entitlement === "free" && window.__homeOrigin && !inNativeWrapper) {
2939 // A real <a> (not a button + window.open): ad/privacy-blocker extensions
2940 // commonly swallow script-initiated window.open() popups silently — no
2941 // console error, no address-bar indicator — while leaving ordinary link
2942 // navigation alone. closeMenu() runs as a plain side effect; it never
2943 // calls preventDefault, so the browser still follows href natively.
2944 const upgradeLink = document.getElementById("menu-upgrade");
2945 upgradeLink.hidden = false;
2946 upgradeLink.href = window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host);
2947 upgradeLink.onclick = closeMenu;
2948 }
2949
2950 // Demo countdown band (docs/demo-spaces.md): a demo space wears its 24-hour
2951 // death across the top — disclosed from the first second, with the one happy
2952 // exit (claim it into an account, on the home page) a tap away. The boot-time
2953 // flag only says "born a demo": a claim flips the ledger WITHOUT restarting
2954 // this instance, so the HOME server's live status decides whether the band
2955 // shows at all, and hides it when the user comes back from claiming.
2956 if (window.__entitlement === "demo" && window.__homeOrigin && !embed) {
2957 const banner = document.getElementById("demo-banner");
2958 const text = document.createElement("span");
2959 text.className = "demo-banner-text";
2960 let ticker = 0;
2961 const dismiss = () => {
2962 banner.classList.add("hidden");
2963 if (ticker) clearInterval(ticker);
2964 ticker = 0;
2965 };
2966 const paint = (expiresAt) => {
2967 const left = expiresAt ? expiresAt * 1000 - Date.now() : NaN;
2968 if (Number.isNaN(left)) {
2969 text.textContent = "This is a demo space — it disappears 24 hours after it was created.";
2970 } else if (left <= 0) {
2971 text.textContent = "This demo has ended — the space will disappear shortly.";
2972 banner.classList.add("demo-over");
2973 } else {
2974 text.textContent = "This is a demo space — it disappears in " + formatCountdown(left) + ".";
2975 }
2976 };
2977 const check = async () => {
2978 let expiresAt = 0; // unix seconds; 0 = status unreachable (generic copy)
2979 try {
2980 const res = await fetch(
2981 window.__homeOrigin + "/api/spaces/demo-status?host=" + encodeURIComponent(location.host),
2982 );
2983 if (!res.ok) throw new Error();
2984 const body = await res.json();
2985 if (!body.demo) return dismiss(); // claimed — the space is safe now
2986 expiresAt = body.expires_at || 0;
2987 } catch {
2988 /* home unreachable — the death is still the contract; show the band */
2989 }
2990 if (!banner.contains(text)) {
2991 banner.append(text);
2992 // Claiming is free (not a purchase), but the native wrappers keep all
2993 // account plumbing out-of-app for now — same posture as Upgrade above.
2994 if (!inNativeWrapper) {
2995 const keep = document.createElement("a");
2996 keep.className = "demo-banner-keep";
2997 keep.textContent = "Keep this space";
2998 keep.href = window.__homeOrigin + "/#claim=" + encodeURIComponent(location.host);
2999 keep.target = "_blank";
3000 keep.rel = "noopener";
3001 banner.append(keep);
3002 }
3003 }
3004 paint(expiresAt);
3005 banner.classList.remove("hidden");
3006 if (ticker) clearInterval(ticker);
3007 if (expiresAt) ticker = setInterval(() => paint(expiresAt), 30_000);
3008 };
3009 check();
3010 // The claim happens in another tab; re-check when this one comes back into
3011 // view so the band vanishes the moment the space is actually theirs.
3012 // (renderApp can re-mount the template — a listener over a replaced band
3013 // retires itself via isConnected.)
3014 document.addEventListener("visibilitychange", () => {
3015 if (!banner.isConnected) return;
3016 if (document.visibilityState === "visible" && !banner.classList.contains("hidden")) check();
3017 });
3018 }
3019
3020 // Notifications toggle (hidden where push isn't usable, e.g. iOS before install).
3021 const notif = document.getElementById("menu-notif");
3022 const refreshNotif = async () => {
3023 if (!pushAvailable()) {
3024 notif.style.display = "none";
3025 return;
3026 }
3027 notif.textContent =
3028 pushEnabled() && (await isSubscribed()) ? "Turn off notifications" : "Turn on notifications";
3029 };
3030 refreshNotif();
3031 notif.onclick = async () => {
3032 closeMenu();
3033 if (pushDenied()) {
3034 alert("Notifications are blocked for this site in your browser settings.");
3035 return;
3036 }
3037 if (pushEnabled() && (await isSubscribed())) await unsubscribePush();
3038 else await subscribePush(session);
3039 refreshNotif();
3040 };
3041
3042 // "Me" avatar (top-left) → edit profile.
3043 paintAvatar(document.getElementById("me-avatar"), session.name, () => avatarFor(myUserId));
3044 document.getElementById("me-btn").onclick = editProfile;
3045
3046 // The ▤ Views dropdown (beside the kebab): the chat's live views, plus
3047 // Memory as the persistent view of an AI DM. Rebuilt on every open, so it
3048 // always reflects the folded state of the thread you're looking at.
3049 const vmenu = document.getElementById("views-menu");
3050 const vmenuBtn = document.getElementById("views-menu-btn");
3051 const closeVMenu = () => vmenu.classList.add("hidden");
3052 vmenuBtn.onclick = (e) => {
3053 e.stopPropagation();
3054 closeTMenu(); // one header menu at a time
3055 if (!vmenu.classList.contains("hidden")) return closeVMenu();
3056 vmenu.replaceChildren();
3057 // The standing last item closes whatever the sidebar is showing — the
3058 // menu is the sidebar's home, so its off switch lives here too.
3059 const entries = [...viewsMenuEntries(activeId)];
3060 if (openPanelTitle[activeId]) entries.push({ label: "Close views sidebar", open: () => closeCommandPanel(activeId) });
3061 for (const entry of entries) {
3062 // A row is a DIV holding sibling buttons (open + remove) — a button
3063 // can't legally nest a button, and browsers eject the inner one.
3064 const row = document.createElement("div");
3065 row.className = "menu-item views-item";
3066 const b = document.createElement("button");
3067 b.className = "views-item-main";
3068 b.setAttribute("role", "menuitem");
3069 // A command row (Close) is plain text; view rows carry their glyph.
3070 if (entry.svg || entry.glyph) {
3071 const g = document.createElement("span");
3072 g.className = "views-item-glyph";
3073 if (entry.svg) g.innerHTML = entry.svg; // static in-code SVG, never data
3074 else g.textContent = entry.glyph;
3075 b.appendChild(g);
3076 }
3077 const l = document.createElement("span");
3078 l.className = "views-item-label";
3079 l.textContent = entry.label;
3080 b.appendChild(l);
3081 b.onclick = () => {
3082 closeVMenu();
3083 entry.open();
3084 };
3085 row.appendChild(b);
3086 // A bot view you're done with removes from here — the ✕ emits the
3087 // existing view.remove event, so it leaves for every member and device
3088 // (Memory and the close row aren't removable; they carry no remove).
3089 if (entry.remove) {
3090 const x = document.createElement("button");
3091 x.className = "views-item-x";
3092 x.setAttribute("aria-label", `Remove "${entry.label}"`);
3093 x.innerHTML = ICON_X; // static in-code SVG
3094 x.onclick = (e) => {
3095 e.stopPropagation();
3096 closeVMenu();
3097 entry.remove();
3098 };
3099 row.appendChild(x);
3100 }
3101 vmenu.appendChild(row);
3102 }
3103 vmenu.classList.remove("hidden");
3104 };
3105 document.addEventListener("click", (e) => {
3106 if (e.target !== vmenuBtn && !vmenu.contains(e.target)) closeVMenu();
3107 });
3108 // A view folding in (or out of) the open thread re-paints the button.
3109 document.addEventListener("lchat:views-changed", () => paintViewsMenu(activeId));
3110 // Mirror what the sidebar is showing: the button reads "Open" at rest and
3111 // the open view's own name while one is up (additive observer — the
3112 // command-host embed keeps the set-slot).
3113 addCommandPanelObserver((threadId, spec) => {
3114 openPanelTitle[threadId] = (spec && spec.title) || "";
3115 if (threadId === activeId) paintViewsMenu(threadId);
3116 });
3117
3118 // Per-thread kebab (thread header): rename group / view members.
3119 const tmenu = document.getElementById("thread-menu");
3120 const tmenuBtn = document.getElementById("thread-menu-btn");
3121 tmenuBtn.innerHTML = ICON_MORE_VERTICAL; // static in-code SVG (icons.js)
3122 const backBtn = document.getElementById("back-btn");
3123 if (backBtn) backBtn.innerHTML = ICON_CHEVRON_LEFT;
3124 const closeTMenu = () => tmenu.classList.add("hidden");
3125 tmenuBtn.onclick = (e) => {
3126 e.stopPropagation();
3127 closeVMenu(); // one header menu at a time
3128 if (nativeMenu) return openNativeMenu(tmenu, tmenuBtn); // native NSMenu on the Mac app
3129 tmenu.classList.toggle("hidden");
3130 };
3131 document.addEventListener("click", (e) => {
3132 if (e.target !== tmenuBtn && !tmenu.contains(e.target)) closeTMenu();
3133 });
3134 document.getElementById("thread-add").onclick = () => {
3135 closeTMenu();
3136 addPeople();
3137 };
3138 document.getElementById("thread-edit").onclick = () => {
3139 closeTMenu();
3140 editGroup();
3141 };
3142 document.getElementById("thread-members").onclick = () => {
3143 closeTMenu();
3144 showMembers();
3145 };
3146 document.getElementById("thread-apps").onclick = () => {
3147 closeTMenu();
3148 manageGroupApps();
3149 };
3150 document.getElementById("thread-pin").onclick = () => {
3151 closeTMenu();
3152 togglePin();
3153 };
3154 document.getElementById("thread-mute").onclick = () => {
3155 closeTMenu();
3156 toggleMute();
3157 };
3158 document.getElementById("thread-close").onclick = () => {
3159 closeTMenu();
3160 closeChat();
3161 };
3162 document.getElementById("thread-end").onclick = () => {
3163 closeTMenu();
3164 endConversation();
3165 };
3166 document.getElementById("thread-block").onclick = (e) => {
3167 closeTMenu();
3168 const uid = e.currentTarget.dataset.uid;
3169 if (uid) viewProfile(uid); // the profile's Block button + its consequence note
3170 };
3171 document.getElementById("thread-remove").onclick = (e) => {
3172 closeTMenu();
3173 const uid = e.currentTarget.dataset.uid;
3174 if (uid) confirmRemoveFromSpace(uid); // its dialog carries the whole-space warning
3175 };
3176 document.getElementById("thread-leave").onclick = () => {
3177 closeTMenu();
3178 leaveGroup();
3179 };
3180
3181 renderThreadList();
3182 // Embed mode opens its one thread explicitly (bootEmbed); don't auto-pick here.
3183 if (embed) return Promise.resolve();
3184 // Choose the thread to land in, in priority order: a tapped-notification
3185 // deep-link (#thread=), the one we were last in (so a refresh reloads where you
3186 // were), else — desktop only — the first visible chat. Returns the open promise
3187 // so boot() can drop the loading cover once the feed is in place (no jump).
3188 const deep = initialThreadId();
3189 let target = null;
3190 // A fresh join (set by the join flow) wins — land straight in that new chat.
3191 const justJoined = localStorage.getItem("lchat_open_thread");
3192 if (justJoined) localStorage.removeItem("lchat_open_thread");
3193 setPendingJoinThread(justJoined || pendingJoinThread); // the welcome conversation's "Say hi" chip needs it later
3194 if (justJoined && threads.some((t) => t.id === justJoined)) {
3195 target = justJoined;
3196 } else if (deep && threads.some((t) => t.id === deep)) {
3197 history.replaceState(null, "", location.pathname + location.search); // consume the deep link
3198 target = deep;
3199 } else {
3200 const saved = localStorage.getItem(ACTIVE_KEY);
3201 if (saved && threads.some((t) => t.id === saved) && !hidden[saved]) target = saved;
3202 else if (!isCoarse) target = (threads.find((t) => !hidden[t.id]) || {}).id || null;
3203 }
3204 return target ? openThread(target) : Promise.resolve();
3205}
3206
3207// The thread id from a deep-link fragment (#thread=<id>), or null. Kept in the
3208// fragment (not a query) so it never reaches the server — see sw.js.
3209function initialThreadId() {
3210 const m = /(?:^|[#&])thread=([^&]+)/.exec(location.hash || "");
3211 return m ? decodeURIComponent(m[1]) : null;
3212}
3213
3214// The opaque message-link token from a "Copy link" permalink fragment
3215// (#l=<token>) — issue #893. Whether we arrived directly or via an
3216// https://11ch.at/l/<host> redirect, the fragment survives (redirects with no
3217// fragment of their own reattach the original), so this is the one landing
3218// spot for both. Kept in the fragment for the same reason as #thread=: never
3219// sent to this server, let alone 11ch.at's.
3220function initialMessageLinkToken() {
3221 const m = /(?:^|[#&])l=([^&]+)/.exec(location.hash || "");
3222 return m ? decodeURIComponent(m[1]) : null;
3223}
3224
3225// Consumes a pending #l= permalink fragment, if any: resolves the token via
3226// this instance's own server (the only place that holds the key to open it —
3227// links.go) and jumps to the message. The resolve call itself enforces CURRENT
3228// thread membership, so a stale link a since-removed member held onto simply
3229// stops working — nothing here trusts the fragment's contents on faith.
3230async function openInitialMessageLink() {
3231 const tok = initialMessageLinkToken();
3232 if (!tok) return;
3233 history.replaceState(null, "", location.pathname + location.search); // consume it, like #thread=
3234 try {
3235 const r = await fetch(`/api/resolve-link?token=${encodeURIComponent(session.token)}`, {
3236 method: "POST",
3237 headers: { "Content-Type": "application/json" },
3238 body: JSON.stringify({ token: tok }),
3239 });
3240 if (!r.ok) return;
3241 const { thread_id, message_id } = await r.json();
3242 if (thread_id && message_id) await jumpToMessage(thread_id, message_id);
3243 } catch {
3244 // Offline / server unreachable: the app still opens normally, just without
3245 // the jump — same graceful degradation as a broken #thread= deep link.
3246 }
3247}
3248
3249// Open a thread by id, tolerating one we haven't loaded yet (e.g. a notification
3250// for a thread created while this tab was asleep). Used by the SW deep-link.
3251async function openThreadById(id) {
3252 if (!threads.some((t) => t.id === id)) {
3253 await loadThreads();
3254 renderThreadList();
3255 }
3256 if (threads.some((t) => t.id === id)) openThread(id);
3257}
3258
3259// A thread's avatar node, as the chat list paints it: DM/self rows show the
3260// person's profile photo, groups their thread photo, app-threads the app's
3261// glyph (or its shipped image — Eleven wears the app icon). Shared with the
3262// search dropdown's Conversations rows so the two surfaces can't drift.
3263function threadRowAvatarEl(t) {
3264 const appName = threadApp[t.id];
3265 if (appName) {
3266 const app = appByName(appName);
3267 const av = document.createElement("span");
3268 av.className = "avatar app-avatar";
3269 // An app can ship an avatar image (Eleven wears the app icon); the glyph
3270 // stays the fallback. In-code URLs only — never user content.
3271 if (app && app.avatar) {
3272 av.style.backgroundImage = `url(${app.avatar})`;
3273 av.style.backgroundSize = "cover";
3274 av.setAttribute("role", "img");
3275 av.setAttribute("aria-label", (app && app.title) || appName);
3276 } else {
3277 appGlyphInto(av, app);
3278 }
3279 return av;
3280 }
3281 let loader = null;
3282 if (t.kind === "group") loader = () => threadAvatarFor(t.id);
3283 else {
3284 const u = t.kind === "self" ? myUserId : (t.members || []).find((id) => id !== myUserId);
3285 loader = () => avatarFor(u);
3286 }
3287 return avatarEl(threadName[t.id] || "?", loader);
3288}
3289
3290function renderThreadList() {
3291 const list = document.getElementById("thread-list");
3292 if (!list) return;
3293 list.innerHTML = "";
3294 // The search box only earns its space once there's a list to filter.
3295 const search = document.getElementById("chat-search");
3296 if (search) search.classList.toggle("hidden", !threads.length);
3297 if (!threads.length) {
3298 const empty = document.createElement("p");
3299 empty.className = "thread-empty-note";
3300 empty.textContent = "No chats yet. Tap the compose button to start one.";
3301 list.appendChild(empty);
3302 return;
3303 }
3304 // A search reveals everything that matches (so a closed chat can be found and
3305 // reopened); the default list hides closed chats. Both bands (pinned, then the
3306 // rest) are ordered most-recent-first: last_at is the last message's time, with
3307 // created_at as the fallback so a brand-new empty chat sorts by when it was made
3308 // (mirrors the server's ORDER BY). This is what floats a chat to the top the
3309 // moment it has new activity — the active chat and your own sends included.
3310 const recencyOf = (t) => t.last_at || t.created_at || 0;
3311 let base = chatFilter
3312 ? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter))
3313 : threads.filter((t) => !hidden[t.id]);
3314 // The funnel menu: category checkboxes (People/Bots/Apps), then online-only.
3315 const filtering = chatFilters.online || !chatFilters.people || !chatFilters.bots || !chatFilters.apps;
3316 if (filtering) {
3317 const others = (t) =>
3318 (t.members || [])
3319 .filter((id) => id !== myUserId)
3320 .map((id) => roster.find((x) => x.user_id === id))
3321 .map((e) => ({ agent: !!(e && e.is_agent), presence: (e && e.presence) || "" }));
3322 base = base.filter((t) => {
3323 const o = others(t);
3324 if (!chatFilters[chatCategory(!!threadApp[t.id], o)]) return false;
3325 return !chatFilters.online || onlineFilterKeeps(o);
3326 });
3327 }
3328 const shown = pinnedFirst(base, (t) => pinned[t.id], recencyOf);
3329 // ---- the space band: one row per space-wide install ----------------------
3330 // The row IS the app's home — its interface opens full-pane from here, and
3331 // only here (space copies never ride other chats' widget bars: place =
3332 // location, docs/space-apps.md). Registry (install) order; filtered like
3333 // chats — the search by title, the funnel's Apps category. Activity previews
3334 // + the unread dot ride the owning app's own preview/unread hooks, folded
3335 // live off the space thread's log.
3336 let spaceRows = 0;
3337 // Apps standing as space rows this pass — the personal-thread loop below
3338 // reads it to spot the twins (same app, two rows) and label its side.
3339 const spaceRowApps = new Set();
3340 if (spaceThreadId && (!filtering || chatFilters.apps) && !chatFilters.online) {
3341 for (const app of spaceAppsFor()) {
3342 if (!app.render) continue; // a command-only install has no surface to open
3343 // The row is named after the SPACE ("Lazyatom To-do") so it never reads
3344 // as a twin of the personal "To-do" beside it; search matches what the
3345 // row shows (the app title is always inside it).
3346 const rowTitle = spaceRowTitle(app.title || app.name, spaceBrandName);
3347 if (chatFilter && !rowTitle.toLowerCase().includes(chatFilter)) continue;
3348 spaceRows++;
3349 spaceRowApps.add(app.name);
3350 const row = document.createElement("button");
3351 row.className = "thread-row space-row" + (activeId === spaceThreadId && spaceAppOpen === app.name ? " active" : "");
3352 const av = document.createElement("span");
3353 av.className = "avatar app-avatar";
3354 if (spaceAvatarSrc) {
3355 // The tile pairs the space's photo and the app glyph as EQUAL halves
3356 // (two same-sized circles on the diagonal) — the twins stop being
3357 // visual twins (words still carry the state: title, badge, resting
3358 // line). The URL is in-code or an object URL from the sealed
3359 // decrypt — never user content.
3360 av.classList.add("space-app-avatar");
3361 av.setAttribute("role", "img");
3362 av.setAttribute("aria-label", rowTitle);
3363 const photo = document.createElement("span");
3364 photo.className = "space-app-photo";
3365 photo.style.backgroundImage = `url(${spaceAvatarSrc})`;
3366 av.appendChild(photo);
3367 const chip = document.createElement("span");
3368 chip.className = "space-app-glyph";
3369 appGlyphInto(chip, app);
3370 av.appendChild(chip);
3371 } else {
3372 appGlyphInto(av, app);
3373 }
3374 row.appendChild(av);
3375 const body = document.createElement("span");
3376 body.className = "thread-row-body";
3377 const name = document.createElement("span");
3378 name.className = "thread-row-name";
3379 const nameText = document.createElement("span");
3380 nameText.className = "thread-row-name-text";
3381 nameText.textContent = rowTitle; // space + member-app names are user content — textContent
3382 name.appendChild(nameText);
3383 const badge = document.createElement("span");
3384 badge.className = "app-badge space-badge";
3385 badge.textContent = "space";
3386 name.appendChild(badge);
3387 body.appendChild(name);
3388 const prev = document.createElement("span");
3389 prev.className = "thread-row-preview";
3390 // The resting line is the orientation itself — the words every other
3391 // surface uses for this place.
3392 prev.textContent = spaceAppPreview[app.name] || "For everyone in this space";
3393 body.appendChild(prev);
3394 row.appendChild(body);
3395 const meta = document.createElement("span");
3396 meta.className = "thread-row-meta";
3397 const dot = document.createElement("span");
3398 dot.className = "unread-dot" + (spaceAppUnread[app.name] ? " on" : "");
3399 meta.appendChild(dot);
3400 row.appendChild(meta);
3401 row.onclick = () => openSpaceApp(app.name);
3402 list.appendChild(row);
3403 }
3404 }
3405 if (!shown.length) {
3406 // With space rows standing, an empty chat list needs no "no matches" note.
3407 if (!spaceRows) {
3408 const empty = document.createElement("p");
3409 empty.className = "thread-empty-note";
3410 empty.textContent = chatFilters.online
3411 ? "No one's online right now."
3412 : filtering
3413 ? "No chats match your filters."
3414 : "No chats match your search.";
3415 list.appendChild(empty);
3416 }
3417 return;
3418 }
3419 for (const t of shown) {
3420 const row = document.createElement("button");
3421 row.className = "thread-row" + (t.id === activeId ? " active" : "");
3422 row.dataset.id = t.id;
3423 row.appendChild(threadRowAvatarEl(t));
3424 const appName = threadApp[t.id]; // the badge + blurb below still key off it
3425
3426 const body = document.createElement("span");
3427 body.className = "thread-row-body";
3428 const name = document.createElement("span");
3429 name.className = "thread-row-name";
3430 // Text in its own span so it keeps ellipsis-truncating while the row is a
3431 // flex line (which vertically centers the name with any badge).
3432 const nameText = document.createElement("span");
3433 nameText.className = "thread-row-name-text";
3434 nameText.textContent = threadName[t.id] || "Chat";
3435 name.appendChild(nameText);
3436 // Badge a DM whose other member is a helper, so the chat index shows AI/BOT
3437 // like the picker and message senders do (muted — it repeats per row).
3438 if (t.kind === "dm") {
3439 const other = (t.members || []).find((id) => id !== myUserId);
3440 const e = roster.find((x) => x.user_id === other);
3441 if (e && e.is_agent) name.appendChild(agentTag(true, e.kind));
3442 }
3443 body.appendChild(name);
3444 if (appName) {
3445 // An app may present as a persona rather than a tool (the setup thread is
3446 // "Eleven", badged BOT) — senderKind overrides the generic APP chip.
3447 const a = appByName(appName);
3448 if (a && a.senderKind) {
3449 name.appendChild(agentTag(true, a.senderKind));
3450 } else {
3451 // When the same app also stands as a space row (the twins), the
3452 // generic "app" chip gives way to the place words — both rows then
3453 // name their side (the space row's badge + title already do), in the
3454 // Apps sheet's own selector vocabulary.
3455 const collide = spaceRowApps.has(appName);
3456 const badge = document.createElement("span");
3457 badge.className = collide ? "place-badge" : "app-badge";
3458 badge.textContent = collide ? "Just for you" : "app";
3459 name.appendChild(badge);
3460 }
3461 }
3462 // An app thread with no activity yet falls back to its one-line blurb, so
3463 // the name doesn't sit alone, vertically centred (matches the native rows).
3464 const blurb = appName ? (appByName(appName) || {}).blurb : "";
3465 const previewLine = threadPreview[t.id] || blurb;
3466 if (previewLine) {
3467 const prev = document.createElement("span");
3468 prev.className = "thread-row-preview";
3469 prev.textContent = previewLine;
3470 body.appendChild(prev);
3471 }
3472 row.appendChild(body);
3473
3474 const meta = document.createElement("span");
3475 meta.className = "thread-row-meta";
3476 const time = document.createElement("span");
3477 time.className = "thread-row-time";
3478 time.textContent = relTime(t.last_at);
3479 meta.appendChild(time);
3480 if (pinned[t.id]) {
3481 const pin = document.createElement("span");
3482 pin.className = "thread-row-pin";
3483 pin.innerHTML = ICON_PIN; // static in-code SVG (safe)
3484 meta.appendChild(pin);
3485 }
3486 if (muted[t.id]) {
3487 const mu = document.createElement("span");
3488 mu.className = "thread-row-mute";
3489 mu.innerHTML = ICON_MUTE; // static in-code SVG (safe)
3490 mu.setAttribute("role", "img");
3491 mu.setAttribute("aria-label", "Muted");
3492 meta.appendChild(mu);
3493 }
3494 // A muted chat never escalates: no unread dot (the mute icon marks the
3495 // state instead — state is never color alone).
3496 const dot = document.createElement("span");
3497 dot.className = "unread-dot" + (unread[t.id] && !muted[t.id] ? " on" : "");
3498 meta.appendChild(dot);
3499 row.appendChild(meta);
3500
3501 row.onclick = () => openThread(t.id);
3502 list.appendChild(row);
3503 }
3504}
3505
3506// ---- time separators + sender-name collapse (feed grouping) -------------
3507
3508function formatTime(ms) {
3509 return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
3510}
3511
3512function sameDay(a, b) {
3513 const da = new Date(a);
3514 const db = new Date(b);
3515 return (
3516 da.getFullYear() === db.getFullYear() &&
3517 da.getMonth() === db.getMonth() &&
3518 da.getDate() === db.getDate()
3519 );
3520}
3521
3522// "Today" / "Yesterday" / a weekday (within the past week) / a date, for the
3523// time-separator day label.
3524function dayLabel(ms) {
3525 const now = Date.now();
3526 if (sameDay(ms, now)) return "Today";
3527 if (sameDay(ms, now - 86400000)) return "Yesterday";
3528 const d = new Date(ms);
3529 if (now - ms < 7 * 86400000) return d.toLocaleDateString([], { weekday: "long" });
3530 const sameYear = new Date(now).getFullYear() === d.getFullYear();
3531 return d.toLocaleDateString(
3532 [],
3533 sameYear ? { month: "short", day: "numeric" } : { month: "short", day: "numeric", year: "numeric" }
3534 );
3535}
3536
3537// A centered separator that opens a burst. `withDay` prepends a bold day label
3538// ("Today 9:41 AM"); a within-day gap shows just the time ("2:15 PM").
3539function buildTimeSeparator(ms, withDay) {
3540 const sep = document.createElement("div");
3541 sep.className = "time-sep";
3542 if (withDay) {
3543 const day = document.createElement("strong");
3544 day.textContent = dayLabel(ms);
3545 sep.appendChild(day);
3546 sep.appendChild(document.createTextNode(" " + formatTime(ms)));
3547 } else {
3548 sep.textContent = formatTime(ms);
3549 }
3550 return sep;
3551}
3552
3553// Nearest .msg sibling above `el`, skipping any .time-sep between messages.
3554function prevMsgOf(el) {
3555 let p = el && el.previousElementSibling;
3556 while (p && !p.classList.contains("msg")) p = p.previousElementSibling;
3557 return p;
3558}
3559
3560// Nearest .msg sibling below `el`, skipping any .time-sep between messages.
3561function nextMsgOf(el) {
3562 let n = el && el.nextElementSibling;
3563 while (n && !n.classList.contains("msg")) n = n.nextElementSibling;
3564 return n;
3565}
3566
3567// Ensure the right separator (or none) sits before `el`, based on the gap to the
3568// previous message. `el.dataset.t` is the message time (ms). The feed only grows
3569// at the bottom here (no history paging), so refreshing the appended message is
3570// enough.
3571function refreshSeparator(el) {
3572 if (!el || !el.classList || !el.classList.contains("msg")) return;
3573 const prev = prevMsgOf(el);
3574 const existing =
3575 el.previousElementSibling && el.previousElementSibling.classList.contains("time-sep")
3576 ? el.previousElementSibling
3577 : null;
3578 const curT = Number(el.dataset.t);
3579 let show, withDay;
3580 if (!prev) {
3581 show = true; // the oldest message always opens with a dated separator
3582 withDay = true;
3583 } else {
3584 const newDay = !sameDay(curT, Number(prev.dataset.t));
3585 show = newDay || curT - Number(prev.dataset.t) > TIME_SEPARATOR_GAP_MS;
3586 withDay = newDay;
3587 }
3588 if (show) {
3589 const sep = buildTimeSeparator(curT, withDay);
3590 if (existing) existing.replaceWith(sep);
3591 else el.parentNode.insertBefore(sep, el);
3592 } else if (existing) {
3593 existing.remove();
3594 }
3595}
3596
3597// Collapse repeated sender names: a "them" message shows its name only when the
3598// sender differs from the message above, or a separator precedes it — so a run
3599// from one person is labelled once. Call after refreshSeparator (it reads whether
3600// a .time-sep now sits directly above).
3601function refreshSenderName(el) {
3602 const prevSib = el.previousElementSibling;
3603 const afterSeparator = !!prevSib && prevSib.classList.contains("time-sep");
3604 const prev = prevMsgOf(el);
3605 const sameSender = !!prev && prev.dataset.sender === el.dataset.sender;
3606 // Continuation of a same-sender run (no separator between) → tighter top gap
3607 // (.cont), so quick-succession messages aren't spaced like separate bursts.
3608 el.classList.toggle("cont", sameSender && !afterSeparator);
3609 const name = el.querySelector(":scope > .name");
3610 if (name) name.style.display = !afterSeparator && sameSender ? "none" : "";
3611}
3612
3613function showList() {
3614 document.getElementById("tt").classList.remove("show-thread");
3615 // Back to the list is the state to restore on refresh (mobile), not the thread.
3616 localStorage.removeItem(ACTIVE_KEY);
3617}
3618
3619// Header avatar: a group's photo, or the DM/self person's profile photo.
3620function paintHeaderAvatar(id) {
3621 const el = document.getElementById("thread-avatar");
3622 if (!el) return;
3623 const t = threads.find((x) => x.id === id);
3624 // The space home heads with the OPEN space app's mark; a personal
3625 // app-thread with its own app's.
3626 const ownerName = spaceThreadId && id === spaceThreadId ? spaceAppOpen : threadApp[id];
3627 const ownerApp = ownerName && appByName(ownerName);
3628 // A logo- or glyph-bearing app heads its thread with the mark itself — no
3629 // photo loader, no initials fallback (paintAvatar can't draw an inline SVG,
3630 // and the space thread has no name to take initials from).
3631 if (ownerApp && !ownerApp.avatar && (ownerApp.logo || ownerApp.glyph)) {
3632 el.classList.add("avatar", "app-avatar");
3633 el.classList.remove("zoomable");
3634 el.onclick = null;
3635 appGlyphInto(el, ownerApp);
3636 return;
3637 }
3638 let loader;
3639 if (ownerApp && ownerApp.avatar) loader = () => Promise.resolve(ownerApp.avatar); // e.g. Eleven's app icon
3640 else if (t && t.kind === "group") loader = () => threadAvatarFor(id);
3641 else {
3642 const u = t && t.kind === "self" ? myUserId : t && (t.members || []).find((x) => x !== myUserId);
3643 loader = () => avatarFor(u);
3644 }
3645 // Tap the header avatar to see the full (400px) photo in a lightbox — only
3646 // when there is one; an initials placeholder isn't clickable.
3647 el.onclick = null;
3648 el.classList.remove("zoomable");
3649 paintAvatar(el, threadName[id] || "?", loader).then((url) => {
3650 if (url) {
3651 el.classList.add("zoomable");
3652 el.onclick = () => openLightbox(url, el);
3653 }
3654 });
3655}
3656
3657// Re-paint avatars across the UI (after a live avatar-change event).
3658function refreshAvatars() {
3659 renderThreadList();
3660 const me = document.getElementById("me-avatar");
3661 if (me) paintAvatar(me, session.name, () => avatarFor(myUserId));
3662 if (activeId) paintHeaderAvatar(activeId);
3663}
3664
3665let openSeq = 0; // bumped each openThread so a slow history load bails if you switch away
3666
3667// Per-thread full-pane app cache. A chat-app
3668// or personal-app thread renders full-pane into #app-pane; opening one used to wipe
3669// the pane and re-fetch + re-fold its whole history before anything painted, so
3670// every open flashed blank for the network round-trip (the "rebuilt/reduced every
3671// time" regression). Instead we park each opened app-thread's rendered root when you
3672// leave, and on return re-attach it and reconcile from the already-folded state —
3673// which live WS keeps current even for backgrounded threads — so the reopen is
3674// instant, then a background catch-up folds anything missed while the socket was down.
3675const appPaneCache = {}; // threadId -> { root, scrollTop, loaded }
3676
3677// Detach the open app-thread's rendered root into its cache entry (saving the feed's
3678// scroll offset, which detaching would otherwise zero) so another thread can take the
3679// #app-pane slot. No-op unless the outgoing thread was a loaded app-thread — a normal
3680// chat leaves #app-pane empty.
3681function stashAppPane(id) {
3682 // The interface pane is re-rendered on open (not parked), so clear it here — a
3683 // normal chat opened next relies on #interface-pane:empty to hide/full-width.
3684 const iface = document.getElementById("interface-pane");
3685 if (iface) iface.textContent = "";
3686 const pane = document.getElementById("app-pane");
3687 const entry = appPaneCache[id];
3688 if (!pane || !entry) return;
3689 const root = pane.firstElementChild;
3690 if (!root) return;
3691 const feed = root.querySelector(".chatapp-feed");
3692 entry.scrollTop = feed ? feed.scrollTop : pane.scrollTop;
3693 entry.root = root;
3694 root.remove();
3695}
3696
3697// The space home's header: the open space app's identity plus the one line
3698// that IS the orientation ("For everyone in this space"). No kebab and no
3699// views menu — there's nothing to mute, pin, or leave: membership is the
3700// space itself — and no view toggle (the home is single-pane by design).
3701function paintSpaceHeader() {
3702 const app = appByName(spaceAppOpen || "") || {};
3703 const title = document.getElementById("thread-title");
3704 // Same name the sidebar row wears (spaceRowTitle), so tapping a row never
3705 // lands on a differently-titled pane.
3706 title.textContent = spaceRowTitle(app.title || app.name || "app", spaceBrandName); // user content — textContent
3707 title.onclick = null;
3708 title.style.cursor = "";
3709 const sub = document.getElementById("thread-sub");
3710 sub.textContent = "For everyone in this space";
3711 sub.onclick = null;
3712 sub.style.cursor = "";
3713 document.getElementById("thread-menu-wrap").style.display = "none";
3714 paintViewsMenu(null);
3715 const toggle = document.getElementById("view-toggle");
3716 if (toggle) toggle.remove();
3717 paintHeaderAvatar(spaceThreadId);
3718}
3719
3720// Paint the thread header (title, member-count subtitle, kebab). Separate from
3721// openThread so membership changes (add/remove people) can refresh it in place —
3722// openThread now early-returns for the already-open thread (feed cache), so it
3723// can't be reused to re-render the header.
3724// threadHasAIPeer: is this a DM whose other member is an AI agent? Gates the
3725// memory surfaces (docs/eleven-memory.md) — the ⋮ Memory item and the Views
3726// dropdown's persistent Memory entry.
3727function threadHasAIPeer(id) {
3728 const t = threads.find((x) => x.id === id);
3729 if (!t || t.kind !== "dm") return false;
3730 return (t.members || []).some((uid) => {
3731 const e = roster.find((x) => x.user_id === uid);
3732 return e && e.is_agent && e.kind === "ai";
3733 });
3734}
3735
3736// viewsMenuEntries: what the ▤ Views dropdown offers for a thread — Memory
3737// (the persistent view of an AI DM) first, then every live bot view, newest
3738// first (the same order /views shows). Empty = the button hides.
3739function viewsMenuEntries(id) {
3740 const entries = [];
3741 if (threadHasAIPeer(id)) entries.push({ svg: ICON_BRAIN, label: "Memory", open: () => openMemoryPanel(id) });
3742 const st = appStateFor(id, "views");
3743 const views = st && Array.isArray(st.views) ? st.views : [];
3744 for (const v of views.slice().reverse()) {
3745 entries.push({
3746 glyph: v.glyph || "▤",
3747 label: v.title,
3748 open: () => openThreadView(id, v.id),
3749 remove: () => {
3750 // If the sidebar is showing this very view, close it with the removal.
3751 if (openPanelTitle[id] === v.title) closeCommandPanel(id);
3752 emitAppEvent(id, { type: "view.remove", id: v.id });
3753 },
3754 });
3755 }
3756 return entries;
3757}
3758
3759// paintViewsMenu shows the ▤ button only where the dropdown has something to
3760// offer. Called from paintThreadHeader and whenever a view folds (views.js
3761// pokes lchat:views-changed), so a view arriving mid-conversation surfaces
3762// the button without a reload.
3763const openPanelTitle = {}; // threadId → title of the open command panel ("" = none)
3764
3765function paintViewsMenu(id) {
3766 const wrap = document.getElementById("views-menu-wrap");
3767 if (!wrap) return;
3768 wrap.classList.toggle("hidden", !id || (viewsMenuEntries(id).length === 0 && !openPanelTitle[id]));
3769 // At rest the button offers "Open"; while a view is up it names it — the
3770 // header tells you where you are, not just what exists.
3771 const label = document.getElementById("views-menu-label");
3772 if (label) label.textContent = (id && openPanelTitle[id]) || "Open";
3773}
3774
3775function paintThreadHeader(id) {
3776 // The space thread has no chat header to paint — its home paints the open
3777 // app's identity instead (and every refresh path funnels through here).
3778 if (spaceThreadId && id === spaceThreadId) return paintSpaceHeader();
3779 const t = threads.find((x) => x.id === id);
3780 const isGroup = t && t.kind === "group";
3781 const title = document.getElementById("thread-title");
3782 title.textContent = threadName[id] || "Chat";
3783 // In a DM, the name in the header opens the other person's profile card
3784 // (photo + "about me") — the counterpart of a group's tappable member count.
3785 const other = !isGroup && t ? (t.members || []).find((uid) => uid !== myUserId) : null;
3786 title.onclick = other ? () => viewProfile(other) : null;
3787 title.style.cursor = other ? "pointer" : "";
3788 const sub = document.getElementById("thread-sub");
3789 // Count people, deterministic bots, and AI agents separately (roster is_agent
3790 // + kind), so the subtitle reads "2 members, 2 bots, 1 AI agent" — mirroring
3791 // the BOT/AI badges rather than lumping helpers in as members.
3792 const members = t ? t.members || [] : [];
3793 let bots = 0;
3794 let agents = 0;
3795 for (const id of members) {
3796 const e = roster.find((x) => x.user_id === id);
3797 if (!e || !e.is_agent) continue;
3798 if (e.kind === "ai") agents++;
3799 else bots++;
3800 }
3801 if (isGroup) {
3802 // "· N online" counts people only — bots hold sockets around the clock, so
3803 // counting them would pin every room at its bot count and say nothing.
3804 let online = 0;
3805 for (const id of members) {
3806 if (id === myUserId) continue;
3807 const e = roster.find((x) => x.user_id === id);
3808 if (e && !e.is_agent && e.presence === "online") online++;
3809 }
3810 sub.textContent =
3811 memberCountLabel(members.length - bots - agents, bots, agents) + (online ? ` · ${online} online` : "");
3812 } else if (t && t.kind === "dm") {
3813 // The status line under the person's name: dot + Online/Away/Offline.
3814 // Bots read Online while their sidecar is connected — an honest health
3815 // light. Blank only when the state is unknown (keyless invitee).
3816 const other = members.find((id) => id !== myUserId);
3817 setPresenceLine(sub, rosterPresence(other));
3818 } else {
3819 sub.textContent = "";
3820 }
3821 sub.onclick = isGroup ? () => showMembers() : null; // tap "N members" → member list
3822 sub.style.cursor = isGroup ? "pointer" : "";
3823 // The kebab is always available (every chat can be closed); the group-only
3824 // items (add / edit / members / leave) show only for groups. With the full
3825 // places UI, Apps shows for DMs too — the sheet's space-wide section
3826 // applies to every conversation; with it hidden (the default — § The
3827 // pullback) the item's one job is adding to THIS chat, so groups only.
3828 document.getElementById("thread-menu-wrap").style.display = "";
3829 for (const gid of ["thread-add", "thread-edit", "thread-members", "thread-leave"]) {
3830 const el = document.getElementById(gid);
3831 if (el) el.style.display = isGroup ? "" : "none";
3832 }
3833 const appsItem = document.getElementById("thread-apps");
3834 if (appsItem) appsItem.style.display = isGroup || (PLACES_UI && t && t.kind === "dm") ? "" : "none";
3835 document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
3836 document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
3837 paintViewsMenu(id);
3838 // End conversation is a DM-only act (issue #578) — and once ended, the
3839 // banner's Reopen is the way back, so the item hides on a closed DM too.
3840 document.getElementById("thread-end").style.display = t && t.kind === "dm" && !dmClosed[id] ? "" : "none";
3841 // Block lives in the DM kebab too (a person can be blocked from their profile,
3842 // but the menu is where people look). DMs with a real member only — not bots,
3843 // not a keyless invitee. It opens their profile, where Block carries its
3844 // ends-your-DMs consequence note.
3845 const blockEl = document.getElementById("thread-block");
3846 if (blockEl) {
3847 const dmOther = t && t.kind === "dm" ? members.find((x) => x !== myUserId) : null;
3848 const pe = dmOther && roster.find((x) => x.user_id === dmOther);
3849 const canBlock = !!(pe && !pe.is_agent && pe.public_key);
3850 blockEl.style.display = canBlock ? "" : "none";
3851 if (canBlock) {
3852 blockEl.textContent = blockedPeople.has(dmOther)
3853 ? `Unblock ${rosterLabel(dmOther)}`
3854 : `Block ${rosterLabel(dmOther)}`;
3855 blockEl.dataset.uid = dmOther;
3856 }
3857 // Admins also get the remove rung here — a short label; the dialog it
3858 // opens carries the whole-space warning (the card keeps the ban option).
3859 const removeEl = document.getElementById("thread-remove");
3860 if (removeEl) {
3861 const canRemove = canBlock && isAdmin();
3862 removeEl.style.display = canRemove ? "" : "none";
3863 if (canRemove) {
3864 removeEl.textContent = `Remove ${rosterLabel(dmOther)}`;
3865 removeEl.dataset.uid = dmOther;
3866 }
3867 }
3868 }
3869 paintViewToggle(id);
3870 paintHeaderAvatar(id);
3871}
3872
3873// The [Chat | Split | Interface] view toggle sits beside the kebab, for two
3874// kinds of thread. A COMPANION app-thread gets Chat = the conversation; Split =
3875// both side by side (desktop only — there's no room to split on mobile);
3876// Interface = the app's interactive UI (its glyph, ⏰ for Reminders). A GROUP
3877// chat with apps added (⋮ → Add apps) gets the same bar with one button per
3878// added app — the shared widget bar every member sees. Seed of a fuller view
3879// picker (a dropdown) later.
3880function paintViewToggle(id) {
3881 const head = document.querySelector(".thread-head");
3882 const menuWrap = document.getElementById("thread-menu-wrap");
3883 let toggle = document.getElementById("view-toggle");
3884 const owner = threadApp[id];
3885 // Only a companion app-thread has more than one view to switch between; a
3886 // takeover app (no chatMessage) owns the whole surface, and a chat-only app
3887 // (the setup conversation) has just its chat — neither gets a toggle.
3888 const companion = !!owner && appWantsChatView(owner) && !appChatOnly(owner);
3889 const groupApps = !owner && chatAppFor(id) ? groupAppsFor(id) : [];
3890 if (!head || (!companion && !groupApps.length)) {
3891 if (toggle) toggle.remove();
3892 return;
3893 }
3894 if (!toggle) {
3895 toggle = document.createElement("div");
3896 toggle.id = "view-toggle";
3897 toggle.className = "view-toggle";
3898 toggle.setAttribute("role", "group");
3899 toggle.setAttribute("aria-label", "View");
3900 head.insertBefore(toggle, menuWrap);
3901 }
3902 const view = appThreadView[id] || (companion ? appDefaultView(owner) : "chat");
3903 // On a narrow layout there's no split — it collapses to Chat, so Chat reads as
3904 // active and the (desktop-only) Split button is hidden.
3905 const narrow = matchMedia("(max-width: 720px)").matches;
3906 const active = narrow && view === "split" ? "chat" : view;
3907 toggle.textContent = "";
3908 const mk = (label, sym, isActive, splitOnly, onTap) => {
3909 const b = document.createElement("button");
3910 b.type = "button";
3911 b.className = "view-toggle-btn" + (isActive ? " active" : "") + (splitOnly ? " split-only" : "");
3912 b.title = label;
3913 b.setAttribute("aria-label", label);
3914 b.setAttribute("aria-pressed", String(isActive));
3915 b.textContent = sym;
3916 b.onclick = onTap;
3917 return b;
3918 };
3919 toggle.append(
3920 mk("Chat", "💬", active === "chat", false, () => setAppThreadView(id, "chat")),
3921 mk("Split", "◫", active === "split", true, () => setAppThreadView(id, "split")),
3922 );
3923 if (companion) {
3924 const glyph = (appByName(owner) || {}).glyph || "▦";
3925 toggle.append(mk("Interface", glyph, active === "interface", false, () => setAppThreadView(id, "interface")));
3926 return;
3927 }
3928 // Group chat: one button per added app. Tapping one shows THAT app — beside
3929 // the chat first (desktop), and a SECOND tap gives it the full pane; mobile
3930 // has no split, so it goes straight to full-pane. Tapping a DIFFERENT app's
3931 // button keeps the current view and just swaps which app the pane shows.
3932 const sel = groupAppSelection(id);
3933 for (const a of groupApps) {
3934 toggle.append(
3935 mk(a.title || a.name, a.glyph || "▦", active === "interface" && sel === a.name, false, () => {
3936 const cur = appThreadView[id] || "chat";
3937 const wasSel = groupAppSelection(id);
3938 groupAppView[id] = a.name;
3939 if (cur === "chat") setAppThreadView(id, narrow ? "interface" : "split");
3940 else if (cur === "split" && (narrow || wasSel === a.name)) setAppThreadView(id, "interface");
3941 else if (cur === "interface" && wasSel === a.name) return; // already the active segment
3942 else {
3943 // Same view, different app — swap the pane in place.
3944 renderGroupAppPane(id);
3945 paintViewToggle(id);
3946 }
3947 }),
3948 );
3949 }
3950}
3951
3952// Switch an app-thread's (or group-with-apps chat's) view (chat | split |
3953// interface). Both panes are already mounted, so this only flips the layout —
3954// instant, no re-render or re-fetch (a group's app pane re-renders from
3955// already-folded state).
3956function setAppThreadView(id, view) {
3957 const cur = appThreadView[id] || (threadApp[id] ? appDefaultView(threadApp[id]) : "chat");
3958 if (cur === view) return;
3959 if (!threadApp[id] && !groupAppsFor(id).length) return;
3960 appThreadView[id] = view;
3961 applyAppThreadView(id);
3962 renderGroupAppPane(id);
3963 paintViewToggle(id);
3964}
3965
3966// The added app a group chat's Interface pane shows: the sticky per-session
3967// choice while that app is still added, else the first added app (or null).
3968// (Only THIS chat's own adds — a space-wide install surfaces in the sidebar's
3969// space band, never in another chat's widget bar: place = location.)
3970function groupAppSelection(id) {
3971 const added = groupAppsFor(id);
3972 if (!added.length) return null;
3973 const pick = groupAppView[id];
3974 return added.some((a) => a.name === pick) ? pick : added[0].name;
3975}
3976
3977// Render a group chat's selected added app into the Interface pane — the same
3978// pane a companion app-thread uses, so the split CSS applies unchanged. ctx is
3979// bound to the GROUP thread: the app's emits land in the group's encrypted log,
3980// folded by every member. Cleared in the chat view so #interface-pane:empty
3981// collapses it and the conversation takes the full width.
3982function renderGroupAppPane(id) {
3983 if (threadApp[id]) return; // a companion app-thread's pane isn't ours to touch
3984 const iface = document.getElementById("interface-pane");
3985 if (!iface || id !== activeId) return;
3986 const sel = groupAppSelection(id);
3987 if (!sel || (appThreadView[id] || "chat") === "chat") {
3988 iface.textContent = "";
3989 delete iface.dataset.app;
3990 return;
3991 }
3992 // The pane holds host chrome above the app's own root — the one-time
3993 // "shared in this chat" orientation card — so the app's render can never
3994 // wipe the card. Rebuilt only when the selected app changes; incremental
3995 // apps (member-app iframes) keep their root across renders as before.
3996 if (iface.dataset.app !== sel) {
3997 iface.textContent = "";
3998 iface.dataset.app = sel;
3999 const orientation = buildPlaceCard(sel, id);
4000 if (orientation) iface.appendChild(orientation);
4001 const root = document.createElement("div");
4002 root.className = "group-app-root";
4003 iface.appendChild(root);
4004 }
4005 renderMiniApp(id, sel, iface.querySelector(".group-app-root"));
4006}
4007
4008// buildPlaceCard: the one-time orientation line atop a CHAT copy's Interface
4009// pane — "shared with everyone in this chat, and only here". Once dismissed
4010// for a given copy it never comes back (per app + thread, this device).
4011// Returns null when already seen. Titles go through textContent — a
4012// member-built app's title is user content. (Space copies need no card: the
4013// sidebar row and its header say the place — the row IS the orientation.)
4014function buildPlaceCard(appName, instanceTid) {
4015 const key = `lchat-place-seen:${appName}:${instanceTid}`;
4016 try {
4017 if (localStorage.getItem(key)) return null;
4018 } catch {
4019 return null;
4020 }
4021 const app = appByName(appName);
4022 const card = document.createElement("div");
4023 card.className = "place-card";
4024 const who = document.createElement("b");
4025 who.textContent = (app && (app.title || app.name)) || appName;
4026 const what = document.createElement("span");
4027 what.textContent =
4028 "— shared with everyone in this chat, and only here." + (app && app.places.includes("you") ? " Your own copy stays separate." : "");
4029 const done = document.createElement("button");
4030 done.type = "button";
4031 done.textContent = "✕";
4032 done.setAttribute("aria-label", "Got it");
4033 done.onclick = () => {
4034 try {
4035 localStorage.setItem(key, "1");
4036 } catch {}
4037 card.remove();
4038 };
4039 card.append(who, what, done);
4040 return card;
4041}
4042
4043// After a group thread's fold reveals or changes its added apps (open, live
4044// event, reconnect catch-up), refresh the three places that reflect them: the
4045// header widget bar, the split layout, and the Interface pane itself.
4046function refreshGroupAppsUI(id) {
4047 if (id !== activeId || threadApp[id] || !chatAppFor(id)) return;
4048 if (!groupAppsFor(id).length) delete appThreadView[id]; // last app removed → back to plain chat
4049 applyAppThreadView(id);
4050 paintViewToggle(id);
4051 renderGroupAppPane(id);
4052}
4053
4054async function openThread(id) {
4055 // The space thread has no chat surface — a stray navigation to it (an old
4056 // deep link, a stale saved id) opens its home instead of a ghost chat.
4057 if (spaceThreadId && id === spaceThreadId) {
4058 return openSpaceApp(spaceAppOpen || (spaceAppsFor().filter((a) => a.render)[0] || {}).name);
4059 }
4060 // Re-tapping the thread that's already open must not rebuild it — just reveal
4061 // the pane (mobile navigates list → thread). No flash, no wiped draft.
4062 if (id === activeId) {
4063 document.getElementById("tt").classList.add("show-thread");
4064 focusChatComposer(); // self-guards: no-op on touch / when another field holds focus
4065 return;
4066 }
4067 if (activeId) stashAppPane(activeId); // park the outgoing app-thread's full-pane root
4068 if (spaceAppOpen) {
4069 // Leaving the space home: its pane never parks (folded state re-renders in
4070 // one call), so just drop the marker — the incoming render replaces the pane.
4071 spaceAppOpen = null;
4072 const sp = document.getElementById("app-pane");
4073 if (sp) delete sp.dataset.spaceApp;
4074 }
4075
4076 const myOpen = ++openSeq;
4077 setActiveId(id);
4078 localStorage.setItem(ACTIVE_KEY, id); // remember where we are, to restore on refresh
4079 unread[id] = false;
4080 markThreadRead(id); // …and tell the server, so your other devices clear too
4081 // Opening a closed chat reopens it (locally + server, synced to your devices).
4082 if (hidden[id]) {
4083 hidden[id] = false;
4084 fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
4085 method: "POST",
4086 headers: { "Content-Type": "application/json" },
4087 body: JSON.stringify({ token: session.token, hidden: false }),
4088 }).catch(() => {});
4089 }
4090 document.getElementById("thread-empty").classList.add("hidden");
4091 document.getElementById("thread-view").classList.remove("hidden");
4092 applyAppThreadView(id); // set/clear the split's .iface class for this thread (never let it linger)
4093 paintThreadHeader(id);
4094 // A pending DM (invitee not joined yet) shows a waiting-to-join banner with the
4095 // share link; a normal chat clears any stale banner.
4096 updatePendingBanner(threads.find((x) => x.id === id));
4097 updateRenameBanner(threads.find((x) => x.id === id));
4098 document.getElementById("tt").classList.add("show-thread");
4099 renderThreadList();
4100
4101 // A personal app-thread (chat or interface view) or a chat-app thread renders
4102 // full-pane (no original feed / composer). An app-thread defaults to its chat
4103 // view — a DM with its bot — with the header toggle switching to the interface view.
4104 // Mount the thread's surface. renderAppFor → chatAppFor looks the thread up
4105 // in `threads`, so a thread that isn't loaded yet (opened programmatically
4106 // during onboarding, or right after a Discover/auto join) resolves to
4107 // "no feed". Fetch once and retry rather than falling through — otherwise
4108 // the block below would leave the PREVIOUS thread's pane showing.
4109 let renderApp = renderAppFor(id);
4110 if (!renderApp && !threads.some((t) => t.id === id)) {
4111 await loadThreads();
4112 if (myOpen !== openSeq) return; // a newer open superseded us
4113 renderThreadList();
4114 renderApp = renderAppFor(id);
4115 }
4116 if (renderApp) {
4117 await openAppThreadView(id, myOpen, renderApp, threadApp[id] || null);
4118 // Land ready to type. The fresh-mount path focuses in chat.js as it
4119 // builds; the instant-restore path re-attaches a parked root without
4120 // rebuilding, so it needs this nudge. focusChatComposer self-guards
4121 // (touch, another editable focused); embed's focus belongs to the
4122 // native composer.
4123 if (myOpen === openSeq && !embed) focusChatComposer();
4124 return;
4125 }
4126 // No feed to mount for this thread. Never leave the outgoing thread's feed
4127 // sitting under the new header (the onboarding-then-join bug: Eleven's
4128 // messages showing "in" the just-opened group) — clear the pane. stashAppPane
4129 // above only detaches the old root when it was cached, so an uncached pane
4130 // (e.g. the onboarding conversation) would otherwise linger here.
4131 const pane = document.getElementById("app-pane");
4132 if (pane) pane.textContent = "";
4133 setAppThreadMode(false);
4134}
4135
4136// ---- the space home: a space app rendered full-pane -----------------------
4137// A space-wide install opens from its SIDEBAR ROW in the space band — its
4138// "home", and the only surface its interface has: place = location, so the
4139// pane you're in always says whose data you're touching. The space thread has
4140// no chat surface (no feed, no composer, no member list) — this is a
4141// takeover-style render bound to the space thread's already-folded state
4142// (loadSpaceThread seeds it; live WS folds keep it current).
4143function openSpaceApp(appName) {
4144 const app = appByName(appName || "");
4145 if (!spaceThreadId || !app || !app.render) return;
4146 if (activeId === spaceThreadId && spaceAppOpen === appName) {
4147 document.getElementById("tt").classList.add("show-thread"); // mobile: reveal the pane
4148 return;
4149 }
4150 if (activeId && activeId !== spaceThreadId) stashAppPane(activeId);
4151 ++openSeq; // supersede any in-flight thread open so it can't repaint over us
4152 spaceAppOpen = appName;
4153 setActiveId(spaceThreadId);
4154 // Deliberately NOT saved to ACTIVE_KEY: restoring the home on refresh would
4155 // need the space thread resolved before the first paint — a refresh lands
4156 // on the last real chat instead, and the row is one tap away.
4157 delete spaceAppUnread[appName];
4158 document.getElementById("thread-empty").classList.add("hidden");
4159 document.getElementById("thread-view").classList.remove("hidden");
4160 applyAppThreadView(spaceThreadId); // clears any stale split-view state
4161 paintThreadHeader(spaceThreadId); // → paintSpaceHeader (guarded at the top)
4162 updatePendingBanner(undefined); // clear a previous DM's waiting card
4163 updateRenameBanner(undefined);
4164 document.getElementById("tt").classList.add("show-thread");
4165 renderThreadList(); // the row lights up as active; its unread dot clears
4166 setAppThreadMode(true);
4167 const pane = document.getElementById("app-pane");
4168 if (pane) {
4169 // Always a fresh mount on open (the pane may hold another surface);
4170 // renderSpaceAppPane's dataset check then keeps live re-renders
4171 // incremental (member-app iframes survive them).
4172 pane.textContent = "";
4173 pane.dataset.spaceApp = appName;
4174 }
4175 renderSpaceAppPane();
4176}
4177
4178// Repaint the open space app — on open and on each live space-thread fold.
4179function renderSpaceAppPane() {
4180 if (activeId !== spaceThreadId || !spaceAppOpen) return;
4181 const pane = document.getElementById("app-pane");
4182 if (!pane) return;
4183 if (pane.dataset.spaceApp !== spaceAppOpen) {
4184 pane.textContent = "";
4185 pane.dataset.spaceApp = spaceAppOpen;
4186 }
4187 renderMiniApp(spaceThreadId, spaceAppOpen, pane);
4188}
4189
4190// A brief status pill (reuses the #toast element).
4191let toastTimer;
4192function toast(msg) {
4193 const t = document.getElementById("toast");
4194 if (!t) return;
4195 t.textContent = msg;
4196 t.classList.add("show");
4197 clearTimeout(toastTimer);
4198 toastTimer = setTimeout(() => t.classList.remove("show"), 1600);
4199}
4200
4201// The app set changed (a bot build, App Studio, a custom-app install/update/
4202// removal). Rather than reload the page out from under someone, surface a
4203// subtle, dismissible offer in the sidebar — inline, never a floating toast.
4204// "Later" hides it; the next apps_changed re-offers. Reloading is the reliable
4205// pickup for every tier (member apps also register live, so they already work).
4206// `kind` ("created" / "updated" / "removed") picks more specific copy than a
4207// blanket "New app available" (#406).
4208function offerAppReload(kind) {
4209 const b = document.getElementById("app-update-banner");
4210 if (!b) return;
4211 b.textContent = "";
4212 b.classList.remove("hidden");
4213 const msg = document.createElement("div");
4214 msg.className = "update-banner-msg";
4215 msg.textContent = appReloadMessage(kind);
4216 const row = document.createElement("div");
4217 row.className = "update-banner-row";
4218 const reload = document.createElement("button");
4219 reload.className = "update-banner-btn primary";
4220 reload.textContent = "Reload";
4221 reload.onclick = () => location.reload();
4222 const later = document.createElement("button");
4223 later.className = "update-banner-btn";
4224 later.textContent = "Later";
4225 later.onclick = () => b.classList.add("hidden");
4226 row.append(reload, later);
4227 b.append(msg, row);
4228}
4229
4230// ---- tamper evidence (docs/tamper-evidence.md, integrity.js) -------------
4231// The client verifies the instance's signed checkpoint chain and gossips its
4232// verified head inside encrypted payloads — the server can't read the gossip,
4233// so it can't selectively strip it, and two members shown different histories
4234// betray the fork to each other. Detection only: alerts surface via the
4235// /integrity command, never a banner (that UX needs its own pass).
4236const integrity = createIntegrity({
4237 store: localStorage,
4238 fetchPage: (after) =>
4239 fetch(`/api/checkpoints?token=${encodeURIComponent(session.token)}&after=${after}`).then((r) => (r.ok ? r.json() : null)),
4240 // A new integrity FAILURE raises the banner (a pulled self-check via
4241 // /integrity is enough for "all clear"; a failure has to come find you).
4242 onAlert: () => renderIntegrityBanner(),
4243});
4244let integrityTimer = null; // slow poll while the socket is open
4245
4246// sampleIntegrity proves a few messages this device holds are committed to the
4247// verified chain (docs/tamper-evidence.md client sampling). Picks random
4248// cached messages, hashes their stored ciphertext the way the server did
4249// (ciphertextHash = sha256 hex of the blob string), and asks integrity.js to
4250// check the inclusion proofs against roots it trusts. Cheap + best-effort;
4251// runs after each poll (so roots exist to check against).
4252async function sampleIntegrity() {
4253 try {
4254 const ids = storeCachedThreadIds();
4255 if (!ids.length) return;
4256 const samples = [];
4257 // A couple of threads, a few messages each — enough that, across many
4258 // members over time, the whole DB is covered probabilistically.
4259 for (const tid of shuffleTake(ids, 2)) {
4260 for (const row of shuffleTake(storeRecentMessages(tid, 60), 3)) {
4261 if (row && row.id && row.blob) {
4262 samples.push({ msgId: row.id, blobHash: await sha256Hex(row.blob) });
4263 }
4264 }
4265 }
4266 if (!samples.length) return;
4267 await integrity.sampleAndVerify(samples, (msgId) =>
4268 fetch(`/api/checkpoint-proof?token=${encodeURIComponent(session.token)}&msg=${msgId}`).then((r) => (r.ok ? r.json() : null)),
4269 );
4270 renderIntegrityBanner(); // a bad-proof would have raised an alert
4271 } catch {
4272 /* best-effort: a failed sample never disrupts the app */
4273 }
4274}
4275
4276// sha256Hex of a string's UTF-8 bytes — matches the server's ciphertextHash
4277// (sha256 hex of the stored blob string).
4278async function sha256Hex(str) {
4279 const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
4280 return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
4281}
4282
4283// shuffleTake returns up to n random elements (Fisher–Yates on a copy).
4284function shuffleTake(arr, n) {
4285 const a = arr.slice();
4286 for (let i = a.length - 1; i > 0; i--) {
4287 const j = Math.floor(Math.random() * (i + 1));
4288 [a[i], a[j]] = [a[j], a[i]];
4289 }
4290 return a.slice(0, n);
4291}
4292
4293// Plain-language line per alert kind — members, not developers. Every kind
4294// here is a server misbehaviour the E2E rule is meant to make impossible to
4295// hide; the wording says what it means and what to do, without jargon.
4296function integrityAlertMessage(kind) {
4297 switch (kind) {
4298 case "gossip-fork":
4299 return "Your record of this space's history disagrees with another member's. A trustworthy server shows everyone the same history — this one may not have.";
4300 case "rollback":
4301 return "This space's server served an older version of its history than your device had already verified.";
4302 case "withheld":
4303 return "Another member has verified activity in this space that this space's server won't show you.";
4304 case "bad-signature":
4305 case "malformed":
4306 return "This space's activity record failed its cryptographic check — it may have been altered.";
4307 case "broken-link":
4308 case "bad-chain-hash":
4309 case "gap":
4310 case "row-drift":
4311 return "This space's activity record doesn't hold together as it should — a sign it may have been altered.";
4312 case "bad-proof":
4313 return "The server couldn't prove one of your messages is in this space's activity record — a sign the record may have been altered.";
4314 case "key-substitution":
4315 return "The key shown for someone you invited isn't the one they set up with. The server may have swapped in its own key to read your messages — don't share anything sensitive, and check with them on another channel.";
4316 default:
4317 return "This space failed an integrity check.";
4318 }
4319}
4320
4321// renderIntegrityBanner shows/updates the top failure band from the monitor's
4322// unresolved alerts. Called on every new alert and once on boot (a failure
4323// recorded in a past session must greet you). Never shown for "all clear".
4324function renderIntegrityBanner() {
4325 const el = document.getElementById("integrity-banner");
4326 if (!el) return;
4327 const alerts = integrity.unresolvedAlerts();
4328 if (!alerts.length) {
4329 el.classList.add("hidden");
4330 el.replaceChildren();
4331 return;
4332 }
4333 const latest = alerts[alerts.length - 1];
4334 el.replaceChildren();
4335 const icon = document.createElement("span");
4336 icon.className = "ib-icon";
4337 icon.textContent = "⚠️";
4338 icon.setAttribute("aria-hidden", "true");
4339 const text = document.createElement("div");
4340 text.className = "ib-text";
4341 const title = document.createElement("div");
4342 title.className = "ib-title";
4343 title.textContent = alerts.length > 1 ? `Integrity check failed (${alerts.length} alerts)` : "Integrity check failed";
4344 const body = document.createElement("div");
4345 body.textContent = `${integrityAlertMessage(latest.kind)} Keep this device — its records are the evidence — and contact your space's admin.`;
4346 text.append(title, body);
4347 const actions = document.createElement("div");
4348 actions.className = "ib-actions";
4349 const details = document.createElement("button");
4350 details.type = "button";
4351 details.className = "ib-primary";
4352 details.textContent = "What's this?";
4353 details.addEventListener("click", () => {
4354 // Answer in place: run /integrity so the full read-out lands in the open
4355 // feed as a private notice (same sink the composer uses for commands).
4356 if (activeId) runCommand("/integrity", activeId, (r) => appendNotice(activeId, r));
4357 });
4358 const dismiss = document.createElement("button");
4359 dismiss.type = "button";
4360 dismiss.textContent = "Dismiss";
4361 dismiss.addEventListener("click", () => {
4362 integrity.acknowledge();
4363 renderIntegrityBanner();
4364 });
4365 actions.append(details, dismiss);
4366 el.append(icon, text, actions);
4367 el.classList.remove("hidden");
4368}
4369
4370// takeGossip inspects and strips a decrypted payload's `_ckpt` before anything
4371// downstream (folds, previews, app snapshots, the sandbox) sees it. Applied at
4372// EVERY message-decrypt site, live and history alike — the host chooses which
4373// path a message arrives by, so gossip that only counted when live would let
4374// it route damning messages through catch-up. countPeer guards the stats:
4375// own echoes and history refolds compare (evidence is evidence) but don't
4376// inflate "peer heads compared".
4377function takeGossip(payload, countPeer) {
4378 if (payload && payload._ckpt) {
4379 try {
4380 integrity.gossipIn(payload._ckpt, { countPeer: !!countPeer });
4381 } catch {}
4382 delete payload._ckpt;
4383 }
4384 return payload;
4385}
4386
4387registerFloorCommand("/integrity", {
4388 desc: "Check this space's tamper-evidence chain (only visible to you)",
4389 run: (_args, _threadId, reply) => {
4390 const st = integrity.state();
4391 const lines = [];
4392 if (!st.head) {
4393 lines.push("No verified checkpoints yet — the chain starts with this space's first activity.");
4394 } else {
4395 lines.push(`Verified head: entry ${st.head.seqTo}, chain ${st.head.chain.slice(0, 12)}…`);
4396 if (st.stats.lastPollAt) lines.push(`Last checked: ${new Date(st.stats.lastPollAt).toLocaleString()}`);
4397 lines.push(st.sigOk ? "Signatures: verified (Ed25519)" : "Signatures: not checked (this browser lacks WebCrypto Ed25519); hash chain still verified");
4398 const seen = st.stats.gossipSeen || 0;
4399 if (seen) lines.push(`Peer heads compared: ${seen} (${st.stats.gossipMatch || 0} matched)`);
4400 const verified = st.stats.sampleVerified || 0;
4401 if (verified) lines.push(`Your messages proven in the record: ${verified.toLocaleString()}`);
4402 // KT: members you invited who are keyed but published no bind-MAC — an
4403 // older client (normal during rollout) or, post-rollout, a possible
4404 // downgrade worth a second look.
4405 if (bindUnverifiedCount) {
4406 lines.push(
4407 bindUnverifiedCount === 1
4408 ? "1 member you invited couldn't be key-verified (older client, or worth checking)."
4409 : `${bindUnverifiedCount.toLocaleString()} members you invited couldn't be key-verified (older clients, or worth checking).`,
4410 );
4411 }
4412 }
4413 if (st.alerts && st.alerts.length) {
4414 lines.push(`⚠️ ${st.alerts.length} integrity alert(s) — latest: ${st.alerts.at(-1).kind}. Keep this device's records and contact your admin.`);
4415 } else {
4416 lines.push("No integrity alerts.");
4417 }
4418 reply({ text: lines.join("\n") });
4419 },
4420});
4421
4422// ---- mini-apps ----------------------------------------------------------
4423// Mini-app events ride the exact same encrypted-message path as chat: the app
4424// only produces a typed payload, and the transport wraps it with the same
4425// envelope (name/uid/ts) chat uses, encrypts to the thread key, and sends it.
4426setEmitTransport(async (threadId, event) => {
4427 let key = threadKeys[threadId];
4428 // An embed emitting into a thread it wasn't seeded with (a personal command
4429 // typed in a group): ask the native shell for that one thread's key before
4430 // giving up — the web app proper never hits this (it unwraps every thread).
4431 if (!key && embed && (await requestNativeKey(threadId))) key = threadKeys[threadId];
4432 if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
4433 // A client id for this event: it rides inside the ciphertext (the server never
4434 // sees it), and the server relays the SAME blob back, so the echo carries it
4435 // verbatim — that's how we pair our optimistic local copy with its
4436 // authoritative confirmation and replace it silently (see optimisticEmit).
4437 const cid = newCid();
4438 const payload = { ...event, name: stampName(), ts: Date.now(), _cid: cid };
4439 if (myUserId) payload.uid = myUserId;
4440 // Tuck our verified chain head into the encrypted payload (tamper-evidence
4441 // gossip) — peers compare it against their own view; apps never see it.
4442 const head = integrity.gossipOut();
4443 if (head) payload._ckpt = head;
4444 optimisticEmit(threadId, payload, cid);
4445 return encrypt(key, payload).then((blob) => {
4446 ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
4447 return true;
4448 });
4449});
4450
4451// A fresh client id for an outgoing event (crypto.randomUUID where available; a
4452// cheap unique fallback otherwise — it only needs to be unique within this tab).
4453let cidSeq = 0;
4454function newCid() {
4455 if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
4456 return "c" + Date.now() + "-" + (cidSeq++);
4457}
4458
4459// Optimistic local echo: fold our own event straight into its surface so a send
4460// appears with no network wait, then let the server's broadcast quietly replace
4461// it. A CHAT message becomes a pending bubble (and, if unconfirmed after 5s, a
4462// subtle "sending…" state via markSlow); a typed app event folds only when its
4463// app opted in (optimistic: true) with an id-idempotent reducer, so the echo
4464// re-fold lands on the same entity rather than duplicating it.
4465function optimisticEmit(threadId, payload, cid) {
4466 if (payload.type) {
4467 if (optimisticFold(threadId, payload, cid) && threadId === activeId) renderActiveSurfaces();
4468 return;
4469 }
4470 if ((chatAppFor(threadId) || threadApp[threadId]) && (payload.text || payload.attachments)) {
4471 foldHostEvent(threadId, "chat", chatMsgEvent(payload), { cid, ts: payload.ts, pending: true });
4472 if (threadId === activeId) renderActiveSurfaces();
4473 markSlow(threadId, cid);
4474 }
4475}
4476
4477// Pending optimistic sends, keyed by client id → a timer that, 5s after emit and
4478// still unconfirmed, flips the bubble to a subtle "sending…" state (delivery is
4479// taking a while and might not have gone through). The echo clears it. We never
4480// remove the optimistic copy on failure — stay-pending — only surface the delay.
4481const slowTimers = new Map();
4482function markSlow(threadId, cid) {
4483 const t = setTimeout(() => {
4484 slowTimers.delete(cid);
4485 foldHostEvent(threadId, "chat", { type: "chat.pending", _cid: cid });
4486 if (threadId === activeId) renderActiveSurfaces();
4487 }, 5000);
4488 slowTimers.set(cid, t);
4489}
4490function clearSlow(cid) {
4491 const t = slowTimers.get(cid);
4492 if (t) {
4493 clearTimeout(t);
4494 slowTimers.delete(cid);
4495 }
4496}
4497
4498// Connected third-party services (docs/connected-services.md). The privileged
4499// half of the connect app — the parts an app can't do itself because they need
4500// the session keypair, roster public keys, and the socket. Two moves:
4501// • store: seal a credential to your OWN key and PUT the opaque blob (the server
4502// stores ciphertext it can't read);
4503// • request: reopen it locally and hand a bot a SHORT-LIVED grant sealed to that
4504// bot's public key. The bot never gets your long-lived token — only a copy
4505// that self-expires, sealed so only it can open it.
4506// Web-only: the native embed never holds userPriv, so both no-op there.
4507// Find a chat that has a bot in it (prefer a DM) — the bot is the actor that does
4508// connected-service work (docs/connected-services.md).
4509// Find the best thread to hand a connected-service op to. Only the default agents
4510// (Eleven/Claude) run the connect handler, so rank agents: the always-present
4511// assistant Eleven first, then Claude, then any AI bot; never the write-only
4512// Announcer, and prefer a lower-ranked custom bot only if nothing better exists.
4513// (A proper per-bot "handles connect" capability flag is the real fix — see
4514// docs/connected-services.md — this heuristic covers the default fleet.)
4515function agentRank(e) {
4516 if (!e || !e.is_agent || !e.public_key) return -1;
4517 if (/announcer/i.test(e.label || "")) return -1;
4518 if (/eleven/i.test(e.label || "")) return 3;
4519 if (/claude/i.test(e.label || "")) return 2;
4520 if (e.kind === "ai") return 1;
4521 return 0;
4522}
4523
4524function findBotThread() {
4525 let best = null;
4526 for (const t of threads) {
4527 for (const id of t.members || []) {
4528 if (id === myUserId) continue;
4529 const e = roster.find((x) => x.user_id === id);
4530 const r = agentRank(e);
4531 if (r < 0) continue;
4532 const score = r * 2 + (t.kind === "dm" ? 1 : 0); // prefer higher-ranked bot, then a DM
4533 if (!best || score > best.score) best = { thread: t, bot: e, score };
4534 }
4535 }
4536 return best ? { thread: best.thread, bot: best.bot } : null;
4537}
4538
4539// Poll a thread for the bot's sealed connect.token reply (the OAuth result).
4540async function awaitConnectToken(threadId, key, service, tries = 24) {
4541 for (let i = 0; i < tries; i++) {
4542 await new Promise((r) => setTimeout(r, 500));
4543 const r = await fetch(`/api/messages?thread=${encodeURIComponent(threadId)}&token=${encodeURIComponent(session.token)}&limit=20`);
4544 const { messages = [] } = r.ok ? await r.json() : {};
4545 for (const m of messages) {
4546 try {
4547 const p = await decrypt(key, m.blob);
4548 if (p.type === "connect.token" && p.service === service) return p.sealed;
4549 } catch {}
4550 }
4551 }
4552 return null;
4553}
4554
4555// Poll a thread for the bot's connect.result reply to one op, matched by the
4556// request id we minted (rid) so a stale/older result for the same service can't
4557// be mistaken for this one. Returns the whole payload ({ domains } or { error }).
4558async function awaitConnectResult(threadId, key, rid, tries = 24) {
4559 for (let i = 0; i < tries; i++) {
4560 await new Promise((r) => setTimeout(r, 500));
4561 const r = await fetch(`/api/messages?thread=${encodeURIComponent(threadId)}&token=${encodeURIComponent(session.token)}&limit=20`);
4562 const { messages = [] } = r.ok ? await r.json() : {};
4563 for (const m of messages) {
4564 try {
4565 const p = await decrypt(key, m.blob);
4566 if (p.type === "connect.result" && p.rid === rid) return p;
4567 } catch {}
4568 }
4569 }
4570 return null;
4571}
4572
4573let oauthPending = null; // { service, state }
4574const oauthCfgCache = {}; // service -> { client_id, authorize_url, redirect_uri }
4575// ---- fetch-service doorway (docs/fetch.md) --------------------------------
4576// One GET against the shared fetch service, handling its optional hashcash
4577// admission: a 401 carries {pow:{challenge,bits}} — grind SHA-256(challenge
4578// ":" nonce) to `bits` leading zeros (async digests yield, so the UI keeps
4579// breathing), retry with the solution, and keep the returned ticket for every
4580// call after (one solve per ~hour, not per request). Query params, not
4581// headers — a custom header would cost a CORS preflight per call.
4582let fetchServiceTicket = "";
4583
4584function fsLeadingZeroBits(bytes) {
4585 let bits = 0;
4586 for (const b of bytes) {
4587 if (b === 0) { bits += 8; continue; }
4588 for (let mask = 128; mask; mask >>= 1) {
4589 if (b & mask) return bits;
4590 bits++;
4591 }
4592 }
4593 return bits;
4594}
4595
4596async function fetchServiceGET(path) {
4597 const base = window.__fetchURL;
4598 if (!base) return null;
4599 const sep = path.includes("?") ? "&" : "?";
4600 const get = (extra) => fetch(base + path + extra, { signal: AbortSignal.timeout(15000) });
4601 let r = await get(fetchServiceTicket ? `${sep}ticket=${encodeURIComponent(fetchServiceTicket)}` : "");
4602 if (r.status === 401) {
4603 const c = ((await r.json().catch(() => ({}))).pow) || null;
4604 if (!c || !c.challenge) return r;
4605 const enc = new TextEncoder();
4606 for (let n = 0; ; n++) {
4607 const d = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(`${c.challenge}:${n}`)));
4608 if (fsLeadingZeroBits(d) >= c.bits) {
4609 r = await get(`${sep}pow=${encodeURIComponent(c.challenge)}&nonce=${n}`);
4610 fetchServiceTicket = r.headers.get("X-Fetch-Ticket") || fetchServiceTicket;
4611 break;
4612 }
4613 }
4614 }
4615 return r;
4616}
4617
4618let oauthCompleting = false; // true while a returned code is being exchanged (drives the "Connecting…" state)
4619
4620// Fetch + cache the broker's public OAuth config. The connect surface warms this
4621// on render so the Connect click can open the popup DIRECTLY at the authorize URL:
4622// a blank popup navigated afterwards (window.open("") then .location=) is rejected
4623// by DNSimple (the blob/access-control 400), a direct top-level nav is not.
4624async function loadOAuthCfg(service) {
4625 if (oauthCfgCache[service]) return oauthCfgCache[service];
4626 try {
4627 const r = await fetch(`/api/oauth/config?service=${encodeURIComponent(service)}&token=${encodeURIComponent(session.token)}`);
4628 const cfg = r.ok ? await r.json() : null;
4629 if (cfg && cfg.client_id) oauthCfgCache[service] = cfg;
4630 return cfg;
4631 } catch {
4632 return null;
4633 }
4634}
4635
4636// Send the whole tab to the provider's authorize page. DNSimple rejects popup /
4637// programmatic-window navigations (the blob/access-control 400); only a top-level
4638// tab navigation works — and this is also what the native app wants (consent in
4639// the system browser). `state` carries this instance's origin (base64url) so the
4640// broker relays the code back here, plus a nonce for CSRF matching; we stash the
4641// pending {service,state} in sessionStorage because module state is lost across
4642// the full-tab round-trip. The browser never holds a secret.
4643function openAuthorize(service, cfg, cap) {
4644 if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
4645 if (!cfg || !cfg.client_id || !cfg.authorize_url || !cfg.redirect_uri)
4646 return { ok: false, error: `${service} isn't set up for OAuth on this server yet` };
4647 const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
4648 const state = bytesToB64url(new TextEncoder().encode(location.origin)) + "." + nonce;
4649 // The access cap the member chose rides the round-trip so the completion can
4650 // seal it into the stored credential — least privilege is picked BEFORE the
4651 // provider is ever visited.
4652 sessionStorage.setItem("lchat_oauth", JSON.stringify({ service, state, cap: cap || "read" }));
4653 let url =
4654 `${cfg.authorize_url}?response_type=code&client_id=${encodeURIComponent(cfg.client_id)}` +
4655 `&redirect_uri=${encodeURIComponent(cfg.redirect_uri)}&state=${encodeURIComponent(state)}`;
4656 // Providers whose manifests declare OAuth scopes get them on the authorize
4657 // URL, matched to the chosen cap (scopes_write only when the member asked
4658 // for read & write) — the provider-side token is then no broader than the
4659 // cap it will live under. Scope-less manifests (DNSimple, GitHub Apps —
4660 // which scope at install time) send no scope param, exactly as before.
4661 const scopes = cap === "write" && Array.isArray(cfg.scopes_write) && cfg.scopes_write.length ? cfg.scopes_write : cfg.scopes;
4662 if (Array.isArray(scopes) && scopes.length) url += `&scope=${encodeURIComponent(scopes.join(" "))}`;
4663 location.assign(url);
4664 return { ok: true };
4665}
4666
4667// The billing variant of openAuthorize: a space ADMIN connects the space's
4668// Stripe account for paid access levels. Same broker + consent trip, but the
4669// state's "billing." marker asks the broker to finish the exchange itself and
4670// hand back ONLY the connected-account id — no token ever reaches the browser
4671// or the server. Direct charges authenticate with the platform key + that id,
4672// which is the same non-secret string admins used to paste by hand.
4673function openBillingAuthorize(cfg) {
4674 if (!session.userPriv) return { ok: false, error: "connecting Stripe isn't supported in the native app yet" };
4675 if (!cfg || !cfg.client_id || !cfg.authorize_url || !cfg.redirect_uri)
4676 return { ok: false, error: "Stripe isn't set up for OAuth on this server yet" };
4677 const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
4678 const state = bytesToB64url(new TextEncoder().encode(location.origin)) + ".billing." + nonce;
4679 sessionStorage.setItem("lchat_oauth", JSON.stringify({ service: "stripe", state, billing: true }));
4680 let url =
4681 `${cfg.authorize_url}?response_type=code&client_id=${encodeURIComponent(cfg.client_id)}` +
4682 `&redirect_uri=${encodeURIComponent(cfg.redirect_uri)}&state=${encodeURIComponent(state)}`;
4683 if (Array.isArray(cfg.scopes) && cfg.scopes.length) url += `&scope=${encodeURIComponent(cfg.scopes.join(" "))}`;
4684 location.assign(url);
4685 return { ok: true };
4686}
4687
4688// On the way back (the app booted at "/" after the callback stashed the code),
4689// finish the exchange. Runs on ws.onopen when roster/threads are loaded; no-ops
4690// unless there's a returned code waiting. Idempotent — clears the pending entry.
4691async function completeOAuthReturn() {
4692 // Billing return first: nothing to exchange (the broker already did) — just
4693 // link the account id under THIS admin session. postBillingAccount is
4694 // tierAdmin-gated, so a non-admin session can't complete a link.
4695 {
4696 let pend;
4697 try {
4698 pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
4699 } catch {
4700 pend = null;
4701 }
4702 if (pend && pend.billing) {
4703 sessionStorage.removeItem("lchat_oauth");
4704 let text;
4705 if (pend.error || !pend.billingAccount) {
4706 text = "Stripe connection cancelled.";
4707 } else {
4708 try {
4709 const r = await fetch("/api/admin/billing/account", {
4710 method: "POST",
4711 headers: { "Content-Type": "application/json" },
4712 body: JSON.stringify({ token: session.token, account: pend.billingAccount }),
4713 });
4714 text = r.ok
4715 ? "Stripe account connected — you can price your levels now."
4716 : `Couldn't save the Stripe account (${(await r.json().catch(() => ({}))).error || r.status}).`;
4717 } catch {
4718 text = "Couldn't save the Stripe account — try again.";
4719 }
4720 }
4721 window.dispatchEvent(new CustomEvent("lchat:billing-changed", { detail: { text } }));
4722 return;
4723 }
4724 }
4725 let pend;
4726 try {
4727 pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
4728 } catch {
4729 pend = null;
4730 }
4731 if (!pend) return;
4732 if (pend.error) {
4733 sessionStorage.removeItem("lchat_oauth");
4734 window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text: `${pend.service || "Connection"} cancelled.` } }));
4735 return;
4736 }
4737 if (!pend.code || oauthCompleting) return; // no code yet (still at consent), or already completing
4738 oauthCompleting = true;
4739 window.dispatchEvent(new CustomEvent("lchat:connected-changed")); // repaint into "Connecting…"
4740 let text;
4741 try {
4742 await completeOAuth(pend, pend.code);
4743 text = `${pend.service} connected — the server never saw your token.`;
4744 } catch (err) {
4745 text = `Couldn't connect ${pend.service}: ${err.message}`;
4746 }
4747 // Clear pending state BEFORE the repaint (dispatchEvent is synchronous — clearing
4748 // in a finally after the dispatch would leave the surface stuck on "Connecting…").
4749 oauthCompleting = false;
4750 sessionStorage.removeItem("lchat_oauth");
4751 window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text } }));
4752}
4753
4754// After the popup relays the one-time code (the verifier stayed here in memory),
4755// hand code+verifier to the bot; it exchanges (no client secret) and returns the
4756// token sealed to us; we store it client-encrypted. The browser never does the
4757// exchange (CORS) and never holds a secret.
4758async function completeOAuth(pend, code) {
4759 const target = findBotThread();
4760 if (!target) throw new Error("start a chat with a bot first — it does the work");
4761 const key = threadKeys[target.thread.id];
4762 if (!key || !ws || ws.readyState !== WebSocket.OPEN) throw new Error("not connected right now");
4763 const msg = { type: "connect.oauth", service: pend.service, code, state: pend.state, name: session.name, ts: Date.now() };
4764 if (myUserId) msg.uid = myUserId;
4765 ws.send(JSON.stringify({ type: "msg", thread_id: target.thread.id, blob: await encrypt(key, msg) }));
4766 const sealed = await awaitConnectToken(target.thread.id, key, pend.service);
4767 if (!sealed) throw new Error("the bot didn't hand back a token — is it running?");
4768 const opened = await openSealed(sealed, session.userPriv);
4769 // Expiring-token providers hand back {secret, refresh, expires_at}; store
4770 // the whole pair (absent fields drop out of the JSON) so ops can rotate it.
4771 // The access cap chosen before the consent trip rides into the credential,
4772 // as does the credential's kind — mixed-scheme providers (Linear) send an
4773 // OAuth token as Bearer where a pasted key goes in the header bare.
4774 await storeCredential(pend.service, { ...opened, cap: pend.cap, kind: "oauth" });
4775}
4776
4777// Seal a credential object to the member's OWN key and store the opaque blob —
4778// the storage half of connecting, shared by the OAuth completion and the
4779// renewed-pair path (a bot rotating an expired token mid-op). `cap` is the
4780// member's connect-time access choice ("read" | "write"); it lives inside the
4781// sealed blob, so the server never learns it and only this member's devices
4782// can read or change it.
4783async function storeCredential(service, cred) {
4784 const me = roster.find((e) => e.user_id === myUserId);
4785 const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at, cap: cred.cap, kind: cred.kind });
4786 const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, {
4787 method: "PUT",
4788 headers: { "Content-Type": "application/json" },
4789 body: JSON.stringify({ token: session.token, blob }),
4790 });
4791 if (!r.ok) throw new Error(`couldn't save the connection (${r.status})`);
4792}
4793
4794// The shared preamble of every connected-service op: locate the bot channel and
4795// reopen the member's stored credential. Returns { target, key, opened } on
4796// success, { error } otherwise. The reopened secret never leaves this module —
4797// callers reseal it into a short-lived grant addressed to the bot's own key.
4798async function openOpChannel(service) {
4799 if (!session.userPriv) return { error: "connecting services isn't supported in the native app yet" };
4800 const target = findBotThread();
4801 if (!target) return { error: "start a chat with a bot first — it does the work" };
4802 const key = threadKeys[target.thread.id];
4803 if (!key || !ws || ws.readyState !== WebSocket.OPEN) return { error: "not connected right now" };
4804 const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
4805 const got = r.ok ? await r.json() : {};
4806 if (!got.connected || !got.blob) return { error: `you haven't connected ${service} yet` };
4807 let opened;
4808 try {
4809 opened = await openSealed(got.blob, session.userPriv);
4810 } catch {
4811 return { error: "couldn't reopen your stored credential" };
4812 }
4813 return { target, key, opened };
4814}
4815
4816// Run one registry op through the bot (the generalized sibling of the legacy
4817// dnsimple-shaped requestData below): seal a short-lived, ACCESS-SCOPED grant
4818// to the bot's key — a "read" grant cannot run a write op — send `connect.op`
4819// with the manifest's named args, and await the structured `connect.result`
4820// matched by rid. Used by the service_op consent chip and exposed to apps as
4821// ctx.host.connected.requestOp.
4822async function requestOp(service, op, args, access, ttlMs = 120000, src = "") {
4823 const ch = await openOpChannel(service);
4824 if (ch.error) return { ok: false, error: ch.error };
4825 // The connection's cap is the ceiling the member set when they connected:
4826 // a read-only connection never mints a write grant, whatever asked for it —
4827 // the refusal happens HERE, before any grant exists, so a tampered chip or
4828 // an over-eager proposal has nothing to escalate.
4829 if (!capAllows(ch.opened.cap, access)) {
4830 return { ok: false, error: `you connected ${service} read-only — change its access in Integrations to allow writes` };
4831 }
4832 const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
4833 const grant = await sealForPub(ch.target.bot.public_key, {
4834 service,
4835 token: ch.opened.secret,
4836 exp: Date.now() + ttlMs,
4837 access: access || "read",
4838 // Expiring-token providers: the refresh token + expiry ride in the grant
4839 // so the bot can rotate a stale pair mid-op (absent fields drop out).
4840 refresh: ch.opened.refresh,
4841 token_expires_at: ch.opened.expires_at,
4842 // The credential's kind rides too: mixed-scheme providers (Linear) need
4843 // the executor to send an OAuth token as Bearer where a pasted key goes
4844 // in the header bare.
4845 kind: ch.opened.kind,
4846 });
4847 const payload = { type: "connect.op", service, op, args: args || {}, grant, rid, name: session.name, ts: Date.now() };
4848 // Chip-originated ops are the bot's own proposals: tagging them lets the
4849 // sidecar answer a FAILURE in the bot's voice (a plain-language explanation
4850 // in-thread) — app-surface fetches stay untagged and render their own errors.
4851 if (src) payload.src = src;
4852 if (myUserId) payload.uid = myUserId;
4853 ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
4854 const res = await awaitConnectResult(ch.target.thread.id, ch.key, rid);
4855 if (!res) return { ok: false, error: "the bot didn't respond — is it running?" };
4856 if (res.error) return { ok: false, error: res.error };
4857 // The bot rotated the pair mid-op and sealed the fresh one to us: re-store
4858 // it (best-effort — the op itself already succeeded) so the next op starts
4859 // from the live pair. Providers' refresh tokens are single-use, so this
4860 // device (the one that triggered the rotation) is the one that persists it.
4861 if (res.renewed) {
4862 try {
4863 // The rotated pair inherits the connection's cap — rotation refreshes
4864 // the token, never the member's access choice. A rotated pair is an
4865 // OAuth pair by construction (only they carry refresh tokens).
4866 await storeCredential(service, { ...(await openSealed(res.renewed, session.userPriv)), cap: ch.opened.cap, kind: "oauth" });
4867 } catch {}
4868 }
4869 return { ok: true, ...res };
4870}
4871
4872// Start a read-only research session (the service_research chip): seal a
4873// short-lived READ grant per service to the bot — reads are within every cap,
4874// so a read-only connection joins a research session freely — send ONE
4875// connect.research with the goal, and wait for the quick ack (the answer
4876// itself arrives as a normal bot reply in the thread). Renewed token pairs
4877// (a provider rotation mid-research) come back on `<rid>.renew`; a background
4878// poll re-stores them so the connection outlives the rotation.
4879async function requestResearch(services, goal) {
4880 const ttlMs = 10 * 60 * 1000;
4881 const grants = {};
4882 const caps = {};
4883 let target = null;
4884 let key = null;
4885 for (const service of services) {
4886 const ch = await openOpChannel(service);
4887 if (ch.error) return { ok: false, error: ch.error };
4888 target = ch.target;
4889 key = ch.key;
4890 caps[service] = ch.opened.cap;
4891 grants[service] = await sealForPub(ch.target.bot.public_key, {
4892 service,
4893 token: ch.opened.secret,
4894 exp: Date.now() + ttlMs,
4895 access: "read",
4896 refresh: ch.opened.refresh,
4897 token_expires_at: ch.opened.expires_at,
4898 kind: ch.opened.kind,
4899 });
4900 }
4901 const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
4902 const payload = { type: "connect.research", goal, grants, rid, name: session.name, ts: Date.now() };
4903 if (myUserId) payload.uid = myUserId;
4904 ws.send(JSON.stringify({ type: "msg", thread_id: target.thread.id, blob: await encrypt(key, payload) }));
4905 const ack = await awaitConnectResult(target.thread.id, key, rid);
4906 if (!ack) return { ok: false, error: "the bot didn't respond — is it running?" };
4907 if (ack.error) return { ok: false, error: ack.error };
4908 pollResearchRenewals(target.thread.id, key, rid, caps);
4909 return { ok: true, bot: target.bot.label || "the bot" };
4910}
4911
4912// After a research session, quietly watch (~2 min) for a `<rid>.renew` result
4913// carrying rotated token pairs, and re-store each under its connection's cap.
4914// Best-effort: the session already answered; this only keeps expiring-token
4915// connections (GitHub Apps) alive past the rotation their research consumed.
4916async function pollResearchRenewals(threadId, key, rid, caps) {
4917 for (let i = 0; i < 40; i++) {
4918 await new Promise((r) => setTimeout(r, 3000));
4919 const res = await awaitConnectResult(threadId, key, rid + ".renew", 1);
4920 if (!res || !res.renewed) continue;
4921 for (const [service, sealed] of Object.entries(res.renewed)) {
4922 try {
4923 // Rotated pairs are OAuth pairs by construction.
4924 await storeCredential(service, { ...(await openSealed(sealed, session.userPriv)), cap: caps[service], kind: "oauth" });
4925 } catch {}
4926 }
4927 return;
4928 }
4929}
4930
4931setHostBridge({
4932 connected: {
4933 // cap is the member's access choice for this connection ("read" |
4934 // "write"); it's sealed into the credential blob alongside the token.
4935 async store(service, secret, cap) {
4936 if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
4937 const me = roster.find((e) => e.user_id === myUserId);
4938 if (!me || !me.public_key) return { ok: false, error: "your encryption key isn't ready yet" };
4939 // A pasted token is a personal key — kind "pat" keeps the manifest's
4940 // header scheme (Linear: bare) where OAuth tokens force Bearer.
4941 const blob = await sealForPub(me.public_key, { secret, cap: cap || "read", kind: "pat" });
4942 const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, {
4943 method: "PUT",
4944 headers: { "Content-Type": "application/json" },
4945 body: JSON.stringify({ token: session.token, blob }),
4946 });
4947 return r.ok ? { ok: true } : { ok: false, error: `couldn't save (${r.status})` };
4948 },
4949 // Re-seal the stored credential under a different access cap — the member
4950 // changing their mind in Integrations without re-pasting or re-consenting
4951 // at the provider. Requires their presence (userPriv) like every open.
4952 async setCap(service, cap) {
4953 if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
4954 try {
4955 const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
4956 const got = r.ok ? await r.json() : {};
4957 if (!got.connected || !got.blob) return { ok: false, error: `you haven't connected ${service} yet` };
4958 const opened = await openSealed(got.blob, session.userPriv);
4959 await storeCredential(service, { ...opened, cap: cap === "write" ? "write" : "read" });
4960 return { ok: true };
4961 } catch (e) {
4962 return { ok: false, error: (e && e.message) || "couldn't update the connection" };
4963 }
4964 },
4965 // The connected service names, so the surface can show what's linked.
4966 async list() {
4967 const r = await fetch(`/api/connected?token=${encodeURIComponent(session.token)}`);
4968 return r.ok ? (await r.json()).services || [] : [];
4969 },
4970 // The service whose returned OAuth code is being exchanged (or waiting to
4971 // be), else "" — so the surface can show that row as "Connecting…" instead
4972 // of flashing "Not connected". Truthy/falsy for old callers.
4973 pending() {
4974 try {
4975 const p = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
4976 if (oauthCompleting) return (p && p.service) || "dnsimple";
4977 return p && p.code ? p.service || "dnsimple" : "";
4978 } catch {
4979 return "";
4980 }
4981 },
4982 async disconnect(service) {
4983 const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`, { method: "DELETE" });
4984 window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text: r.ok ? `Disconnected ${service}.` : "" } }));
4985 return { ok: r.ok };
4986 },
4987 // Warm the OAuth config so the Connect click can open the popup directly.
4988 prepare(service) {
4989 loadOAuthCfg(service);
4990 },
4991 // Whether the broker has an OAuth app for this service — the catalog only
4992 // renders a "Connect with X" button when the flow can actually start
4993 // (otherwise the row offers paste-a-token alone).
4994 async oauthReady(service) {
4995 const cfg = await loadOAuthCfg(service);
4996 return !!(cfg && cfg.client_id && cfg.authorize_url && cfg.redirect_uri);
4997 },
4998 // Open the authorize popup. If config is already warm (the surface prefetched
4999 // it), open synchronously so the click's user-gesture is kept and the popup
5000 // navigates DIRECTLY to the URL. Uncached (a very fast click) falls back to a
5001 // fetch-then-open, which the browser may block — rare, given the prefetch.
5002 startOAuth(service, cap) {
5003 const cached = oauthCfgCache[service];
5004 if (cached) return Promise.resolve(openAuthorize(service, cached, cap));
5005 return loadOAuthCfg(service).then((cfg) => openAuthorize(service, cfg, cap));
5006 },
5007 request(service, op, arg, ttlMs = 120000) {
5008 return (async () => {
5009 const ch = await openOpChannel(service);
5010 if (ch.error) return { ok: false, error: ch.error };
5011 const grant = await sealForPub(ch.target.bot.public_key, { service, token: ch.opened.secret, exp: Date.now() + ttlMs });
5012 const payload = { type: "connect.op", service, op, domain: arg, grant, name: session.name, ts: Date.now() };
5013 if (myUserId) payload.uid = myUserId;
5014 ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
5015 return { ok: true, bot: ch.target.bot.label || "the bot" };
5016 })();
5017 },
5018 // Like request(), but a request/response round-trip: the bot answers with a
5019 // structured, non-rendering `connect.result` (matched by rid) that we return
5020 // to the caller — so an app (e.g. the DNSimple companion) can render the data
5021 // itself instead of it just landing as a chat message. Same grant model: the
5022 // token is sealed to the bot for one op, opened only in its trust domain.
5023 requestData(service, op, arg, ttlMs = 120000) {
5024 return (async () => {
5025 // Legacy dnsimple wire shape (`domain: arg`, un-scoped grant) — kept
5026 // byte-identical for the existing DNSimple companion; new callers use
5027 // requestOp with named args + an access-scoped grant.
5028 const ch = await openOpChannel(service);
5029 if (ch.error) return { ok: false, error: ch.error };
5030 const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
5031 const grant = await sealForPub(ch.target.bot.public_key, { service, token: ch.opened.secret, exp: Date.now() + ttlMs });
5032 const payload = { type: "connect.op", service, op, domain: arg, grant, rid, name: session.name, ts: Date.now() };
5033 if (myUserId) payload.uid = myUserId;
5034 ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
5035 const res = await awaitConnectResult(ch.target.thread.id, ch.key, rid);
5036 if (!res) return { ok: false, error: "the bot didn't respond — is it running?" };
5037 if (res.error) return { ok: false, error: res.error };
5038 return { ok: true, ...res };
5039 })();
5040 },
5041 // The registry-driven op runner: manifest-named args + an access-scoped
5042 // grant. Same request/response round-trip as requestData.
5043 requestOp,
5044 // The integrations catalog the instance serves (the manifest registry):
5045 // [{ id, name, category, auth, ops }, …]. null when the fetch fails, so the
5046 // surface can fall back rather than render an empty catalog.
5047 async catalog() {
5048 try {
5049 const r = await fetch(`/api/integrations?token=${encodeURIComponent(session.token)}`);
5050 if (!r.ok) return null;
5051 const j = await r.json();
5052 return Array.isArray(j.services) ? j.services : null;
5053 } catch {
5054 return null;
5055 }
5056 },
5057 // Whether one service has a stored credential, plus its access cap when a
5058 // key is present to open the blob locally ("read" | "write"; undefined =
5059 // a legacy, cap-less connection). The blob itself never leaves this module.
5060 async status(service) {
5061 try {
5062 const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
5063 const got = r.ok ? await r.json() : {};
5064 const out = { connected: !!got.connected };
5065 if (out.connected && got.blob && session.userPriv) {
5066 try {
5067 out.cap = (await openSealed(got.blob, session.userPriv)).cap;
5068 } catch {}
5069 }
5070 return out;
5071 } catch {
5072 return { connected: false };
5073 }
5074 },
5075 },
5076 // The Vault app's scoped doorway to the roster + session key (docs/locked-vaults.md).
5077 // A locked vault hands its key to members by SEALING it to each member's roster
5078 // public key; opening that handoff needs the session PRIVATE key. Both live
5079 // here, in shell.js, never in the app — the seam stays narrow (two calls, no
5080 // raw keys ever cross it).
5081 vault: {
5082 // The other human members of a thread, each with their roster public key —
5083 // so a lock (or a give) can seal the vault key to them as a `member` handoff.
5084 // Excludes ME (the locker/giver already holds a durable wrap) and BOTS
5085 // (is_agent): a locked vault must NEVER hand its key to a machine member,
5086 // whose private key lives on a sidecar box (CLAUDE.md). Keyless members
5087 // (a pending invitee) are skipped — an insider gives them access once they
5088 // join. Names are the PUBLISHED label (rides into the emitted wrap, so it
5089 // must not carry my private nickname). Empty for a personal vault → the lock
5090 // seals to my passkey alone. Synchronous (a plain read over in-memory state).
5091 members(threadId) {
5092 const t = threads.find((x) => x.id === threadId);
5093 if (!t) return [];
5094 return (t.members || [])
5095 .filter((uid) => uid && uid !== myUserId)
5096 .map((uid) => roster.find((x) => x.user_id === uid))
5097 .filter((e) => e && e.public_key && !e.is_agent)
5098 .map((e) => ({ uid: e.user_id, name: publishedLabel(e.user_id), pub: e.public_key }));
5099 },
5100 // Open a handoff (an ECIES blob sealed to MY roster public key) with my
5101 // session private key — the vault key sealed to me at lock time, or by a
5102 // give. Returns the decrypted JSON (the app reads the key out of `.k`), or
5103 // null if it wasn't sealed to me / I have no key here (native embed).
5104 async openMine(blob) {
5105 if (!session.userPriv) return null;
5106 try {
5107 return await openSealed(blob, session.userPriv);
5108 } catch {
5109 return null;
5110 }
5111 },
5112 },
5113 // The /gif app's scoped Giphy doorway (apps never fetch — the host does, and
5114 // only to *.giphy.com, which the CSP pins). This is the client-side sibling
5115 // of the unfurl exception: the SERVER never sees the search, the pick, or
5116 // the bytes — Giphy sees the query from the member's own client.
5117 //
5118 // The doorway holds NO key and no storage: the key is the SPACE's, folded into
5119 // the space thread's log, and the app reads it off its own state and hands it
5120 // in. All the host does is the two things an app can't — the pinned fetch, and
5121 // posting into the chat the member is actually looking at.
5122 gif: {
5123 // Search returns the raw data[] page — or a reason string: "key" (the space
5124 // has no key yet), "auth" (Giphy rejected it), null (network/other), so the
5125 // panel can ask for a key exactly when one is the problem.
5126 async search(q, key, limit = 24) {
5127 // The shared fetch service searches with the platform's key (docs/fetch.md
5128 // § Giphy) — no space key needed, and its results come back with every
5129 // media URL already signed onto the service's own /img proxy, so the
5130 // browser never talks to Giphy at all. 404 = the service has no upstream
5131 // key; that and any transport failure fall through to the space's
5132 // bring-your-own-key path below.
5133 if (window.__fetchURL) {
5134 try {
5135 const r = await fetchServiceGET(`/api/gif?q=${encodeURIComponent(q || "")}&limit=${limit}`);
5136 if (r && r.ok) return (await r.json()).data || [];
5137 } catch (e) {
5138 console.error("gif search via fetch service failed:", e);
5139 }
5140 }
5141 if (!key) return "key";
5142 const base = "https://api.giphy.com/v1/gifs/";
5143 const url = q
5144 ? `${base}search?api_key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}&limit=${limit}&rating=pg-13`
5145 : `${base}trending?api_key=${encodeURIComponent(key)}&limit=${limit}&rating=pg-13`;
5146 try {
5147 const r = await fetch(url, { signal: AbortSignal.timeout(15000) });
5148 if (r.status === 401 || r.status === 403) return "auth";
5149 if (!r.ok) return null;
5150 return (await r.json()).data || [];
5151 } catch (e) {
5152 console.error("gif search failed:", e);
5153 return null;
5154 }
5155 },
5156 // Download one rendition's bytes — Giphy hosts only, size-capped (checked
5157 // again after download; Content-Length can be absent). Failures log the
5158 // step, so a native shell's unified log shows WHERE a post died.
5159 async bytes(url, maxBytes) {
5160 try {
5161 const u = new URL(url);
5162 // Two allowed sources, matching the two search paths: the fetch
5163 // service's signed /img proxy, or Giphy directly (space-key path).
5164 const viaFetch = window.__fetchURL && u.origin === new URL(window.__fetchURL).origin;
5165 const giphy = u.protocol === "https:" && (u.hostname === "giphy.com" || u.hostname.endsWith(".giphy.com"));
5166 if (!viaFetch && !giphy) {
5167 console.error("gif bytes refused url outside giphy/fetch-service");
5168 return null;
5169 }
5170 const r = await fetch(u, { signal: AbortSignal.timeout(30000) });
5171 if (!r.ok) {
5172 console.error("gif bytes fetch not ok:", r.status);
5173 return null;
5174 }
5175 const buf = new Uint8Array(await r.arrayBuffer());
5176 if (maxBytes && buf.length > maxBytes) {
5177 console.error("gif bytes over cap:", buf.length);
5178 return null;
5179 }
5180 return buf;
5181 } catch (e) {
5182 console.error("gif bytes failed:", e);
5183 return null;
5184 }
5185 },
5186 // Encrypt + upload bytes AS-IS (no image re-encode — an animated GIF must
5187 // survive; the caller already bounded the size) and post them as an ordinary
5188 // message into the chat the member is looking at.
5189 //
5190 // "Here" is the HOST's to know, not the app's: /gif's ctx addresses the
5191 // SPACE (where its key lives), so the app has no handle on the conversation
5192 // — and shouldn't. It gets no say in the destination: activeId is read here,
5193 // exactly as the composer would.
5194 //
5195 // Reading activeId at TAP time (rather than capturing it when the command
5196 // ran) is safe because a command panel is not free-floating chrome: it is
5197 // mounted INSIDE its own thread's pane (chat.js keys cmdPanels by threadId,
5198 // and each pane registers its controller), and only the active thread's pane
5199 // is on screen. So a panel you can tap is necessarily the active thread's,
5200 // and activeId is necessarily the chat it was opened in. If panels ever
5201 // become detachable from their pane, this must capture the thread at open
5202 // time instead — a GIF in the wrong conversation is a leak, not a glitch.
5203 async post(bytes, meta = {}) {
5204 const threadId = activeId;
5205 const key = threadKeys[threadId];
5206 if (!key || !bytes || !bytes.length || bytes.length > MAX_UPLOAD) return false;
5207 try {
5208 const enc = await encryptBytes(key, bytes);
5209 const id = await uploadEncrypted(threadId, enc);
5210 const att = { id, name: meta.name || "gif.gif", type: meta.type || "image/gif", size: bytes.length };
5211 if (meta.w) att.w = meta.w;
5212 if (meta.h) att.h = meta.h;
5213 // The same transport ctx.emit uses, so the message carries the usual
5214 // name/ts/uid and reads as an ordinary attachment from the member.
5215 return await emitAppEvent(threadId, { text: "", attachments: [att] });
5216 } catch (e) {
5217 console.error("gif post failed:", e);
5218 return false;
5219 }
5220 },
5221 },
5222});
5223
5224// The OAuth popup lands on /oauth/<service>/callback, which postMessages the
5225// one-time code back here; we hold the matching verifier. Complete on receipt.
5226window.addEventListener("message", (e) => {
5227 if (e.origin !== location.origin || !e.data || e.data.source !== "lchat-oauth") return;
5228 const pend = oauthPending;
5229 if (!pend || e.data.state !== pend.state) return;
5230 oauthPending = null;
5231 const done = (text) => window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text } }));
5232 if (e.data.error || !e.data.code) return done(`${pend.service} connection cancelled.`);
5233 completeOAuth(pend, e.data.code)
5234 .then(() => done(`${pend.service} connected — the server never saw your token.`))
5235 .catch((err) => done(`Couldn't connect ${pend.service}: ${err.message}`));
5236});
5237
5238// Scheduling transport (ctx.schedule / ctx.unschedule): encrypt the event NOW —
5239// the server holds only ciphertext + (thread, time) — and let it deliver the
5240// blob into the thread at the due moment, where it arrives as a normal message.
5241setScheduleTransport({
5242 schedule: async (threadId, event, whenMs) => {
5243 const key = threadKeys[threadId];
5244 if (!key) return null;
5245 const payload = { ...event, name: session.name, ts: Date.now() };
5246 if (myUserId) payload.uid = myUserId;
5247 const blob = await encrypt(key, payload);
5248 try {
5249 const r = await fetch("/api/schedule", {
5250 method: "POST",
5251 headers: { "Content-Type": "application/json" },
5252 body: JSON.stringify({
5253 token: session.token,
5254 thread_id: threadId,
5255 deliver_at: Math.floor(whenMs / 1000),
5256 blob,
5257 }),
5258 });
5259 if (!r.ok) return null;
5260 return (await r.json()).id || null;
5261 } catch {
5262 return null;
5263 }
5264 },
5265 cancel: async (sid) => {
5266 try {
5267 const r = await fetch(`/api/schedule/${encodeURIComponent(sid)}?token=${encodeURIComponent(session.token)}`, {
5268 method: "DELETE",
5269 });
5270 return r.ok;
5271 } catch {
5272 return false;
5273 }
5274 },
5275});
5276
5277// The chat app's host bridge: everything that is a server mechanism or needs
5278// the keys/token rather than an event. Reactions/unsend/edit ride the exact WS
5279// frames the original feed sends (so author-verification and per-user dedupe
5280// apply identically); blobs, uploads, unfurls, and history pages reuse the
5281// original machinery — the app itself never touches crypto or tokens.
5282setChatAppHost({
5283 // A thread with no other members (self threads — the setup conversation, app
5284 // threads): notices there are implicitly private, so no "Only visible to you".
5285 soloThread: (threadId) => {
5286 const t = threads.find((x) => x.id === threadId);
5287 return !!t && t.kind === "self";
5288 },
5289 react: async (threadId, targetSid, emoji, on, targetUid, targetText) => {
5290 const key = threadKeys[threadId];
5291 if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
5292 const payload = {
5293 type: "reaction",
5294 targetSid,
5295 emoji,
5296 on,
5297 name: stampName(),
5298 uid: myUserId,
5299 ts: Date.now(),
5300 };
5301 // The fold (chat.js "chat.reaction") keys reactions by (emoji, uid) and
5302 // drops uid-less events — without an identity the reaction can't render
5303 // or be un-reacted, so don't send one.
5304 if (!myUserId) return false;
5305 // The reacted-to message's author, riding inside the ciphertext: push
5306 // handlers (sw.js, the iOS NSE) use it to show the reaction only to the
5307 // author — the server never learns whose message it was. Absent for
5308 // legacy uid-less messages (then no one is notified).
5309 if (targetUid) payload.targetUid = targetUid;
5310 // A snippet of the reacted-to message, likewise inside the ciphertext: the
5311 // chat list needs it to say "reacted 👍 to “buy milk”" for a thread it has
5312 // never opened (the row is built from last_blob alone, so there is nothing
5313 // to look the target up in). A snapshot, like a reply quote's — it outlives
5314 // an edit or a delete of the original.
5315 if (targetText) payload.targetText = targetText;
5316 try {
5317 const blob = await encrypt(key, payload);
5318 ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
5319 return true;
5320 } catch {
5321 return false;
5322 }
5323 },
5324 del: (threadId, messageId, blobIds) => {
5325 if (!ws || ws.readyState !== WebSocket.OPEN) return;
5326 const frame = { type: "delete", thread_id: threadId, message_id: messageId };
5327 // The server can't see which blobs a message referenced (they're inside the
5328 // ciphertext), so the app — which can — reports them for GC on delete.
5329 if (blobIds && blobIds.length) frame.blob_ids = blobIds;
5330 ws.send(JSON.stringify(frame));
5331 },
5332 // Status pill for app-side actions (e.g. Copy) — reuses the classic feed's
5333 // toast so confirmations look identical to the original chat.
5334 toast: (msg) => toast(msg),
5335 // Re-encrypt the full payload (the app passes { ...orig, text }, exactly like
5336 // the original edit path) and send the original edit frame.
5337 edit: (threadId, messageId, payload) => {
5338 const key = threadKeys[threadId];
5339 if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false);
5340 return encrypt(key, payload).then((blob) => {
5341 ws.send(JSON.stringify({ type: "edit", thread_id: threadId, message_id: messageId, blob }));
5342 return true;
5343 });
5344 },
5345 // Fetch + decrypt an attachment blob (cached object URL — same path the
5346 // original feed uses for thumbnails and downloads).
5347 loadBlob: (threadId, att, onProgress) => loadBlob(att.id, threadId, att.type, onProgress),
5348 upload: (threadId, file, onProgress) => chatAppUpload(threadId, file, onProgress),
5349 unfurl: async (url) => {
5350 // Prefer the shared fetch service when this instance points at one (docs/fetch.md):
5351 // identity-free — no token rides along — and the card's image comes back
5352 // as the service's signed proxy URL. Any transport failure falls through to
5353 // the instance's own unfurler, so a fetch-service outage degrades to today's path
5354 // (an ok:false answer is a real answer — the page had nothing — not
5355 // a reason to re-ask the instance).
5356 if (window.__fetchURL) {
5357 try {
5358 const r = await fetchServiceGET(`/api/unfurl?url=${encodeURIComponent(url)}`);
5359 if (r && r.ok) return await r.json();
5360 } catch {}
5361 }
5362 try {
5363 return await (
5364 await fetch(`/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(url)}`)
5365 ).json();
5366 } catch {
5367 return null;
5368 }
5369 },
5370 loadOlder: (threadId, beforeSid) => chatAppLoadOlder(threadId, beforeSid),
5371 // A member's current (client-decrypted) roster name by uid, "" when unknown —
5372 // the bubble-header lookup, my own name for them winning (#577). Deliberately
5373 // no "Someone" fallback, so the renderer can fall through to the payload's
5374 // name snapshot.
5375 memberName: (uid) => {
5376 const e = roster.find((x) => x.user_id === uid);
5377 return displayNameFor(nicknames[uid], e && e.label);
5378 },
5379 // Mentions: the roster's (client-decrypted) display names. Deliberately the
5380 // PUBLISHED names, not my nicknames — a mention travels as text every member
5381 // must resolve, so "@Dermo" would highlight for no one else. Tapping one
5382 // opens the member's profile card (Message them, mute, block, …).
5383 mentionNames: () => roster.map((e) => e.label).filter(Boolean),
5384 mention: (label) => {
5385 const entry = roster.find((e) => e.label === label);
5386 if (entry && entry.user_id !== myUserId) viewProfile(entry.user_id);
5387 },
5388 // Renderers gate the tappable @mention affordance on this so a self-mention
5389 // stays a plain bold chip (no link, no menu) — matching the classic feed.
5390 mentionable: (label) => {
5391 const entry = roster.find((e) => e.label === label);
5392 return !!entry && entry.user_id !== myUserId;
5393 },
5394 // The chip text for a matched @mention: my name for them when I have one
5395 // (#577). Read-side only — matching, the menu, and the wire text all stay
5396 // on published names; only what THIS viewer sees on the chip changes.
5397 mentionDisplay: (label) => {
5398 const entry = roster.find((e) => e.label === label);
5399 return (entry && displayNameFor(nicknames[entry.user_id], entry.label)) || label;
5400 },
5401 // Presentation facts the app needs to render the CLASSIC markup exactly:
5402 // sender names show only in groups; helper senders get the AI/BOT badge; the
5403 // hover icons are the same static in-code SVGs the original feed uses.
5404 isGroup: (threadId) => {
5405 const t = threads.find((x) => x.id === threadId);
5406 return !!t && t.kind === "group";
5407 },
5408 // Returns "ai" / "bot" for a helper sender's display name, null for a person.
5409 agentKind: (name) => {
5410 const e = roster.find((x) => x.is_agent && x.label === name);
5411 return e ? e.kind || "bot" : null;
5412 },
5413 // The empty-state intro for an app-thread's chat ("This is the start of your
5414 // chat history with To-do…") — the owning app's chatIntro, or null for a normal
5415 // chat (no intro).
5416 chatIntro: (threadId) => {
5417 const owner = threadApp[threadId];
5418 if (owner) {
5419 const a = appByName(owner);
5420 return (a && a.chatIntro) || null;
5421 }
5422 // A person-to-person DM gets one too, so a brand-new chat isn't a blank
5423 // void before the first message (or while a typing indicator runs).
5424 const t = threads.find((x) => x.id === threadId);
5425 if (t && t.kind === "dm") {
5426 const other = (t.members || []).find((uid) => uid !== myUserId);
5427 const name = other && rosterLabel(other);
5428 if (name) return `This is the start of your chat with ${name}.`;
5429 }
5430 // An open community group opens with a start marker that invites the first
5431 // hello — a stranger arriving from Discover lands in a room that greets
5432 // them rather than a blank feed. Persists as the history's first row (it
5433 // scrolls up as the conversation grows); the count stays current as people
5434 // join (the intro re-renders when its text changes — see chat.js).
5435 if (t && t.kind === "group" && t.visibility === "open") {
5436 const name = threadName[threadId] || "this group";
5437 const n = (t.members || []).length;
5438 const who = n === 1 ? "There's 1 person here." : `There are ${n} people here.`;
5439 return `This is the start of the conversation in ${name}. ${who} Say hi!`;
5440 }
5441 return null;
5442 },
5443 icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN },
5444 // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted,
5445 // labelled, and run by the host — the app just mounts the returned row.
5446 actionChips: (actions) => {
5447 const chips = sanitizeActions(actions);
5448 return chips.length ? buildActionChips(chips) : null;
5449 },
5450 // Typeahead: the original candidate ranking (emoji + roster mentions) and the
5451 // original avatar element for mention rows — one source, identical results.
5452 typeaheadItems: (tok) => typeaheadItems(tok),
5453 taAvatar: (name, userId) => avatarEl(name, () => avatarFor(userId)),
5454 // Native shell (iOS/macOS webview) integration: whether we're embedded, the
5455 // channel back to the shell (reply banner / staged / error state), and the
5456 // registration point for the app's composer handlers — installNativeBridge
5457 // routes window.lchatNative through these when the lab is active.
5458 embed: !!embed,
5459 native: (msg) => nativePost(msg),
5460 registerNative: (handlers) => {
5461 chatAppNative = handlers;
5462 for (const f of preMountStaged.splice(0)) handlers.stage(f);
5463 },
5464 // Moderation (issue #578): the muted-people set lives here in the shell
5465 // (sealed prefs blob, synced across devices); the app folds a muted member's
5466 // messages into a quiet stub and offers Mute/Unmute in the message menu.
5467 mutedPerson: (uid) => mutedPeople.has(uid),
5468 setPersonMuted: (uid, on) => setPersonMuted(uid, on),
5469 // Report a message (issue #578): the sheet lives in the shell — it holds
5470 // the roster and the keys that seal the report to each admin.
5471 reportMessage: (threadId, m) => openReportSheet({ uid: m.uid, threadId, message: m }),
5472 // Message permalinks ("Copy link", issue #893): mints an opaque, server-
5473 // sealed token (links.go — never a thread/instance key, so decoding a link
5474 // always re-checks CURRENT thread membership) and returns a shareable URL,
5475 // or null on failure. The app writes it to the clipboard and toasts, same
5476 // as the plain-text Copy action.
5477 //
5478 // This instance's own origin for now (openInitialMessageLink reads #l= from
5479 // the fragment regardless of how it got there). The design target is a
5480 // shared https://11ch.at/l/<host>#l=<token> redirector so the link opens
5481 // natively on every device via one Associated Domain instead of one per
5482 // instance host (see #893) — but that redirector isn't deployed yet, so
5483 // emitting it today would produce dead links. Swap the template below for
5484 // it once the service exists; nothing else here changes.
5485 mintMessageLink: async (threadId, messageId) => {
5486 try {
5487 const r = await fetch(`/api/messages/${encodeURIComponent(messageId)}/link?token=${encodeURIComponent(session.token)}`, {
5488 method: "POST",
5489 headers: { "Content-Type": "application/json" },
5490 body: JSON.stringify({ thread_id: threadId }),
5491 });
5492 if (!r.ok) return null;
5493 const { token } = await r.json();
5494 return token ? `${location.origin}/#l=${encodeURIComponent(token)}` : null;
5495 } catch {
5496 return null;
5497 }
5498 },
5499 // Closed DMs (issue #578): the app swaps its composer for the closed banner
5500 // and offers Reopen; the server owns the state, the shell mirrors it.
5501 dmClosed: (threadId) => !!dmClosed[threadId],
5502 reopenDM: async (threadId) => {
5503 try {
5504 const r = await fetch(`/api/threads/${encodeURIComponent(threadId)}/reopen-dm`, {
5505 method: "POST",
5506 headers: { "Content-Type": "application/json" },
5507 body: JSON.stringify({ token: session.token }),
5508 });
5509 if (r.ok) {
5510 dmClosed[threadId] = false;
5511 hidden[threadId] = false;
5512 renderThreadList();
5513 renderActiveSurfaces(); // swap the banner back to the composer
5514 return { ok: true };
5515 }
5516 const body = await r.json().catch(() => ({}));
5517 return { ok: false, error: body.error || "server" };
5518 } catch {
5519 return { ok: false, error: "network" };
5520 }
5521 },
5522});
5523
5524// The lab app's native-composer handlers (registered via the bridge when the
5525// app mounts in an embedded webview); null while original drives.
5526let chatAppNative = null;
5527
5528// chatAppUpload stages one file for the chat app: the same downscale / extension
5529// allow-list / client-side encryption path as the original composer. On success
5530// it returns the attachment metadata that rides inside the encrypted payload; on
5531// failure it returns a reason string ("type" / "photo" / "size" / "generic") so
5532// the app can restore the original composer's three distinct error messages.
5533// POST encrypted bytes to the blob store, reporting upload progress (0..1) via
5534// onProgress. Uses XHR because fetch() can't observe request-body upload progress
5535// (and streaming request bodies aren't supported on Safari/iOS).
5536function uploadEncrypted(threadId, bytes, onProgress) {
5537 return new Promise((resolve, reject) => {
5538 const xhr = new XMLHttpRequest();
5539 xhr.open(
5540 "POST",
5541 `/api/upload?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}`,
5542 );
5543 if (onProgress && xhr.upload) {
5544 xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
5545 }
5546 xhr.onload = () => {
5547 if (xhr.status >= 200 && xhr.status < 300) {
5548 try { resolve(JSON.parse(xhr.responseText).id); } catch { reject(new Error("bad upload response")); }
5549 } else {
5550 reject(new Error("upload " + xhr.status));
5551 }
5552 };
5553 xhr.onerror = () => reject(new Error("upload network error"));
5554 xhr.send(bytes);
5555 });
5556}
5557
5558async function chatAppUpload(threadId, file, onProgress) {
5559 const key = threadKeys[threadId];
5560 if (!key || !file) return "generic";
5561 const isImage = !!(file.type && file.type.startsWith("image/"));
5562 const isVideo = !!(file.type && file.type.startsWith("video/"));
5563 // Any file type uploads — it's an opaque encrypted blob to the server, and
5564 // the name/MIME ride inside the payload. Size stays the only gate.
5565 try {
5566 // Every blob is tagged with its thread so the server can GC it with the
5567 // thread / when the referencing message is deleted — it never sees the blob
5568 // id (it's inside the encrypted payload). The thread id isn't secret to it.
5569 const post = async (bytes, onProg) => {
5570 const enc = await encryptBytes(key, bytes);
5571 return uploadEncrypted(threadId, enc, onProg);
5572 };
5573 if (isImage) {
5574 const { bytes, type, w, h, bh } = await processImage(file);
5575 if (bytes.length > MAX_UPLOAD) return "photo";
5576 const id = await post(bytes);
5577 const att = { id, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg", type, w, h };
5578 if (bh) att.bh = bh;
5579 return att;
5580 }
5581 if (isVideo) {
5582 // Send-and-play: encrypt the clip whole + upload it, plus a client-extracted
5583 // poster frame (the server can't make one — it only holds ciphertext). Dims
5584 // and duration ride inside the encrypted payload as attachment metadata.
5585 const bytes = new Uint8Array(await file.arrayBuffer());
5586 if (bytes.length > MAX_VIDEO_UPLOAD) return "video";
5587 // Extract the poster concurrently with th