// The multi-thread chat app (see docs/threads-and-dms.md). One instance holds // many threads — DMs, groups, and "Notes" (self). Each thread has its own AES // key, wrapped to each member's user public key; this browser unwraps with the // session's user private key. The server only ever relays opaque ciphertext. // // MVP scope: thread list + DMs/groups + text messaging + roster picker, with a // master-detail layout (desktop) and list↔thread navigation (mobile). Reactions, // attachments, edits, link cards, and push are deferred to a follow-up. import { importKey, encrypt, decrypt, encryptBytes, decryptBytes, generateKey, generateUserKeypair, wrapThreadKey, unwrapThreadKey, sealForPub, openSealed, pkcePair, bytesToB64url, b64urlToBytes, randomSecret, deriveInvite, bindMac, verifyBindMac, mintBrandingKey, deriveBrandingKey, } from "/static/crypto.js"; import { shouldSend, fileAllowed, fileMime, fileIcon, TEXT_SIZES, composerToken, pinnedFirst, ownMessage, sanitizeActions, memberCountLabel, onlineFilterKeeps, chatCategory, presenceLabel, displayNameFor, renameNotice, translateMentions, capAllows, appReloadMessage, } from "/static/ui.js"; import { richTokens, blockTokens, firstLink, replySnippet, reactionLine, convertLine, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js"; import { emojify, emojiMatches } from "/static/emoji.js"; import { startBootScene, finishBootScene } from "/static/bootscene.js"; import { processImage, videoMeta } from "/static/media.js"; import { plainText as mdPlainText } from "/static/markdown.js"; import { createIntegrity } from "/static/integrity.js"; import { registerFloorCommand, setEmitTransport, setScheduleTransport, setEphemeralTransport, setStateSaver, setCurrentUser, setAppThreadResolver, setToast, setDefaultBot, setThreadHasBot, setThreadIsGroup, setThreadOwner, setThreadOpener, setHostBridge, setSpaceInfo, spaceInfo, setElevenTour, setSetupBridge, setupBridge, runCommand, parseCommand, commandCatalog, buildLocalNotice, isAppType, foldEvent, foldHostEvent, optimisticFold, deliverEphemeral, appPreview, appUnread, resetThread, seedThread as seedAppThread, takeSnapshot, notifyLive, renderApp as renderMiniApp, appByName, appCatalogReport, appIsQuiet, appForType, appWantsChatView, appDefaultView, appChatOnly, appStateFor, groupCapableApps, groupAppsFor, installGroupApp, installSpaceApp, spaceAppsFor, setSpaceThread, setSpaceThreadResolver, setSpaceInstallPolicy, setInstanceOpener, mentionableApps, emitAppEvent, messageActionsFor, renderInline as renderAppLine, setCommandPanelObserver, closeCommandPanel, setMemoryStore, appGlyphInto, addCommandPanelObserver, spaceRowTitle, } from "/static/apps.js"; // Register the mini-apps (side-effect imports). Each is a single self-contained // file (JS + its own CSS). Personal apps get their own sidebar thread; shared // apps run inside a chat; system apps (the chat feed) are mounted explicitly. import "/static/apps/todo.js"; import "/static/apps/remind.js"; import "/static/apps/bookmarks.js"; import "/static/apps/poll.js"; import "/static/apps/vault.js"; import "/static/apps/board.js"; import "/static/apps/agents.js"; // shared remote Claude Code sessions (fleet board; bridge = lchat agents run) import "/static/apps/connect.js"; import "/static/apps/dnsimple.js"; import "/static/apps/stripe.js"; import "/static/apps/hubspot.js"; import "/static/apps/github.js"; import "/static/apps/linear.js"; import "/static/apps/webhook-monitor.js"; import { openThreadView } from "/static/apps/views.js"; // views: bot-created data surfaces (docs/views.md); also registers the app import "/static/apps/emoji.js"; // /emoji — the command-panel reference app import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif) import { startTour } from "/static/tutorial.js"; // also registers the /tutorial floor command import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app) import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app) import { planQuickForm } from "/static/quickform.js"; // "Fill in a form instead" — values → verbs+records import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip) import "/static/apps/webhooks.js"; // incoming-webhooks management (admin-only; /webhooks — docs/webhooks.md) import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md) import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md) import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane, threadListCount, openThreadList, MAX_CONVERT_IDS } from "/static/apps/chat.js"; import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js"; import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js"; import { editGroup, showMembers, viewProfile, confirmRemoveFromSpace, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js"; import { addDevice, openInApp } from "/static/adddevice.js"; import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal, billingDate, levelPrice, ensureTierKeys } from "/static/billingui.js"; import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js"; import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md) import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586) import { wireMediaView, openMediaView, closeMediaView, mediaInvalidate, mediaModerationChanged } from "/static/mediaview.js"; // Media/Links/Docs overlay import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md) import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import import { 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) import { openLightbox } from "/static/lightbox.js"; import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js"; import { parseInviteFragment, buildInviteFragment } from "/static/invite.js"; import { qrCanvas } from "/static/qrcode.js"; import { initBackupBanner, canRestore, autoRestoreRequested, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js"; import { needsHomeScreen, pushAvailable, pushEnabled, pushDenied, registerSW, subscribePush, unsubscribePush, isSubscribed, idbSet, } from "/static/push.js"; // The local message store (#457): raw server responses cached in IndexedDB so // reloads and thread switches paint instantly (and, later, search works). See // store.js's header for the trust-boundary rationale (ciphertext only). import { SNAP_VERSION, snapshotUsable, staleThreadIds, getSnapshot as storeGetSnapshot, putSnapshot as storePutSnapshot, getRecentMessages as storeRecentMessages, putMessages as storePutMessages, applyEditRow as storeApplyEdit, deleteMessageRow as storeDeleteMessage, clearThread as storeClearThread, cachedThreadIds as storeCachedThreadIds, wipeStore, allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill, } from "/static/store.js"; import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js"; // Populate the behaviour seam: the core verbs extracted modules call through // (function declarations hoist, so these references are valid here). Object.assign(verbs, { openThread, loadThreads, renderThreadList, renderActiveSurfaces, paintThreadHeader, modal, toast, displayName, threadAvatarFor, announceJoin, announceLeave, announceRemove, sendChatText, loadBlobBytes, chatAppFor, chatAppLoadOlder, stashAppPane, resolveRename, showList, paintHeaderAvatar, loadTiers, setNickname, syncThreadKeysToSW, inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed, decryptRosterLabels, openThreadById, seedAppState, welcomePending, isAdmin, removeFromSpace, banFromSpace, cacheTiersRaw, manageGroupApps, openSpaceApp, }); onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change wireSearch({ openThread, chatAppFor, chatAppLoadOlder, threadAvatar: threadRowAvatarEl }); // navigation + thread predicate + scroll-back paging + row avatars wireMediaView({ 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 // Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the // envelope's `ephemeral` flag asks the server to relay-and-forget — no store, // no push, no message id. Presence traffic only. setEphemeralTransport((threadId, event) => { const key = threadKeys[threadId]; if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false); const payload = { ...event, name: session.name, ts: Date.now() }; if (myUserId) payload.uid = myUserId; return encrypt(key, payload).then((blob) => { ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob, ephemeral: true })); return true; }); }); // A mute folds bubbles in the open thread; a block also re-badges the list. onModerationChange((threadsToo) => { renderActiveSurfaces(); if (threadsToo) renderThreadList(); mediaModerationChanged(); // repaint an open Media/Links/Docs overlay too }); const SESSION_KEY = "lchat_session"; const isCoarse = matchMedia("(pointer: coarse)").matches; // Video clips get a larger ceiling (Signal-style send-and-play). Matches the // server's default LCHAT_MAX_UPLOAD_BYTES; a lower per-instance cap just means // the server rejects the upload and the composer shows a "couldn't add" message. const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // ---- split view divider ---------------------------------------------------- // Desktop-only Chat|Interface split (see the [data-app-view="split"] CSS): // #app-split-divider lets a member drag the boundary between #app-pane and // #interface-pane. The ratio is a local preference, same pattern as the theme // and text size — applied as a CSS custom property the split CSS reads. const SPLIT_RATIO_KEY = "lchat_split_ratio"; const SPLIT_RATIO_MIN = 0.2; const SPLIT_RATIO_MAX = 0.8; const clampSplitRatio = (r) => Math.min(SPLIT_RATIO_MAX, Math.max(SPLIT_RATIO_MIN, r)); const currentSplitRatio = () => { const v = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY)); return Number.isFinite(v) ? clampSplitRatio(v) : 0.5; }; function applySplitRatio(ratio) { document.documentElement.style.setProperty("--split-ratio", String(ratio)); const divider = document.getElementById("app-split-divider"); if (divider) divider.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); } function setSplitRatio(ratio) { ratio = clampSplitRatio(ratio); localStorage.setItem(SPLIT_RATIO_KEY, String(ratio)); applySplitRatio(ratio); } // #app-split-divider lives inside #app-tpl (cloned into the DOM by renderApp, // below), so it doesn't exist yet at module load — applySplitRatio's element // lookup is a no-op until renderApp calls wireSplitDivider(). function wireSplitDivider() { const divider = document.getElementById("app-split-divider"); const container = document.getElementById("app-split"); if (!divider || !container) return; applySplitRatio(currentSplitRatio()); const step = 0.02; let startX = 0; let startRatio = 0.5; divider.addEventListener("pointerdown", (e) => { divider.setPointerCapture?.(e.pointerId); startX = e.clientX; startRatio = currentSplitRatio(); divider.classList.add("dragging"); }); divider.addEventListener("pointermove", (e) => { if (!divider.classList.contains("dragging")) return; const rect = container.getBoundingClientRect(); if (rect.width) setSplitRatio(startRatio + (e.clientX - startX) / rect.width); }); const endDrag = (e) => { divider.classList.remove("dragging"); divider.releasePointerCapture?.(e.pointerId); }; divider.addEventListener("pointerup", endDrag); divider.addEventListener("pointercancel", endDrag); divider.addEventListener("dblclick", () => setSplitRatio(0.5)); divider.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") setSplitRatio(currentSplitRatio() - step); else if (e.key === "ArrowRight") setSplitRatio(currentSplitRatio() + step); else if (e.key === "Home") setSplitRatio(SPLIT_RATIO_MIN); else if (e.key === "End") setSplitRatio(SPLIT_RATIO_MAX); else if (e.key === "Enter") setSplitRatio(0.5); else return; e.preventDefault(); }); } // ---- action chips --------------------------------------------------------- // One-tap buttons a helper attaches to its reply ("Switch to dark" etc.). // Consent-based by design: nothing runs until the member taps — payloads // aren't sender-signed, so a chip is a *suggestion*, and the whitelist in // ui.js keeps every runnable action harmless and reversible. const CHIP_RUNNERS = { set_theme: (a) => { setTheme(a.theme); return `${THEME_META[a.theme].name} ✓`; }, set_appearance: (a) => { setAppearance(a.appearance); return { light: "Light ✓", dark: "Dark ✓", system: "Following your device ✓" }[a.appearance]; }, open_theme_picker: () => (showThemePicker(), null), open_profile: () => (editProfile(), null), // Start Eleven's guided tour in the thread the chip was tapped in (the welcome // DM). The tour renders as private notices via chat.js's appendNotice; the chip // stays as-is (returns null), since the tour appears below it in the feed. open_tutorial: () => (startTour((spec) => appendNotice(activeId, spec)), null), open_thread: (a) => (openThreadById(a.thread), null), // Open a bot-created view in this chat (docs/views.md): pins the view's // panel above the feed from state already folded here. Returns the in-place // line when the view isn't in the fold (yet) — never throws. open_view: (a) => openThreadView(activeId, a.view) || null, // The memory scratchpad, for the thread the chip was tapped in (the chip is // sidecar-attached on every remember/forget write — transparency + the door // in one tap). Returns null: the chip keeps its label for re-taps. open_memory: () => (openMemoryPanel(activeId), null), // Connected-service op: the tap IS the consent (a write chip's label carries // the manifest's confirm line). Lock the chip immediately so a double-tap // can't run the op twice; the run is async and its outcome lands as a private // notice in the thread the chip was tapped in. service_op: (a) => { const threadId = activeId; requestOp(a.service, a.op, a.args, a.access, 120000, "chip").then((res) => // On failure: a plain-words line with the raw error QUOTED under it // (never dumped into the prose), and the bot follows up in-thread with // an explanation (the src:"chip" tag). appendNotice( threadId, res.ok ? { text: summarizeOpResult(a, res) } : { text: "That didn't work.", quote: res.error } ) ); return a.access === "write" ? "Confirmed ✓" : "Requested ✓"; }, // Run a slash command on THIS device — the tap is the permission ("add that // reminder"), and the command runs exactly where the member's apps live, so // /remind's time phrases parse in THEIR timezone. APP commands only: a bot // command or unknown verb is refused at the tap (parseCommand must resolve // to an owning app), and the app's own private reply ("I'll remind you…") // is the confirmation, rendered where the chip was tapped. run_command: (a) => { const threadId = activeId; const cmd = parseCommand(a.command, threadId); if (!cmd || !cmd.app) return "Not available here"; runCommand(a.command, threadId, (r) => appendNotice(threadId, r)); return "Done ✓"; }, // Research session: the tap consents to a short READ-ONLY look across the // named connected services; the bot's answer arrives as a normal reply in // this thread, so success needs no notice — only a failure to start does. service_research: (a) => { const threadId = activeId; requestResearch(a.services, a.goal).then((res) => { if (!res.ok) appendNotice(threadId, { text: "That didn't work.", quote: res.error }); }); return "Looking ✓"; }, }; // A small, safe line for a completed service op. res.data can be a large JSON // string from the provider — surface at most a name/id from it, never dump it. function summarizeOpResult(a, res) { let text = `Done — ${a.op} on ${a.service} succeeded.`; if (typeof res.data === "string" && res.data) { try { const parsed = JSON.parse(res.data); const d = parsed && parsed.data ? parsed.data : parsed; const v = d && !Array.isArray(d) && (d.name || d.title || d.id); if (typeof v === "string" || typeof v === "number") text += ` (${String(v).slice(0, 200)})`; if (Array.isArray(d)) text = `Done — ${a.op} on ${a.service} returned ${d.length} item${d.length === 1 ? "" : "s"}.`; } catch {} } return text; } const chipLabel = (a) => a.label || { set_theme: `Switch to ${(THEME_META[a.theme] || {}).name || a.theme}`, set_appearance: { light: "Switch to light", dark: "Switch to dark", system: "Follow my device" }[a.appearance], open_theme_picker: "Open theme picker", open_profile: "Edit my profile", open_tutorial: "Take the tour", open_thread: "Open the chat", open_view: "Open the view", open_memory: "See what I remember", service_op: `Run ${a.op} on ${a.service}`, service_research: `Look at ${(a.services || []).join(" & ")} (read-only)`, // The command IS the label (ui.js strips any free label) — a run_command // chip can never claim one thing and run another. run_command: a.command, }[a.type]; function buildActionChips(actions) { const row = document.createElement("div"); row.className = "chip-row"; for (const a of actions) { const b = document.createElement("button"); b.className = "action-chip"; b.textContent = chipLabel(a); b.onclick = () => { const done = CHIP_RUNNERS[a.type](a); if (done) { b.textContent = done; // applied in place — show it stuck b.disabled = true; } }; row.appendChild(b); } return row; } // Theme picker sheet (kebab → Theme): an appearance segmented control + a grid // of colour swatches. Both apply live and persist; nothing leaves the browser. function showThemePicker() { const backdrop = modal(); const card = backdrop.querySelector(".sheet"); const h = document.createElement("h2"); h.textContent = "Theme"; card.appendChild(h); const apLabel = document.createElement("label"); apLabel.className = "field-label"; apLabel.textContent = "Appearance"; card.appendChild(apLabel); const seg = document.createElement("div"); seg.className = "appearance-seg"; const appBtns = {}; for (const [val, lab] of [ ["light", "Light"], ["system", "System"], ["dark", "Dark"], ]) { const b = document.createElement("button"); b.type = "button"; b.textContent = lab; b.setAttribute("aria-pressed", String(currentAppearance() === val)); b.onclick = () => { setAppearance(val); for (const [v, el] of Object.entries(appBtns)) el.setAttribute("aria-pressed", String(v === val)); }; appBtns[val] = b; seg.appendChild(b); } card.appendChild(seg); const thLabel = document.createElement("label"); thLabel.className = "field-label"; thLabel.textContent = "Scheme"; thLabel.style.marginTop = "14px"; card.appendChild(thLabel); const grid = document.createElement("div"); grid.className = "theme-grid"; const swatches = {}; for (const id of THEME_ORDER) { const b = document.createElement("button"); b.type = "button"; b.className = "theme-swatch" + (currentTheme() === id ? " active" : ""); const disc = document.createElement("span"); disc.className = "disc"; disc.style.background = THEME_META[id].disc; const nm = document.createElement("span"); nm.textContent = THEME_META[id].name; b.appendChild(disc); b.appendChild(nm); b.onclick = () => { setTheme(id); for (const [v, el] of Object.entries(swatches)) el.classList.toggle("active", v === id); }; swatches[id] = b; grid.appendChild(b); } card.appendChild(grid); // Text size (desktop only — touch follows the system setting): the iMessage // slider, A…A over the sizes in ui.js. Applies live so the feed behind the // sheet previews the choice. if (!isCoarse) { const tsLabel = document.createElement("label"); tsLabel.className = "field-label"; tsLabel.textContent = "Text size"; tsLabel.style.marginTop = "14px"; card.appendChild(tsLabel); const row = document.createElement("div"); row.className = "text-size-row"; const small = document.createElement("span"); small.className = "text-size-a small"; small.textContent = "A"; const slider = document.createElement("input"); slider.type = "range"; slider.min = "0"; slider.max = String(TEXT_SIZES.length - 1); slider.step = "1"; slider.value = String(TEXT_SIZES.indexOf(currentTextSize())); slider.setAttribute("aria-label", "Text size"); slider.oninput = () => setTextSize(TEXT_SIZES[+slider.value]); const big = document.createElement("span"); big.className = "text-size-a big"; big.textContent = "A"; row.append(small, slider, big); card.appendChild(row); } // Composer preferences (#531): "Format as you type" applies live to every // mounted composer; "Classic composer" (the plain-textarea escape hatch) // takes effect on the next thread open — reopening the current one here so // the choice is visible immediately. const composerLabel = document.createElement("label"); composerLabel.className = "field-label"; composerLabel.textContent = "Composer"; composerLabel.style.marginTop = "14px"; card.appendChild(composerLabel); const toggleRow = (labelText, checked, onchange, hintText) => { const row = document.createElement("label"); row.className = "composer-pref-row"; const box = document.createElement("input"); box.type = "checkbox"; box.checked = checked; box.onchange = () => onchange(box.checked); const txt = document.createElement("span"); txt.textContent = labelText; row.append(box, txt); card.appendChild(row); if (hintText) { const hint = document.createElement("div"); hint.className = "composer-pref-hint"; hint.textContent = hintText; // literal markers on purpose — the hint teaches the syntax card.appendChild(hint); } return row; }; toggleRow( "Convert common Markdown formatting as you type", formatAsYouTypePref(), (on) => setFormatAsYouType(on), "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~", ); toggleRow("Classic plain-text composer", classicComposerPref(), (on) => { setClassicComposer(on); // Remount the open thread's pane so the switch is immediate. Parked panes // rebuild on their next open (appPaneCache entries are dropped). for (const k of Object.keys(appPaneCache)) delete appPaneCache[k]; const active = localStorage.getItem(ACTIVE_KEY); if (active && typeof openThreadById === "function") openThreadById(active); }); const foot = document.createElement("div"); foot.className = "sheet-foot"; const done = document.createElement("button"); done.textContent = "Done"; done.onclick = () => backdrop.remove(); foot.appendChild(done); card.appendChild(foot); } const preMountStaged = []; // native-staged files awaiting the chat app's composer (flushed by registerNative) const blobURLs = {}; // blob id -> decrypted object URL (cache) // ---- sealed branding (docs/no-plaintext.md) ------------------------------- // On a sealed instance the space's name/description/photo/admin identity live // in one server-held ciphertext blob, opened with the link-carried branding // key B. Members hold B here (localStorage after a link or an instance-key // unwrap); every minted invite link carries it in the #fragment. let brandingSalt = ""; // PBKDF2 salt for deriving from B (not secret) let serverBrandingSealed = false; // bootstrapBranding: after keys are up, learn whether this instance is sealed // and open the blob. B comes from localStorage (a link stashed it), else from // the copy sealed under the instance key — so members never re-visit a link. async function bootstrapBranding() { let g; try { g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); } catch { return; } if (!g) return; // The space's chosen name + photo also brand the sidebar's space rows (the // space band names each copy after the space — spaceRowTitle — and its tile // wears the space photo). Stash them here in the unsealed case; the sealed // path below stashes from the opened blob instead. name_set separates a // chosen name from the compiled-in default: a default never brands a row. if (!g.sealed) { if (g.name_set && g.name) spaceBrandName = g.name; if (g.avatar) spaceAvatarSrc = "/api/server-avatar"; return; } if (!g.branding_blob) return; serverBrandingSealed = true; brandingSalt = g.branding_salt || ""; try { setBrandingB(localStorage.getItem("lchat_branding_b") || null); } catch {} if (!brandingB && g.branding_key_sealed && instanceKey) { try { const opened = await decrypt(instanceKey, g.branding_key_sealed); if (opened && opened.b) { setBrandingB(opened.b); try { localStorage.setItem("lchat_branding_b", brandingB); } catch {} } } catch {} } if (!brandingB) return; try { const bKey = await deriveBrandingKey(brandingB, brandingSalt); setSealedBranding(await decrypt(bKey, g.branding_blob)); } catch { // Stale cached B — an unseal→re-seal mints a fresh key, and a device // still holding the old one would otherwise render generic forever (and // an admin device couldn't edit or unseal). The instance-key copy always // holds the CURRENT B: fall back to it and repair the cache. setSealedBranding(null); if (g.branding_key_sealed && instanceKey) { try { const opened = await decrypt(instanceKey, g.branding_key_sealed); if (opened && opened.b && opened.b !== brandingB) { setBrandingB(opened.b); try { localStorage.setItem("lchat_branding_b", brandingB); } catch {} const bKey = await deriveBrandingKey(brandingB, brandingSalt); setSealedBranding(await decrypt(bKey, g.branding_blob)); } } catch { setSealedBranding(null); // still undecryptable — surfaces stay generic } } } if (sealedBranding && sealedBranding.name) document.title = sealedBranding.name; paintSpaceName(); // The sealed counterparts of the plaintext stash above (the space band's // name + photo). The seal captured whatever name the space had, chosen or // not, so there's no name_set gate to apply here. if (sealedBranding) { if (sealedBranding.name) spaceBrandName = sealedBranding.name; spaceAvatarSrc = (await sealedServerAvatarURL()) || ""; } } // paintSpaceName: the sidebar heading IS the space's name (it ellipsizes, so // the sidebar width never depends on it). document.title is the one source of // the name on this page (served substituted, corrected at runtime when sealed // branding decrypts), so the heading just mirrors it — call after every // document.title update. function paintSpaceName() { const el = document.getElementById("side-space-name"); if (el) el.textContent = document.title; } // The space's display name + photo, stashed by bootstrapBranding for the // sidebar's space rows (before the space thread ever loads, so the band never // paints generic first). Empty = unbranded: spaceRowTitle falls back to // "Space ", the tile to the plain glyph. let spaceBrandName = ""; let spaceAvatarSrc = ""; // resealBranding: the sealed counterpart of saveGroupSettings — merge the // patch into the decrypted set, re-encrypt, and store. The plaintext setters // 409 while sealed, so every edit routes through here. async function resealBranding(patch) { if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return "error"; // Mirror the plaintext setter's 1–60 rule — the server can't see inside the // blob to enforce it, so the sealed path validates here. if (patch.name !== undefined && (!patch.name.trim() || patch.name.length > 60)) return "invalid"; const next = { ...sealedBranding, ...patch }; try { const bKey = await deriveBrandingKey(brandingB, brandingSalt); const body = { branding_blob: await encrypt(bKey, next), branding_salt: brandingSalt, branding_key_sealed: await encrypt(instanceKey, { b: brandingB }), }; const r = await fetch("/api/admin/branding/seal", { method: "POST", headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (r.ok) { setSealedBranding(next); if (next.name) { document.title = next.name; paintSpaceName(); } } return r.ok ? "ok" : r.status === 400 ? "invalid" : "error"; } catch { return "error"; } } // sealServerPhoto: change the space photo while sealed — the plaintext upload // endpoint 409s (deliberately), so the new bytes are encrypted under the // branding key and ride a re-seal (which also carries the new mime + alt // inside the blob). The sealed counterpart of pickServerPhoto's POST. async function sealServerPhoto(bytes, mime, alt) { if (!serverBrandingSealed || !sealedBranding || !brandingB || !instanceKey) return false; try { const bKey = await deriveBrandingKey(brandingB, brandingSalt); const next = { ...sealedBranding, avatar_mime: mime || "image/jpeg" }; if (alt !== undefined) next.avatar_alt = alt || ""; const r = await fetch("/api/admin/branding/seal", { method: "POST", headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, body: JSON.stringify({ branding_blob: await encrypt(bKey, next), branding_salt: brandingSalt, branding_key_sealed: await encrypt(instanceKey, { b: brandingB }), avatar_sealed: bytesToB64url(await encryptBytes(bKey, bytes)), }), }); if (r.ok) sealedBranding = next; return r.ok; } catch { return false; } } // sealedServerAvatarURL: a displayable URL for the space photo when sealed — // /api/server-avatar serves ciphertext, so fetch + decrypt to an object URL. // Returns null when there's no photo or no key to open it. async function sealedServerAvatarURL() { if (!serverBrandingSealed || !sealedBranding || !brandingB) return null; try { const resp = await fetch("/api/server-avatar"); if (!resp.ok) return null; const bKey = await deriveBrandingKey(brandingB, brandingSalt); const photo = await decryptBytes(bKey, b64urlToBytes(await resp.text())); return URL.createObjectURL(new Blob([photo], { type: sealedBranding.avatar_mime || "image/jpeg" })); } catch { return null; } } // sealBrandingNow: flip this instance to sealed branding. Mints B, gathers the // current plaintext set (including the photo bytes, re-encrypted under B), // stores the blob + the instance-key-sealed copy of B, and the server clears // every plaintext counterpart in the same request. Admin-gated server-side. async function sealBrandingNow() { if (!instanceKey) return "error"; try { const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json(); if (g.sealed) return "ok"; const b = mintBrandingKey(); const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); const bKey = await deriveBrandingKey(b, salt); const branding = { name: g.name || "", description: g.description || "", admin_name: g.admin_name || "", admin_email: g.admin_email || "", avatar_alt: g.avatar_alt || "", avatar_mime: "", }; const body = { branding_salt: salt, branding_key_sealed: await encrypt(instanceKey, { b }), }; if (g.avatar) { // The photo must ride the seal: the server clears a plaintext photo it // gets no sealed replacement for (it must never serve readable bytes // behind the sealed flag). So a failed fetch aborts — nothing has been // written yet, and the admin just retries. const resp = await fetch("/api/server-avatar"); if (!resp.ok) return "error"; branding.avatar_mime = resp.headers.get("Content-Type") || "image/jpeg"; const bytes = new Uint8Array(await resp.arrayBuffer()); body.avatar_sealed = bytesToB64url(await encryptBytes(bKey, bytes)); } body.branding_blob = await encrypt(bKey, branding); const r = await fetch("/api/admin/branding/seal", { method: "POST", headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) return r.status === 409 ? "ok" : "error"; setBrandingB(b); brandingSalt = salt; setSealedBranding(branding); serverBrandingSealed = true; try { localStorage.setItem("lchat_branding_b", b); } catch {} return "ok"; } catch { return "error"; } } // unsealBrandingNow: back to plaintext branding — the server can't decrypt, so // we hand it the values we just decrypted. The sealed photo is cleared // server-side (its bytes are useless in plaintext mode); the admin re-uploads. async function unsealBrandingNow() { if (!serverBrandingSealed || !sealedBranding) return "error"; try { const r = await fetch("/api/admin/branding/unseal", { method: "POST", headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" }, body: JSON.stringify({ name: sealedBranding.name || "", description: sealedBranding.description || "", admin_name: sealedBranding.admin_name || "", admin_email: sealedBranding.admin_email || "", avatar_alt: sealedBranding.avatar_alt || "", }), }); if (!r.ok) return "error"; serverBrandingSealed = false; setSealedBranding(null); setBrandingB(null); try { localStorage.removeItem("lchat_branding_b"); } catch {} return "ok"; } catch { return "error"; } } // Tiers — "access levels" in every UI surface: admin-curated member sets, // each with its own key on the instance-key pattern (docs/community-and-tiers.md). // The fixed reaction set (mirrors allowedReactions in hub.go). const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it // Open a burst with a centered time separator when consecutive messages are more // than this far apart (or land on a different day), rather than stamping each one. // An exact per-message time is still available on hover (desktop). const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; // The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md). import { 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"; const app = document.getElementById("app"); // The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with // everything needed to render ONE thread: {token, name, myUserId, threadId, // threadKey, instanceKey?}. In embed mode we render just that thread's feed — the // app draws its own sidebar, title bar and composer. The user *private* key is // never handed to the webview: native unwraps the thread key and passes only it, // so a single thread's page can't reach any other thread's content. setSession(embed ? { token: embed.token, name: embed.name } : loadSession()); // Post a message back to the native shell's "lchat" handler (no-op in a browser). function nativePost(msg) { try { if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat) { window.webkit.messageHandlers.lchat.postMessage(msg); } } catch {} } // Surface a problem to the user. In the webview an alert() would freeze the page // (a modal dialog blocks the native message channel), so route errors to the shell. function notifyError(msg) { if (embed) nativePost({ error: msg }); else alert(msg); } // Native dropdown menus (macOS app only). The Mac shell registers an "lchatMenu" // handler; when present, a kebab renders as a real NSMenu instead of the in-page // HTML menu. We mirror the live DOM menu — each visible .menu-item becomes a // native item — and picking one just .click()s the real button, so there's a // single source of truth for what every option does and says. const nativeMenu = (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchatMenu) || null; let nativeMenuSeq = 0; const nativeMenuPending = new Set(); // Pop up `menuEl`'s items as a native menu, anchored under `anchorEl`. Skips // items the web layer has hidden — inline display:none (group-only entries, // the notifications toggle where push is unusable) or the `hidden` attribute // (Open in the app, Upgrade) — so the native menu matches exactly what the // HTML one would show. Missing the `hidden` half of this used to leak // Upgrade into the native NSMenu, which the caller deliberately keeps out of // native for App Store rules (see the Upgrade wiring below). function openNativeMenu(menuEl, anchorEl) { const items = [...menuEl.querySelectorAll(".menu-item")] .filter((el) => el.style.display !== "none" && !el.hidden) .map((el) => ({ id: el.id, label: el.textContent.trim(), danger: el.classList.contains("danger") })); if (!items.length) return; const token = ++nativeMenuSeq; nativeMenuPending.add(token); const r = anchorEl.getBoundingClientRect(); nativeMenu.postMessage({ token, items, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } }); } // Called by the Mac shell after the user picks an item (id) or dismisses the // menu (id null). Clicking the real button runs the identical handler the HTML // item would have. Idempotent: an unknown/spent token is ignored. window.__lchatMenuPick = (token, id) => { if (!nativeMenuPending.delete(token) || !id) return; const item = document.getElementById(id); if (item) item.click(); }; // App state. // A thread's chosen VIEW. "chat" (a DM-style conversation) is always the default // view; an app-thread also offers the "interface" view (its interactive UI). More // views (custom, per-thread) are planned — a view dropdown, not just this toggle. // Sticks per thread for the session. Only meaningful when threadApp[id] is set. const appThreadView = {}; // thread id -> "chat" | "interface" // For a GROUP chat with apps added (see groupAppsFor): which added app its // Interface pane shows. Sticks per thread for the session, like appThreadView. const groupAppView = {}; // thread id -> app name const threadPreview = {}; // thread id -> last-message snippet // The decrypted payload each snippet came from, so previews can re-derive when // names arrive/change (boot computes them before labels + nicknames decrypt). const previewPayload = {}; // Pending-invite links, keyed by the (pending DM) thread id. Only known in the // session that minted them — the secret never reaches the server, so it can't be // recovered after a reload (the pending chat offers "re-send" to mint a fresh one). const inviteLinks = {}; // thread id -> read watermark (the newest message time we've read, in server // seconds). Synced from the server so "read on one device" clears the dot on // the others; advanced locally first so the UI never waits on the POST. const readMarks = {}; let chatFilter = ""; // lowercased substring filtering the thread list (search box) // The funnel menu beside search: online-only plus category checkboxes. All // categories on + online off = the unfiltered default (funnel unhighlighted). const chatFilters = { online: false, people: true, bots: true, apps: true }; let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect) let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting) let pingTimer = null; // keepalive ping interval while the socket is open let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip // Custom-app import race: this const must initialize BEFORE route() below runs // boot() synchronously, or loadCustomApps hits its own const in the temporal // dead zone and every custom app silently skips on a same-tick boot. const CUSTOM_APPS_TIMEOUT_MS = 4000; // The boot loading scene (smoke, dust, and an unseen meter climbing to // eleven) lives in bootscene.js — boot() starts it, hideLoading() finishes it. // Paths that never start it (embed, signed-out, A2HS) get 0 back and just fade. // If we've just returned from the home server's /restore page, adopt the // recovered session before routing (checkRestoreReturn writes it to storage). // Otherwise route straight away — the extra tick only happens on a real return. if (!embed && !session && location.hash.includes("lchat_restore=")) { checkRestoreReturn().then((restored) => { if (restored) setSession(loadSession()); route(); }); } else { route(); } function consumeAutoRestoreMarker() { const clean = new URL(location.href); clean.searchParams.delete("restore"); history.replaceState(null, "", clean.pathname + clean.search + clean.hash); } function route() { if (embed) return bootEmbed(); if (!session) return showNoInvite(); // An already-signed-in space needs no handoff, but still consumes home's // one-shot marker so signing out later cannot unexpectedly trigger one. if (autoRestoreRequested(location.search)) consumeAutoRestoreMarker(); if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") { return showA2HS(boot); } boot(); } // Custom apps: modules the server sideloads (LCHAT_APPS_DIR) register through // the same defineApp() the compiled-in apps use — the /api/apps manifest lists // them and each is dynamic-imported here, BEFORE the first render, so // registration (typeOwner, personalApps) is complete when folding starts. A // broken or slow custom app must never block or white-screen boot: every // import failure is contained to a console.error, and the whole phase races a // short timeout so boot proceeds regardless. (Its timeout const is declared up // by route()'s invocation — see the TDZ note there.) async function loadCustomApps() { try { const load = (async () => { const r = await fetch("/api/apps"); if (!r.ok) return; const { apps } = await r.json(); await Promise.all( (apps || []).map((a) => import(a.url).catch((e) => console.error(`custom app "${a.name}" failed to load:`, e)), ), ); })(); await Promise.race([load, new Promise((res) => setTimeout(res, CUSTOM_APPS_TIMEOUT_MS))]); } catch (e) { console.error("custom apps skipped:", e); } } // Member-built apps (sandbox.js): fetch + decrypt + register the sandboxed // proxies. Time-boxed like loadCustomApps — registration is a boot dependency // (thread rendering + live folds need the proxies), but a slow fetch must // never hold the whole app hostage. No-op until the instance key is here. // Give the sandbox host what it needs from the shell: seed an app-thread's // snapshot before an off-thread command runs (so the frame's getState is // accurate), and repaint surfaces after companion chat projections fold in. setMemberSeeder(seedAppState); setSurfaceRefresh((tid) => { if (tid === activeId) renderActiveSurfaces(); }); // App Studio's privileged context: the token + instance key it needs to // encrypt/deploy member apps, plus the openers its place-aware "Open" button // routes through (appOpenPlan in ui.js). An accessor, so it always reads the // live instance key (which arrives after boot). setMemberAdmin(() => ({ token: session.token, instanceKey, uid: myUserId, name: session.name, // In a native embed this page IS one thread's pane — navigating it here // would render the app inside App Studio's own window. Flagged so the // studio offers only what works, and openApp hands the gesture to the // native shell instead ({openAppThread}), which opens — or creates — the // app's thread as its own native surface. embedded: !!embed, openApp: async (name) => { if (embed) { nativePost({ openAppThread: { app: name, title: (appByName(name) || {}).title || name } }); return; } const id = await resolveAppThread(name); if (id) openThread(id); }, // The group chats this member could add an app to (the studio's "which // group?" picker). Groups whose key hasn't arrived can't take the install // event yet, so they're left out rather than offered and failed. groups: () => threads .filter((t) => t.kind === "group" && threadKeys[t.id]) .map((t) => ({ id: t.id, title: threadName[t.id] || "Group" })), // Add a shared copy to a group chat and land there — the same groupapps.add // every Apps sheet emits, so the add is an event in the group's own log. openAppInGroup: async (name, tid) => { const app = appByName(name); if (!app || !threadKeys[tid]) return false; if (!(await installGroupApp(tid, app))) return false; openThread(tid); return true; }, // Open the space-wide copy (its sidebar home), installing it first when it // isn't there yet. installSpaceApp enforces the install policy — false // means "admins only" here, and the studio says so instead of half-opening. openAppForSpace: async (name) => { const app = appByName(name); if (!app || !app.render) return false; if (!spaceAppsFor().some((a) => a.name === name) && !(await installSpaceApp(app))) return false; openSpaceApp(name); return true; }, })); async function loadMemberAppsNow() { try { const load = loadMemberApps({ token: session.token, instanceKey, authorName: (uid) => { const l = rosterLabel(uid); return l === "Someone" ? "" : l; }, }); await Promise.race([load, new Promise((res) => setTimeout(res, CUSTOM_APPS_TIMEOUT_MS))]); } catch (e) { console.error("member apps skipped:", e); } } // ---- the space thread: space-wide apps (docs/space-apps.md) ---------------- // One hidden, all-members thread backs every "installed for everyone" app: the // installs registry and each installed app's shared instance fold from ITS // encrypted log. The thread never appears in /api/threads (no membership rows // — membership is implicit server-side) and has no chat surface at all: each // space app surfaces as its own SIDEBAR ROW (the space band, renderThreadList) // opening the app full-pane — place = location, so the space's copies never // ride other chats' widget bars. Its key rides open_key — encrypted under the // instance key, like an open group's — so this no-ops until the instance key // arrives (onInstanceKeyReady retries). let spaceThreadId = null; let spaceAppOpen = null; // the space app rendered full-pane (while activeId === spaceThreadId) const spaceAppPreview = {}; // appName → last activity line, for its sidebar row const spaceAppUnread = {}; // appName → unseen activity (local, like the rows it badges) // loadSpaceThread fetches + unwraps the space thread and folds its state // (registry + every space app), so command routing and the sidebar's space // band see the installs. Safe to call repeatedly — re-seeding + catch-up // converge. async function loadSpaceThread() { // Embeds included, when the native shell seeded the instance key (the // phase-4 lift, docs/native-apps.md): a command host that knows the space // thread routes space-installed apps' commands correctly — /gif works // natively where installed. Without the key (not granted, or an old // native shell), the bail stands and space commands answer "isn't // available here yet", exactly as before. if (!instanceKey) return; try { const r = await (await fetch(`/api/space-thread?token=${encodeURIComponent(session.token)}`)).json(); if (!r.id || !r.open_key) return; // no space installs yet if (!threadKeys[r.id]) { const b64 = (await decrypt(instanceKey, r.open_key)).k; threadKeys[r.id] = await importKey(b64); threadKeyB64[r.id] = b64; } spaceThreadId = r.id; await seedAppState(r.id, { full: true }); // snapshot + catch-up → installs + app states renderThreadList(); // the space band reflects the installs renderSpaceAppPane(); // a reconnect while the space home is open repaints it } catch (e) { console.error("space thread skipped:", e); } } // resolveSpaceThread finds-or-creates the space thread — the first space-wide // install creates it: mint a fresh AES key, wrap it under the instance key // (the server can't open it), and POST. A racing creator wins harmlessly: the // server returns the stored row and we unwrap THEIR key. In-flight guard like // resolveAppThread, so a double-tap can't post twice. let spaceThreadCreating = null; function resolveSpaceThread() { if (spaceThreadId) return Promise.resolve(spaceThreadId); if (!instanceKey) return Promise.resolve(null); // keys still syncing — callers answer honestly if (!spaceThreadCreating) { spaceThreadCreating = (async () => { try { const { b64 } = await generateKey(); const open_key = await encrypt(instanceKey, { k: b64 }); const r = await ( await fetch("/api/space-thread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, open_key }), }) ).json(); if (!r.id || !r.open_key) return null; // The STORED open_key is authoritative (a racing creator's key, when we // lost) — never assume our candidate key was the one that stuck. const stored = (await decrypt(instanceKey, r.open_key)).k; threadKeys[r.id] = await importKey(stored); threadKeyB64[r.id] = stored; spaceThreadId = r.id; await seedAppState(r.id, { full: true }); return r.id; } catch (e) { console.error("space thread create failed:", e); return null; } finally { spaceThreadCreating = null; } })(); } return spaceThreadCreating; } setSpaceThread(() => spaceThreadId); setSpaceThreadResolver(resolveSpaceThread); // The space-install policy (docs/space-apps.md § controls): a plaintext admin // setting — served as page meta, default "everyone" — plus roster roles. Read // fresh on every call: roles and the roster move under a live session. Note // the raw meta read (not gateMeta): this flag defaults OFF. setSpaceInstallPolicy(() => { const adminsOnly = ((document.querySelector('meta[name="space-installs-admins"]') || {}).content || "0") === "1"; const roleOf = (uid) => ((roster || []).find((e) => e.user_id === uid) || {}).role || ""; return { adminsOnly, canInstall: !adminsOnly || roleOf(myUserId) === "admin", adderOK: (uid) => roleOf(uid) === "admin", }; }); // ---- local-store boot cache (#457) ---------------------------------------- // The raw server responses the last boot saw, cloned at fetch time (before any // apply-side decryption touches them) and persisted as one snapshot. On the // next load, hydrateFromCache() replays them through the same apply pipeline — // instant paint, zero network — then the real fetches reconcile. const bootRaw = { threads: null, roster: null, nick: null, tiers: null, instance: null }; // Cache the raw tier rows into the boot store (billingui calls this via the // verbs seam — the store cache is shell's, not billing's). function cacheTiersRaw(rows) { bootRaw.tiers = structuredClone(rows); scheduleStoreSnapshot(); } let cachePainted = false; // a cached paint happened; network failures must not blank it let snapTimer = null; function scheduleStoreSnapshot() { if (embed || !bootRaw.threads || !bootRaw.roster) return; clearTimeout(snapTimer); snapTimer = setTimeout(() => { storePutSnapshot({ v: SNAP_VERSION, ts: Date.now(), // Bind the snapshot to this session's identity: a claim flow can replace // lchat_session without a sign-out (add-device, paste-to-join), and the // next boot must not hydrate the previous person's cache. owner: session.userPub || "", threads: bootRaw.threads, roster: bootRaw.roster, nick: bootRaw.nick, tiers: bootRaw.tiers, instance: bootRaw.instance, }).catch(() => {}); }, 1500); } // Paint the whole app from the cached snapshot: same apply pipeline, no // network. Returns true when the shell painted (the boot scene then stays // down and the reveal has already happened). Any failure means "no cache" — // boot proceeds exactly as it always has. async function hydrateFromCache() { if (embed) return false; let snap = null; try { snap = await storeGetSnapshot(); } catch {} if (!snapshotUsable(snap)) return false; // A different member's leftovers (session replaced by a claim flow, no // sign-out): their ciphertext is useless to this key anyway — wipe, don't // hydrate a cache that can only render undecryptable rows. if (snap.owner !== (session.userPub || "")) { wipeStore().catch(() => {}); return false; } try { await applyRoster(snap.roster); if (snap.instance && snap.instance.wrapped) { try { const b64 = await unwrapThreadKey(snap.instance.wrapped, session.userPriv); setInstanceKey(await importKey(b64), b64); } catch {} } if (snap.nick) { const n = await applyNicknamesBody(snap.nick).catch(() => null); if (n) setNicknames(n); } if (Array.isArray(snap.tiers)) await applyTiers(snap.tiers, false); await applyThreads(snap.threads); await decryptRosterLabels(); await recomputeThreadNames(); const opened = renderApp(); // the initial thread's feed folds from the store connectWS(); // live frames flow into the cached paint immediately // A failed initial open (e.g. offline + a never-cached thread) still leaves // a usable shell — reveal rather than fall back to the boot scene. await Promise.resolve(opened).catch(() => {}); cachePainted = true; hideLoading(); return true; } catch (e) { console.error("hydrate from cache", e); return cachePainted; } } async function boot() { try { // An instant paint from the local snapshot, when one exists. The boot // scene is first-run theatre: it only shows when there's nothing to paint. const painted = await hydrateFromCache(); if (!painted) startBootScene(); if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); await loadCustomApps(); await ensurePubkey(); // Role + first-run state, fetched in parallel with the heavy loads: // maybeStartSetup awaits it at the end, and on first run the loading cover // holds until Eleven's conversation is open (no flash of an empty home). // Time-boxed: a stalled /api/me must never hold the reveal hostage for // everyone — on timeout the app opens normally and setup retries next load. const mePromise = Promise.race([ fetch(`/api/me?token=${encodeURIComponent(session.token)}`) .then((r) => r.json()) .catch(() => null), new Promise((resolve) => setTimeout(() => resolve(null), 4000)), ]); await loadRoster(); // Nicknames need only our own keypair (not the instance key), so they load // alongside — before thread titles are derived below. Time-boxed: a stalled // /api/nicknames must not hold the reveal hostage — on timeout the app // opens with published names, and repaints when the fetch finally lands // (the thread-list check skips the repaint when it landed pre-paint). const nickLoad = loadNicknames().then(() => { if (document.getElementById("thread-list")) refreshNamesUI(); }); // Muted people ride the same sealed-blob shape; a late arrival just means // a few bubbles unfold on the repaint that follows. loadModPrefs().then(() => { syncThreadKeysToSW(); renderActiveSurfaces(); }); loadBlocks(); // the profile card's Block/Unblock state await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]); await bootstrapBranding(); // sealed instances: open the branding blob (needs the instance key for first-time B) await loadTiers(); // before the re-fold: gated groups unwrap via tier keys // The loads race: when the thread list landed first, any open group (key // under the instance key or a tier key) was skipped — fold it in now. if (threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads(); // Member-built apps are sealed under the instance key, so they can only // register now (a late-arriving key retries from onInstanceKeyReady). // Before renderApp + connectWS on purpose: their threads and live events // need the proxies in the registry. await loadMemberAppsNow(); // The space thread (space-wide apps) unwraps via the instance key too — // after the member apps, so their proxies exist before their space state // folds. Before connectWS, so live space events find the key in place. await loadSpaceThread(); // Names are encrypted under the instance key, which is only ready now — decrypt // the roster and publish our own (new joiner, or migrating an old plaintext name). await decryptRosterLabels(); await recomputeThreadNames(); // titles were cached (by loadThreads) before the key was ready syncThreadKeysToSW(); // and so were the SW's notification titles await publishMyLabel(); if (!painted) { const opened = renderApp(); // paints the shell; opens the initial thread (async) connectWS(); // Reveal only once the initial feed is loaded + scrolled — so the first // paint is the finished layout, not an empty feed that then jumps to the // bottom. await opened; } else { // The shell painted from the snapshot before these loads ran — refresh // its surfaces with server truth (folds dedupe; renders are idempotent). // No catch-up here: the cached open already reconciled the active feed // (openAppThreadView fires one on its cache path, and its network path // IS a fresh fetch) — a second identical fetch would be pure waste. renderThreadList(); refreshNamesUI(); // Member/custom apps register only in the loads above — a cached open of // one of their threads mounted BEFORE registration and drew nothing // (renderMiniApp on an unknown app is a no-op over a cleared pane). // An empty pane with an active thread is exactly that case: mount it // again now the registry is complete. const pane = document.getElementById("app-pane"); if (activeId && pane && !pane.childNodes.length && renderAppFor(activeId)) { delete appPaneCache[activeId]; await openAppThreadView(activeId, ++openSeq, renderAppFor(activeId), threadApp[activeId] || null); } } registerSW(session); // best-effort; subscribes if notifications already on startHeartbeat(); // The backup banner is initialised inside maybeStartSetup (below): while the // setup conversation runs, backup messaging is Eleven's — one voice. // Tapping a notification while a tab is already open posts us the thread to // open (the SW can't navigate a live SPA) — jump straight to it. if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener("message", (e) => { if (e.data && e.data.type === "open-thread" && e.data.thread) openThreadById(e.data.thread); }); } // First-run: open the admin's setup conversation before the reveal, so the // first thing an admin ever sees IS the chat with Eleven (no-op otherwise). await maybeStartSetup(mePromise); // A freshly-joined member gets Eleven's welcome instead (mutually exclusive // with admin setup — a setup claim never sets the welcome marker). await maybeStartWelcome(); // A tapped "Copy link" permalink (#l=, issue #893): resolve + jump last, so // it always wins over whatever thread the normal boot flow landed in. await openInitialMessageLink(); } catch (e) { console.error(e); // A failed network boot over a successful cached paint is the OFFLINE case // working as designed — the app stays readable; never blank it. if (!cachePainted) { app.innerHTML = `

Something went wrong

` + `

Couldn't load your chats. Try reloading.

`; } } finally { hideLoading(); } } // Fade out and remove the full-screen loading cover (shown until the app's first // meaningful paint). Idempotent — safe to call from any entry path. The cover // assembles in stages (bootscene.js), and the reveal waits out whatever it had // time to show: a posed brand gets its beat, a quote its read, the full scene // its punch to eleven. function hideLoading() { const el = document.getElementById("app-loading"); if (!el) return; const clicked = finishBootScene(); // the dwell the staged cover is owed — let it land setTimeout(() => { // cover-out, NOT the global .hidden — that's display:none !important and // would snap the cover off with no cross-fade. el.classList.add("cover-out"); setTimeout(() => el.remove(), 400); // after the CSS fade (0.35s with .scene-out) }, clicked); } // Embed bootstrap: render a single thread's feed for the native shell. Skips the // normal roster/instance/thread *unwrap* path (the webview has no user private key) // — native already unwrapped this thread's key and handed it over. Everything past // that (feed render, send, reactions, edits, typing, attachments) is reused as-is. async function bootEmbed() { document.body.classList.add("native-embed"); // Command-host pages collapse to just the panel (style.css .command-host // rules) — classed before the first render so the feed never flashes. if (embed.mode === "command") document.body.classList.add("command-host"); try { setMyUserId(embed.myUserId || ""); setCurrentUser(myUserId, session.name || "New member"); // The instance key first (a local unwrap, no network): applyRoster can // then decrypt labels in its first pass, and member apps below never hit // their no-key no-op. if (embed.instanceKey) { try { setInstanceKey(await importKey(embed.instanceKey), embed.instanceKey); } catch {} } // The boot round-trips are independent of each other, so they run // together rather than as a waterfall — this pane is behind the native // loading veil, and every serialized fetch here holds the veil longer. // The native shell can land straight in a custom app's thread, so // registration must still be complete before the render below. const customAppsLoad = loadCustomApps(); const rosterLoad = loadRoster(); // token-only; gives sender labels + profile photos // Thread objects for kind/members/name_blob, but only the active thread // has a key — the others can't be unwrapped here without the private key. const threadsLoad = fetch(`/api/threads?token=${encodeURIComponent(session.token)}`) .then((r) => r.json()) .catch(() => ({})); // Per-viewer names (#577): the embed never holds userPriv, so it can't // open the nicknames blob — the native shell (which can) hands the // RESOLVED uid → name map over the bridge instead. Fold it in as // display-only overrides (seen = name: names render; the rename prompt // stays native chrome's job — updateRenameBanner bails in embeds). const applyEmbedNames = (map) => { for (const [uid, name] of Object.entries(map || {})) { if (name) nicknames[uid] = { name, seen: name }; } }; applyEmbedNames(embed.names); // The seed is baked at webview creation and parked webviews outlive a // rename — the native shell pushes fresh maps here after each fold. window.__lchatSetNames = (map) => applyEmbedNames(map); const tid = embed.threadId; threadKeyB64[tid] = embed.threadKey; threadKeys[tid] = await importKey(embed.threadKey); // Member apps read roster labels for their bylines, so they start once // the roster lands — still overlapping the custom-app phase, so the two // load timeouts run in the same window instead of back to back. The // native shell can land straight in a member app's thread too — the // sandboxed proxies must be registered before the render below. await rosterLoad; await Promise.all([customAppsLoad, loadMemberAppsNow()]); const r = await threadsLoad; setThreads(r.threads || []); if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] }); threadName[tid] = await displayName(threads.find((t) => t.id === tid)); // 🤖 The native shell can ask for a specific view — its companion split // renders chat NATIVELY and embeds this page purely as the app's // interface, so without this the pane would default to the chat view and // show a second, redundant web chat beside the native one. Chat-only apps // (the welcome conversation) have no Interface — the chat view stands. if (embed.view && threadApp[tid] && !appChatOnly(threadApp[tid])) appThreadView[tid] = embed.view; // 🤖 A GROUP's shared app pane: the native shell names which added app to // show (its own header switch renders chat natively, so this page is // purely that app's interface). Assigned directly — the groupapps fold // hasn't run yet, so setAppThreadView's guard would reject it here; the // open below folds state and refreshGroupAppsUI applies the view. if (embed.app && !embed.space) { groupAppView[tid] = embed.app; appThreadView[tid] = "interface"; } renderApp(); if (embed.space && embed.app) { // 🤖 A SPACE APP's surface (docs/native-apps.md phase 4): this page is // that app rendered full-pane over the space thread — the web's own // openSpaceApp path, never a chat (the space thread has no chat // surface; without the flag the dm-fallback row above would mount the // conversation feed). The seed's thread IS the space thread, so its // key is already in place; fold its state, then open the app. spaceThreadId = tid; await seedAppState(tid, { full: true }); openSpaceApp(embed.app); } else { await openThread(tid); } connectWS(); installNativeBridge(); // Space-installed apps' commands route in this host too (phase 4): learn // the space thread when the native shell seeded the instance key. // Deliberately not awaited — {ready} must not wait on it; a command // racing the load still answers honestly ("isn't available here yet"). if (!embed.space) loadSpaceThread(); // Cross-thread command routing (docs/native-apps.md phase 3): resolving // an app's personal thread needs decrypted name_blobs this page can't // open — route the resolution through the native shell instead of the // shell.js resolver (which would create a duplicate thread here). setAppThreadResolver(embedResolveAppThread); // Command-host mode (the Mac shell's panel webview): this page exists to // RUN slash commands and show their panels — the native feed renders the // chat itself. See installCommandHost. if (embed.mode === "command") installCommandHost(tid); startHeartbeat(); // The pane has real content now — the native shell drops its loading // veil on this (didFinish fires long before the app has painted). nativePost({ ready: true }); } catch (e) { console.error(e); nativePost({ error: "Couldn't open this chat." }); } finally { hideLoading(); } } // ---- app-host key requests (docs/native-apps.md phase 3) ------------------- // A command typed in one thread can legitimately land in another — a personal // /todo typed in a group routes to the todo app-thread (instanceThread). The // embed page holds a single thread's key, so that emit would silently fail; // instead it asks the NATIVE shell, which can unwrap exactly the threads this // member is in and hands back one narrow thread key — never the private key. // Two requests: {needKey: threadId} when the id is known, and // {needAppThread: {app, title}} when the app→thread resolution itself needs // native (the embed can't decrypt other threads' name_blobs, so resolving // here would mint a DUPLICATE app thread). Native answers by calling // __lchatProvideKey / __lchatProvideAppThread; a missing answer times out to // the same graceful failure as today. const nativeKeyWaits = new Map(); // threadId -> [resolve(bool)] const nativeAppThreadWaits = new Map(); // appName -> [resolve(threadId|null)] window.__lchatProvideKey = async (threadId, keyB64) => { try { if (threadId && keyB64 && !threadKeys[threadId]) { threadKeys[threadId] = await importKey(keyB64); threadKeyB64[threadId] = keyB64; } } catch {} const waits = nativeKeyWaits.get(threadId) || []; nativeKeyWaits.delete(threadId); waits.forEach((r) => r(!!threadKeys[threadId])); }; window.__lchatProvideAppThread = async (appName, threadId, keyB64) => { await window.__lchatProvideKey(threadId, keyB64); if (threadId && threadKeys[threadId]) threadApp[threadId] = appName; const waits = nativeAppThreadWaits.get(appName) || []; nativeAppThreadWaits.delete(appName); waits.forEach((r) => r(threadId && threadKeys[threadId] ? threadId : null)); }; function requestNativeKey(threadId) { if (!embed || !threadId) return Promise.resolve(false); if (threadKeys[threadId]) return Promise.resolve(true); return new Promise((resolve) => { const waits = nativeKeyWaits.get(threadId) || []; if (!waits.length) nativePost({ needKey: threadId }); // first asker posts; the rest share the answer let done = false; const once = (ok) => { if (!done) { done = true; resolve(ok); } }; waits.push(once); nativeKeyWaits.set(threadId, waits); setTimeout(() => once(false), 4000); }); } function requestNativeAppThread(appName, title) { if (!embed || !appName) return Promise.resolve(null); return new Promise((resolve) => { const waits = nativeAppThreadWaits.get(appName) || []; if (!waits.length) nativePost({ needAppThread: { app: appName, title: title || appName } }); let done = false; const once = (id) => { if (!done) { done = true; resolve(id); } }; waits.push(once); nativeAppThreadWaits.set(appName, waits); // Creation is server round-trips on the native side — wait longer than needKey. setTimeout(() => once(null), 8000); }); } // The embed's app→thread resolver: the active thread if it IS the app, else // ask native (which opens-or-creates and answers with id + key). Never create // from here — a duplicate app thread is worse than an honest miss. async function embedResolveAppThread(appName) { for (const id in threadApp) { if (threadApp[id] === appName && threadKeys[id]) return id; } const title = (appByName(appName) || {}).title || appName; return requestNativeAppThread(appName, title); } // The bridge the native composer drives: forward typed text and staged photos into // the one crypto/WebSocket path (shell.js). Mirrors the hooks ChatView's WebBridge // calls; staged-attachment state + errors are posted back on the "lchat" handler. function installNativeBridge() { window.lchatNative = { // Returns whether the text was accepted (socket open + something to send), so // the native field knows whether to clear itself. When the chat app drives // this thread, the app's composer handlers take over (same contract). sendText(text) { if (!ws || ws.readyState !== WebSocket.OPEN) return false; if (chatAppNative) return chatAppNative.sendText(text); return false; }, // The native composer's keystrokes: beat "typing" to the thread while there's // text, stop when it empties. The chat app owns the beats (chat.typing, // relay-only ephemeral); before it has mounted there's no one to show a // beat to, so pre-mount keystrokes are simply dropped. typing(hasText) { if (chatAppNative) chatAppNative.typing(hasText); }, // Stage a photo picked by the native + button (base64, standard alphabet). The // web composer only handles images; anything else is rejected with an error. stageAttachment(att) { try { if (!att || !att.type || !att.type.startsWith("image/")) { nativePost({ error: "Only photos can be attached." }); return; } const bin = atob(att.b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const file = new File([bytes], att.name || "photo.jpg", { type: att.type }); if (chatAppNative) chatAppNative.stage(file); // Pre-mount: hold the file until the chat app registers — the legacy // staging this used to fall back to had no send path left (a black // hole). registerNative flushes the queue. else preMountStaged.push(file); } catch { nativePost({ error: "Couldn't attach that photo." }); } }, // The native reply banner's ✕ — clears the staged reply on both sides // (cancelReply posts {replying: null} back). cancelReply() { if (chatAppNative) return chatAppNative.cancelReply(); }, // The native edit banner's ✕ — drops edit mode (cancelEdit posts // {editing: null} back so the native banner + prefilled composer clear). cancelEdit() { if (chatAppNative) chatAppNative.cancelEdit(); }, // Switch a companion app-thread between its Chat and Interface views — // the native header's toggle drives this (the web's own header toggle // isn't rendered in embed, so without it the second view is unreachable // on iOS). No-op for non-app threads (setAppThreadView guards). setAppView(view) { const tid = embed && embed.threadId; if (!tid) return; setAppThreadView(tid, view === "interface" ? "interface" : "chat"); }, // Swap which of a GROUP's added apps the Interface pane shows — the native // header's app dropdown drives this without a page reload (the pane // re-renders from already-folded state). Unknown names stay inert: // renderGroupAppPane resolves via groupAppsFor, which filters to the // catalog of group-capable apps. setGroupApp(app) { const tid = embed && embed.threadId; if (!tid || typeof app !== "string" || !app) return; groupAppView[tid] = app; appThreadView[tid] = "interface"; refreshGroupAppsUI(tid); }, // The app-host protocol (docs/native-apps.md): the native shell pulls the // registered-app catalogue — every app this page loaded across all three // tiers (built-in, admin custom, member), with places / presentation / // commands — to drive its own registry, pickers, and surface routing. // Pull-based on purpose: an evaluateJavaScript call returns the plain // JSON directly, and native re-pulls on {catalogChanged} posts. appCatalog() { return appCatalogReport(); }, // This thread's command rows — the same commandCatalog(threadId) that // feeds the web "/" typeahead and /help, place-stamped and with the // install-flag alt rows, so the native composer can list and rank exactly // what the web would. Rows change as installs fold, so native re-pulls // per composer session rather than caching long. commandItems() { return embed && embed.threadId !== undefined ? commandCatalog(embed.threadId) : commandCatalog(); }, // The app-host protocol, phase 5 (docs/native-apps.md): project ONE app // event into its chat line for the NATIVE feed — the web's chatMessage / // groupFeedMessage answered by the engine that owns them. The event is // the decrypted payload (native already holds the thread key; no key // material rides here). Ownership resolves via appForType — the same // registry the web folds use, so families like webhooks.* land on their // app. Mirrors foldGroupAppEventBubble/foldAppEventBubble: a group feed // prefers groupFeedMessage, else chatMessage({group:true}); a personal // companion projects through chatMessage. null = silent by the app's own // choice, an unknown type, or a member app (its hook runs only in the // sandbox, never here). `title` rides along so native names the bubble // without re-deriving it. // The app-host protocol, voice surfaces: the sidebar line for one app // event — the app's own preview hook (opts.you = the actor is the // reader), defaulting to the actor-prefixed chat projection (appPreview's // chain). null = leave the row's line alone. previewEvent(event, you) { try { if (!event || typeof event.type !== "string") return null; const text = appPreview(event, { you: !!you }); return text ? { text } : null; } catch { return null; } }, projectEvent(event, group) { try { if (!event || typeof event.type !== "string") return null; const app = appForType(event.type); if (!app) return null; let line = null; if (group) { if (typeof app.groupFeedMessage === "function") line = app.groupFeedMessage(event); else if (typeof app.chatMessage === "function") line = app.chatMessage(event, { group: true }); } else if (typeof app.chatMessage === "function") { line = app.chatMessage(event); } if (!line) return null; const text = typeof line === "string" ? line : typeof line.text === "string" ? line.text : ""; if (!text) return null; return { text, action: typeof line === "object" && !!line.action, title: app.title || app.name, }; } catch { return null; } }, // Composer typeahead for the native input: same token detection + ranking // as the web menu (composerToken + typeaheadItems). caret is a UTF-16 // offset, matching JS string indexing. Returns {start, items} or null. typeahead(text, caret) { const tok = composerToken(text, caret); if (!tok) return null; const items = typeaheadItems(tok).map((it) => ({ label: it.glyph ? `${it.glyph} :${it.code}:` : it.name, insert: it.insert, })); return items.length ? { start: tok.start, items } : null; }, // Called when this thread's (possibly cached) webview lands on screen — // a notification tap, foregrounding the app, or returning from another // chat. A parked page keeps receiving messages, but with rendering // suspended their smooth-scroll pinning never runs, so the feed resumes // shy of the newest message. Re-pin via the settle loop — a one-shot // scrollTop write here gets clamped by WebKit's resume-time layout — // unless the user had deliberately scrolled up into history. // // `force` (a notification tap) overrides that hold: the message you were // pinged about is the newest one, so jump to it even if the parked feed // had been scrolled up. didAttach(force) { if (chatAppNative) chatAppNative.didAttach(force); }, }; } // ---- command host (embed.mode === "command") ------------------------------- // // The native Mac shell renders chat NATIVELY, so it has no JS command engine — // this page is that engine, one per thread: the shell keeps it hidden, forwards // a typed slash command to window.lchatNative.runCommand, and reveals the // webview only while a panel is showing (body.command-host CSS collapses // everything except the .cmd-panel, so the webview IS the panel). Private // replies post back as {notice} for the shell to render inline; panel // visibility and height post as {panel}; a panel's insertText already posts // {composerInsert} (chat.js) for the shell's own composer. function installCommandHost(tid) { // Content height for the shell's frame. scrollHeight is the content EXTENT, // immune to the max-height clamp and to the webview being 0-tall while // hidden — the one measurement that works before the shell reveals it. const measure = () => { const body = document.querySelector(".cmd-panel-body"); const head = document.querySelector(".cmd-panel-head"); return Math.min(600, (body ? body.scrollHeight : 200) + (head ? head.offsetHeight : 26) + 12); }; const postPanel = (spec) => { if (!spec) return nativePost({ panel: null }); // Measure synchronously — scrollHeight forces layout, and this page is // DETACHED from the native view hierarchy until the shell reveals it, so // a requestAnimationFrame here never fires (WebKit suspends rendering // callbacks for unparented webviews) and the panel would deadlock hidden. // Timers still run detached, so the late re-measure (fonts, images, and // the real width once the shell attaches the frame) uses setTimeout. nativePost({ panel: { title: spec.title || (spec.sender && spec.sender.name) || "", height: measure(), // The app's animation choice (animate: false = appear instantly) — // in command-host mode the NATIVE shell owns the reveal, so the flag // rides the post instead of easing in-page (see panelEases, chat.js). animate: spec.animate !== false, }, }); setTimeout(() => nativePost({ panelHeight: measure() }), 350); }; setCommandPanelObserver((threadId, spec) => { if (threadId === tid) postPanel(spec); }); // The shell can't see this page's console, so surface page-side failures as // {error} posts — they land in the native unified log (CommandHost). window.addEventListener("error", (e) => nativePost({ error: "page: " + (e.message || "error") })); window.addEventListener("unhandledrejection", (e) => nativePost({ error: "page: " + ((e.reason && (e.reason.message || e.reason)) || "unhandled rejection") })); const origConsoleError = console.error.bind(console); console.error = (...args) => { origConsoleError(...args); try { nativePost({ error: "console: " + args.map((a) => (a && a.message) || String(a)).join(" ").slice(0, 300) }); } catch {} }; // Returns whether the text was a command this engine answered (panel or // reply) — false lets the shell send it as a normal message (bot commands). window.lchatNative.runCommand = (text) => runCommand(text, tid, (r) => nativePost({ notice: { from: (r && r.sender && r.sender.name) || "", text: (r && r.text) || "" } })); // Escape in the native composer (and the shell's own chrome) dismisses. window.lchatNative.dismissPanel = () => closeCommandPanel(tid); nativePost({ commandHostReady: true }); } // ---- data --------------------------------------------------------------- function ensurePubkey() { return fetch("/api/pubkey", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, public_key: session.userPub }), }); } async function loadRoster() { const r = await (await fetch(`/api/roster?token=${encodeURIComponent(session.token)}`)).json(); // Snapshot the response BEFORE apply: decryptRosterLabels writes decrypted // names onto these entries, and plaintext must never reach the store. if (Array.isArray(r.roster)) bootRaw.roster = structuredClone(r); await applyRoster(r); scheduleStoreSnapshot(); } // The apply half of loadRoster — also run over the cached response on boot. async function applyRoster(r) { setRoster(r.roster || []); // In embed mode the shell tells us our user id up front (it can't be matched by // public key — the webview never holds one). Otherwise find ourselves by pubkey. if (!myUserId) { const me = roster.find((e) => e.public_key === session.userPub); setMyUserId(me ? me.user_id : ""); } // The "New member" fallback (here and on every outbound payload): setup and // welcome claimers are signed in nameless until Eleven's first question — if // they send anything before answering, recipients never see a blank sender. setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote") if (instanceKey) await decryptRosterLabels(); applyInviterNameHint(); verifyInviteBindings(); // KT: check any invitees we hold bind keys for (#573) } // The joiner's first paint (#1436): their invite's sealed payload carried the // inviter's display name. While the instance key (and so the inviter's real // label) hasn't reached this fresh device, fill the hinted DM's other member // with that name instead of "Someone" — and the moment the real label has // decrypted, drop the hint for good. Scoping (only that one member, never a // bot, never over a decrypted label) lives in inviterHintTarget. function applyInviterNameHint() { let hint = null; try { hint = readInviterHint(localStorage.getItem(INVITER_HINT_KEY) || "", Date.now()); } catch {} if (!hint) return; const t = inviterHintTarget(hint, threads, roster, myUserId); if (!t) return; if (t.resolved) { try { localStorage.removeItem(INVITER_HINT_KEY); } catch {} return; } const e = roster.find((x) => x.user_id === t.userId); if (e) e.label = t.name; } // ---- key transparency: verify-before-wrap (advisory, #573) ---------------- // The inviter is the one member who holds T for a joiner, so only they can // check the bind-MAC. We stash each invitee's bind key at invite time, and // once they've joined + published a key, confirm the roster key is the one // THEY published — not a substitute the server slipped in. Advisory by design // (Paul's call): a mismatch records an integrity alert (raising the banner), // never blocks a wrap. A verified binding is dropped (the key can't change — // setUserPublicKey never overwrites it). Absence of a bind-MAC (a pre-KT // member) is "unverified", not an alarm. // Only 1:1 single-use invites (inviteSomeone) stash a bind key: their T is // known to exactly the inviter + joiner, so the MAC is unforgeable. Reusable // admin/cohort invites share T among every link holder, so their bind-MAC // proves nothing — NEVER call rememberInviteBindKey for those. const INVITE_BINDS_KEY = "lchat_invite_binds"; const INVITE_BINDS_MAX = 500; let bindUnverifiedCount = 0; // invited members keyed WITHOUT a MAC (rollout / downgrade); shown in /integrity function loadInviteBinds() { try { return JSON.parse(localStorage.getItem(INVITE_BINDS_KEY) || "{}") || {}; } catch { return {}; } } function rememberInviteBindKey(uid, bindKey) { if (!uid || !bindKey) return; const m = loadInviteBinds(); m[uid] = bindKey; localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(m)); } async function verifyInviteBindings() { const m = loadInviteBinds(); const uids = Object.keys(m); if (!uids.length) return; let unverified = 0; const next = {}; // rebuilt so members who've LEFT the roster are pruned (bounded growth) for (const uid of uids) { const e = roster.find((x) => x.user_id === uid); if (!e) continue; // member gone (removed) — drop the stale stash // Keep the bind key as long as the member is present and re-verify on EVERY // roster load, NOT once: a compromised server can substitute the key at any // later wrap (a new group, a tier re-key), so a one-shot check would miss it. next[uid] = m[uid]; if (!e.public_key) continue; // hasn't published a key yet — check next time if (!e.bind_mac) { // Keyed but no MAC. Benign during rollout (a pre-KT joiner), but for // someone WE invited post-rollout it's a possible downgrade — surface it // in /integrity (advisory), don't banner (rollout would be too noisy). unverified++; continue; } let ok = false; try { ok = await verifyBindMac(m[uid], e.public_key, e.bind_mac); } catch { continue; } if (!ok) { integrity.recordAlert("key-substitution", uid); renderIntegrityBanner(); } } // Hard backstop on growth (object keeps insertion order → drop the oldest). const keep = Object.keys(next); if (keep.length > INVITE_BINDS_MAX) { for (const k of keep.slice(0, keep.length - INVITE_BINDS_MAX)) delete next[k]; } bindUnverifiedCount = unverified; localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(next)); } // People's display names arrive encrypted under the instance key (label_blob). // Decrypt them into .label so the synchronous rosterLabel() can read them. Bots // carry a plaintext .label and no blob; a not-yet-migrated person keeps whatever // plaintext .label they still have as a fallback. async function decryptRosterLabels() { if (!instanceKey) return; const before = new Set(roster.map((e) => e.label).filter(Boolean)); await Promise.all( roster.map(async (e) => { if (e.label_blob) { try { e.label = await decrypt(instanceKey, e.label_blob); } catch {} } if (e.avatar_alt_blob) { try { e.avatarAlt = await decrypt(instanceKey, e.avatar_alt_blob); } catch {} } if (e.description_blob) { try { e.about = await decrypt(instanceKey, e.description_blob); } catch {} } }), ); backfillLegacyLabels(); // A chat message is scanned for @mentions against host.mentionNames() only // once, when it first renders (chat.js's keyedList never revisits an // unchanged row) — so a message mentioning someone who wasn't yet // mentionable at render time stays plain, unbolded text forever unless // something repaints it. A roster label newly resolving here — a fresh // joiner, or a name that just finished decrypting — is exactly that // moment; hand the newly-known names to chat.js so it can repaint just the // messages that actually reference one (issue #249). const newNames = []; for (const e of roster) if (e.label && !before.has(e.label)) newNames.push(e.label); if (newNames.length) repaintMentionsOf(newNames); } // Seal legacy plaintext names (docs/no-plaintext.md): a person whose name // predates label_blob still has it readable at rest until THEY next load the // app — which an inactive member never does. We hold the instance key, so seal // it for them; the server's gate is strictly fill-once (people only, plaintext // still present, no existing blob), so this client only re-encodes the name // the server already stored. (The server can't verify blob content — the // honest-member assumption here is stated at postLabelBackfill in main.go.) // Fire-and-forget. const labelBackfillSent = new Set(); function backfillLegacyLabels() { if (!instanceKey) return; for (const e of roster) { if (e.is_agent || e.label_blob || !e.label || labelBackfillSent.has(e.user_id)) continue; labelBackfillSent.add(e.user_id); encrypt(instanceKey, e.label) .then((blob) => fetch("/api/label/backfill", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, user_id: e.user_id, label_blob: blob }), }), ) .catch(() => {}); } } // The name outgoing payloads stamp. session.name is THIS DEVICE's copy, and it // is routinely empty: the chat-first join claims nameless and lets Eleven ask // in-chat (loadSession says so), and a member who answered on another device // leaves this one blank forever. Everything stamped in that window reads as // "Someone" wherever the payload's own name is the only source (sidebar // previews, push copy). So fall back to my PUBLISHED roster label — never a // nickname: this rides where other members read it. Local name first, so a // rename takes effect before its label finishes publishing. function stampName() { if (session.name) return session.name; const me = roster.find((e) => e.user_id === myUserId); return (me && me.label) || ""; } // Publish our own display name, encrypted under the instance key — for a fresh // joiner, and to migrate anyone whose name is still stored in plaintext. The // server only ever stores the blob. async function publishMyLabel() { if (!instanceKey || !myUserId || !session.name) return; const me = roster.find((e) => e.user_id === myUserId); if (me && me.label_blob && me.label === session.name) return; // already correct try { const blob = await encrypt(instanceKey, session.name); await fetch("/api/label", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, label_blob: blob }), }); if (me) { me.label = session.name; me.label_blob = blob; } } catch {} } // Publish our "about me" text, encrypted under the instance key ("" clears it). // The server stores only the blob and serves it to our connections, who decrypt // it like the display name. async function publishMyDescription(text) { if (!instanceKey || !myUserId) return; try { const blob = text ? await encrypt(instanceKey, text) : ""; await fetch("/api/description", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, description_blob: blob }), }); const me = roster.find((e) => e.user_id === myUserId); if (me) { me.about = text; me.description_blob = blob; } } catch {} } // ---- per-viewer names (#577) --------------------------------------------- // What *I* call people, independent of the name they publish: uid → { name, // seen } (see ui.js displayNameFor/renameNotice for the two rules). The whole // map is one blob sealed to MY OWN public key (sealForPub) — deliberately // stricter than the instance-key blobs: my names for people are mine, not the // group's, so other members can't open it. Devices share the keypair, so it // syncs across my devices for free. The native embed never holds userPriv, so // there this stays empty and published names render (native parity follows). // Ops this device has made that the server copy may not have yet: uid → the // entry to write, or null for "cleared". Saves re-read the server blob and // replay only these on top — so a stale tab never clobbers names set on // another device, and a boot whose load failed can't turn the next save into // a wipe of everything stored. Ops stay queued (and retry) until a save lands. const nicknameOps = new Map(); let nickRetryTimer = 0; // The server blob's version at our last read — sent back with each save so a // stale write 409s (compare-and-swap) instead of racing another device. let nicknamesVersion = 0; // The server's current map, or null when it can't be known right now (fetch // failed, blob won't open, no key). null means "don't write" — never guess. async function fetchServerNicknames() { if (!session.userPriv) return null; try { const r = await fetch(`/api/nicknames?token=${encodeURIComponent(session.token)}`); if (!r.ok) return null; const body = await r.json(); bootRaw.nick = { version: body.version || 0, nicknames_blob: body.nicknames_blob || "" }; scheduleStoreSnapshot(); return await applyNicknamesBody(body); } catch { return null; } } // Decode a /api/nicknames body (sealed to our own key) — shared with the // cached-snapshot hydrate. async function applyNicknamesBody(body) { nicknamesVersion = body.version || 0; if (!body.nicknames_blob) return {}; const obj = await openSealed(body.nicknames_blob, session.userPriv); return (obj && obj.names) || {}; } async function loadNicknames() { const server = await fetchServerNicknames(); if (server) setNicknames(server); } // Read-modify-write: re-read the server copy and replay only this device's // pending ops on top, then seal + store. Returns false — leaving the ops // queued and a retry scheduled — when the read or the write fails. Saves are // serialized on a chain: two quick renames must not interleave their // fetch/POST pairs (the second's op would be dropped by the first's cleanup). let nickSaveChain = Promise.resolve(true); function saveNicknames() { nickSaveChain = nickSaveChain.then(saveNicknamesNow, saveNicknamesNow); return nickSaveChain; } async function saveNicknamesNow() { if (!session.userPriv || !session.userPub) return true; if (!nicknameOps.size) return true; const included = [...nicknameOps.entries()]; const base = await fetchServerNicknames(); if (!base) return queueNickRetry(); const baseVersion = nicknamesVersion; // pin the version this merge is built on const merged = { ...base }; for (const [uid, entry] of included) { if (entry) merged[uid] = entry; else delete merged[uid]; } try { const blob = await sealForPub(session.userPub, { v: 1, names: merged }); const r = await fetch("/api/nicknames", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, nicknames_blob: blob, version: baseVersion }), }); // A 409 means another device wrote between our read and this write — the // queued ops retry over a fresh read (read-modify-write converges). if (!r.ok) return queueNickRetry(); nicknamesVersion = ((await r.json()) || {}).version || baseVersion + 1; setNicknames(merged); // adopt anything another device stored meanwhile too // Clear only what this save wrote — an op re-set mid-save stays queued. for (const [uid, entry] of included) if (nicknameOps.get(uid) === entry) nicknameOps.delete(uid); return true; } catch { return queueNickRetry(); } } // A failed save must not be a silent loss: keep trying while ops are queued. // Always returns false so saveNicknames' failure paths read as one-liners. function queueNickRetry() { clearTimeout(nickRetryTimer); nickRetryTimer = setTimeout(async () => { if (nicknameOps.size && (await saveNicknames())) await refreshNamesUI(); }, 5000); return false; } // Set (or with "" clear) my name for someone, stamping `seen` with their // current published label so a later rename of theirs prompts me exactly once. // Returns whether the save has landed (false = applied locally, still syncing). async function setNickname(uid, name) { const e = roster.find((x) => x.user_id === uid); const entry = name ? { name, seen: (e && e.label) || "" } : null; if (entry) nicknames[uid] = entry; else delete nicknames[uid]; nicknameOps.set(uid, entry); const ok = await saveNicknames(); await refreshNamesUI(); return ok; } // Acknowledge a rename: keep my name but stop prompting (keep=true), or drop // my name and follow theirs from now on (keep=false). async function resolveRename(uid, keep) { const e = roster.find((x) => x.user_id === uid); const nick = nicknames[uid]; if (!nick) return true; const entry = keep ? { name: nick.name, seen: (e && e.label) || nick.seen } : null; if (entry) nicknames[uid] = entry; else delete nicknames[uid]; nicknameOps.set(uid, entry); const ok = await saveNicknames(); await refreshNamesUI(); return ok; } // The instance key is a single AES key shared by all members, used only for // all-members content (profile photos). It's wrapped to each member's user // pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if // granted; bootstrap it if nobody has it yet; and grant it to any member missing // it (so it propagates as people join). Never used for messages. // Holder liveness from the last keyless /api/instance-key read (#785): are // any key holders online, and when was one last around. Feeds the honest // invite copy below; null until a keyless read reports it. let keyHolderState = null; async function setupInstanceKey() { let r; try { r = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json(); } catch { return; } if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 }; if (r.wrapped) { bootRaw.instance = { wrapped: r.wrapped }; scheduleStoreSnapshot(); try { const b64 = await unwrapThreadKey(r.wrapped, session.userPriv); setInstanceKey(await importKey(b64), b64); } catch { return; } await grantInstanceKeyTo(r.have || []); } else if (!(r.have || []).length) { const k = await generateKey(); // first member: bootstrap the instance key setInstanceKey(k.key, k.b64); await grantInstanceKeyTo([]); } else { // It exists but we don't hold a copy yet. Nudge any online holder to wrap it // to us, then poll to pick up the grant — and KEEP re-nudging (on focus, and // on a slow timer) until it lands: the first holder to see a nudge may come // online long after the first poll would have expired, and a keyless member // renders everyone as "Someone" until this succeeds. const nudge = () => { fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {}); pollForInstanceKey(4); }; const onFocus = () => (instanceKey ? stopRetry() : nudge()); const timer = setInterval(() => (instanceKey ? stopRetry() : nudge()), 60000); const stopRetry = () => { clearInterval(timer); removeEventListener("focus", onFocus); }; addEventListener("focus", onFocus); fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {}); pollForInstanceKey(24); } } // After nudging holders, poll for a wrapped instance key; once granted, unwrap it // and re-render avatars. (The open Edit-profile sheet picks it up on next open.) async function pollForInstanceKey(tries = 24) { for (let i = 0; i < tries && !instanceKey; i++) { await new Promise((res) => setTimeout(res, 2500)); if (instanceKey) return; let r; try { r = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json(); } catch { continue; } if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 }; if (r.wrapped) { try { const b64 = await unwrapThreadKey(r.wrapped, session.userPriv); setInstanceKey(await importKey(b64), b64); await onInstanceKeyReady(); } catch {} return; } } } // ---- thread data, instance key & app lifecycle ---------------------------- // Flip the open Edit-profile sheet's photo control from "Photo syncing…" to // "Change photo" the moment the instance key arrives (live, no reload needed). function refreshProfilePhotoBtn() { const b = document.getElementById("me-photo-btn"); if (b && instanceKey) { b.disabled = false; b.textContent = "Change photo"; } // The "About you" field in the same sheet waits on the key too. const a = document.getElementById("me-about"); if (a && instanceKey && a.disabled) { a.disabled = false; a.placeholder = "A line or two about yourself"; const e = roster.find((x) => x.user_id === myUserId); if (!a.value) a.value = (e && e.about) || ""; } } // Wrap the instance key to every roster member not already in `have`. async function grantInstanceKeyTo(have) { if (!instanceKeyB64) return; const haveSet = new Set(have); const grants = []; for (const e of roster) { // Skip keyless members — a pending invitee who hasn't joined yet has no // public key to wrap to (connections() returns them for pending-DM names). // Without this guard wrapThreadKey's importKey throws DataError and aborts // boot for *everyone*; they pick up the key from a holder's backfill once // they join and publish one. if (e.public_key && !haveSet.has(e.user_id)) { grants.push({ user_id: e.user_id, wrapped: await wrapThreadKey(instanceKeyB64, e.public_key) }); } } if (grants.length) { await fetch("/api/instance-key", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, grants }), }); } } async function loadThreads() { const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json(); if (Array.isArray(r.threads) && !embed) { // Snapshot before apply mutates entries (e.g. the open_key "set" gate fix). bootRaw.threads = structuredClone(r); scheduleStoreSnapshot(); // Threads we've left / been removed from must not keep readable local rows // beyond what this device's keys already imply — prune the store to the // live list (an error body never reaches here, so it can't wipe the cache). storeCachedThreadIds() .then((ids) => Promise.all(staleThreadIds(ids, r).map((id) => storeClearThread(id)))) .catch(() => {}); } await applyThreads(r); } // The apply half of loadThreads (unwrap keys → titles → previews → dots) — // also run over the cached response on boot, so hydration and the network // path are one code path. async function applyThreads(r) { setThreads(r.threads || []); // Threads may land after the roster on a fresh boot — re-apply the // inviter-name hint (#1436) now that the hinted DM's members are known, // BEFORE titles compute below, so the DM lists under the inviter's name. applyInviterNameHint(); for (const t of threads) { if (!threadKeys[t.id] && (t.wrapped || t.open_key)) { // Two key sources: the per-member wrapped copy, then — for open groups — // open_key (the thread key encrypted under the instance key). open_key is // a true fallback, not an else: a bad wrapped blob must not hide an open // group the instance key can decrypt. If the instance key hasn't arrived // yet, skip for now; boot / onInstanceKeyReady re-fold the list. let b64 = null; if (t.wrapped) { try { b64 = await unwrapThreadKey(t.wrapped, session.userPriv); } catch (e) { console.error("unwrap thread", t.id, e); } } if (!b64 && t.open_key) { // The open_key's wrapping key depends on the gate: a level-gated // group's rides under that tier's key, an ungated one under the // instance key. Either may not have arrived yet — boot and the // key-granted prods re-fold when it does. const src = t.open_tier ? tierKeys[t.open_tier] : instanceKey; if (src) { try { b64 = (await decrypt(src, t.open_key)).k; } catch (e) { console.error("unwrap open thread", t.id, e); } } } if (!b64) continue; threadKeys[t.id] = await importKey(b64); threadKeyB64[t.id] = b64; } threadName[t.id] = await displayName(t); hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list pinned[t.id] = !!t.pinned; // pinned chats (server-synced) float to the top muted[t.id] = !!t.muted; // muted chats (server-synced) stay quiet dmClosed[t.id] = !!t.dm_closed; // ended DMs show the closed banner, not a composer // The server's watermark can trail a POST we just made — never regress ours. readMarks[t.id] = Math.max(readMarks[t.id] || 0, t.last_read_at || 0); if (t.last_blob && threadKeys[t.id]) { try { const payload = takeGossip(await decrypt(threadKeys[t.id], t.last_blob), false); // A typed app event (e.g. vault.add) has no .text — previewText would // read it as an empty chat message and render "Name: " with nothing // after the colon. Route it through the OWNING APP's preview hook // instead (same as the live path below), leaving the row's stale // preview in place when the app declares none (silent, not blank). if (isAppType(payload.type)) { const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId }); if (pv) threadPreview[t.id] = pv; } else { // Anything else — a chat message, or a trailing reaction event, which // previewText speaks as "James reacted 👍 to …" rather than the blank // row it used to leave behind. previewPayload[t.id] = payload; threadPreview[t.id] = previewText(t, payload); } // Seed the unread dot from the synced watermark, applying the same // gates as a live frame: your own last message never lights it (you // sent it, or replying elsewhere means you read it), and app events // only light it if the app says so. A silent app event neither lights // nor clears — an earlier lit dot stays lit, matching the live path. if (t.id !== activeId) { if ((t.last_at || 0) <= readMarks[t.id] || ownMessage(payload, myUserId, session.name)) { unread[t.id] = false; } else if (payload.type === "reaction" || payload.type === "threadconvert") { // Someone reacting, or tidying a chain into a thread, never lights // the dot (matching the live path); an earlier lit dot stays lit. } else if (!isAppType(payload.type) || appUnread(payload)) { unread[t.id] = true; } } } catch {} } } syncThreadKeysToSW(); } // Advance this user's read watermark for a thread to its newest message — the // server relays it to their other devices so the dot clears everywhere, and // remembers it so a reload doesn't resurrect it. Local-first and deduped: the // mark moves before the POST, and an already-current mark makes this a no-op // (so calling it on every live message while a thread is open stays cheap). function markThreadRead(id) { const t = threads.find((x) => x.id === id); const at = (t && t.last_at) || 0; if (!at || at <= (readMarks[id] || 0)) return; readMarks[id] = at; fetch(`/api/threads/${encodeURIComponent(id)}/read`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, last_read_at: at }), }).catch(() => {}); } // Hand the service worker the per-thread keys (+ name/kind) it needs to decrypt // push payloads. Stored in IndexedDB (the SW can't read localStorage). function syncThreadKeysToSW() { const map = {}; for (const t of threads) { if (threadKeyB64[t.id]) { map[t.id] = { key: threadKeyB64[t.id], name: threadName[t.id] || "Chat", kind: t.kind }; } } idbSet("threadKeys", map).catch(() => {}); // The SW also needs to know who "me" is: reaction pushes are shown only to // the reacted-to message's author (see sw.js), decided after decryption. if (myUserId) idbSet("myUid", myUserId).catch(() => {}); // Names for the SW's notification text (#577): a sender resolves by uid to // the name I show for them, and @mentions in the body rewrite to my names. // Pre-resolved here (the SW holds no keys for the nicknames blob) — kept // fresh by refreshNamesUI, which calls this on every name change. const byUid = {}; for (const e of roster) { const n = displayNameFor(nicknames[e.user_id], e.label); if (n) byUid[e.user_id] = n; } idbSet("memberNames", { byUid, renames: nicknameRenames() }).catch(() => {}); // Mute state for the SW's own notification gate. The server already skips // pushes for muted THREADS; muted PEOPLE are invisible to it by design, so // the sender check after decryption is the only place that one can run. const mutedThreads = Object.keys(muted).filter((id) => muted[id]); idbSet("muted", { threads: mutedThreads, people: [...mutedPeople] }).catch(() => {}); } // The frontend build this page loaded with (server-injected into the HTML). const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).content || ""; // Whether ordinary members may invite people (server-injected admin setting). Gates // the invite UI only; the server enforces it regardless. Kept live by // "user_invites_changed" below (issue #346) — an admin flipping the toggle // used to only take effect for connected members after a reload. let USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1"; // Per-instance feature gates (server-injected admin settings). Default ON — a // missing meta (an older server) reads as enabled — and, like USER_INVITES, // they only hide entry points; the server enforces each gate. ENABLE_AI has no // web entry point to hide (the sidecar enforces it), but rides along so copy // can consult it. // Whether THIS member may invite: the toggle governs ordinary members, but an // admin always can — otherwise the person who just set the space up couldn't // bring anyone in without a trip to the admin console (a path we're // sunsetting). Live read, so it's correct once the roster (which carries our // role) has loaded. function inviteAllowed() { if (USER_INVITES) return true; const me = roster.find((e) => e.user_id === myUserId); return !!(me && me.role === "admin"); } // Whether THIS member holds the admin role — gates the Admin app's entry // points in the pickers. A UI nicety, not security: the Admin app re-checks // the role through its host bridge (/api/me) before doing anything. function isAdmin() { const me = roster.find((e) => e.user_id === myUserId); return !!(me && me.role === "admin"); } // True when a composer holds unsent text — reload paths check this so picking up // an update never eats someone's draft. Covers every mounted chat-app composer // (Split view shows two). function hasUnsentDraft() { // The chat composer(s) live behind the composer.js facade — Split view can // mount two, and the rich backend isn't a textarea. Same semantics as the // old DOM query: only MOUNTED composers block (parked panes never did). return anyComposerHasText(); } // On returning to the app, ask the server for the current build hash; if it shipped // a newer frontend, reload to pick it up — unless that would clobber an unsent draft. let updateChecking = false; async function checkForUpdate() { if (!APP_VERSION || updateChecking) return; updateChecking = true; try { const r = await fetch("/api/version", { cache: "no-store" }); if (r.ok) { const v = (await r.json()).version; if (v && v !== APP_VERSION && !hasUnsentDraft()) location.reload(); } } catch { } finally { updateChecking = false; } } // Heartbeat so push is suppressed while the app is open: write a timestamp the SW // checks, and tell the server this device is active (mutes its other devices). function startHeartbeat() { const beat = () => { if (document.visibilityState !== "visible") return; idbSet("lastActiveAt", Date.now()).catch(() => {}); if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "active" })); }; beat(); setInterval(beat, 30000); document.addEventListener("visibilitychange", beat); clearNotifications(); // Returning to the app (tab/window visible again) → check for a newer build, // and catch up on any messages missed while hidden. This is what fixes a tapped // notification landing on a stale thread: the socket may still look OPEN (the OS // froze it without firing onclose), so a reconnect never fires — but refocus // always does, and re-fetches the missed messages. document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { checkForUpdate(); resyncActive(); // its tail marks the active thread read once caught up focusChatComposer(); clearNotifications(); } }); // ...and when the window itself regains focus (clicking back into the app from // another window/app, where the tab stayed "visible" so the handler above // doesn't fire). Both routes drop the caret into the composer so you're ready // to type — the Mac app does the same on didBecomeActive. window.addEventListener("focus", () => { focusChatComposer(); onSawActiveThread(); }); } // Coming back to the app with a thread open means you've now seen whatever // arrived in it while you were away — advance its synced read watermark // (messages landing in the ACTIVE thread never set the local dot, so without // this the user's other devices would keep showing one). function onSawActiveThread() { if (activeId) markThreadRead(activeId); } // Being in the app means you've seen what the banners were shouting about: close // any lchat notifications still sitting in the shade. Same sweep a notification // tap does in sw.js — the shared "lchat-message" tag means there's at most one, // covering whichever thread pushed last, so closing all loses nothing. function clearNotifications() { if (!("serviceWorker" in navigator)) return; navigator.serviceWorker.getRegistration().then(async (reg) => { if (!reg) return; for (const n of await reg.getNotifications()) n.close(); }).catch(() => {}); } // Drop the caret into the live chat composer (the id-less chat.js