eleven / ocho Public

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

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