openvibers/openvibe.live Commits
The home page no longer downloads the chat UI (chat-core.js, the chatUI route feature, budgets), and warms it at idle (#672)
Changes
@@@ -49,6 +49,9 @@ public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ovroute generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()public/js/app.js core: router, auth, API helper, shared renderers, connection pillpublic/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs++public/js/chat-core.js the part of chat every page needs: toast(), the chat settings, the floating chat++ button's visibility, the ?vcInvite= deep link. The chat UI itself (public/js/chat.js)++ belongs to the pages that have chat.```- **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's@@@ -70,6 +73,15 @@ public/js/app-*.js route code split out of app.js: home, channel, media, cCommunity cannot answer, the page is 200, never a false 404.- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: thestub loads the feature, then calls the real function.++- **The chat is a lazy feature.** The chat UI (`public/js/chat.js` — the socket, the messages, the++ panels, the moderation — with `public/js/chat-shared.js` and `public/css/chat-shared.css`) is 340 KB++ of JavaScript, so only the pages that have chat ask for it: the routes `/@…`, `/stream/…`, `/chat`++ and `/broadcast` name the feature `chatUI` (public/features.json). It is a route feature and not a++ dependency of `channel`, so the home page's idle prefetch of the channel page does not carry it.++ The part of chat that every page needs — `toast()`, the chat settings, the floating chat button's++ visibility, the `?vcInvite=` deep link — is `public/js/chat-core.js`. The chat UI's inline handlers++ (the floating button, the chat pane's buttons) are in the feature's `stubs`, so a click before it++ has loaded fetches it and then does the thing (`test/chat-ui-lazy.test.js`).- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files(`<link rel=prefetch>`, download only); a route's `idle` list is prefetched after load. Skipped onSave-Data, 2G and low-memory devices.@@@ -201,6 +201,49 @@ The throttled-network `--phone` profile still shows the home's first paint at 3.share the 1.6 Mbps link with ~30 deferred scripts). Real-user data (#217) is the arbiter now: compare`live_rum_p75{page="home",device="phone"}` before restructuring the stylesheets.++## Measurement 2026-11-07: the chat UI is a lazy feature++++The chat (`public/js/chat.js`, 326 KB, plus `chat-shared.js`) used to be in the home page's shell:++every page — the home, the dashboard, a channel — downloaded the whole chat before it painted++anything. It is now a feature (`public/features.json` → `chatUI`) that only the routes with chat++ask for, and `public/js/chat-core.js` (19 KB: the toast, the chat settings, the floating button,++the `?vcInvite=` link) stays in the shell for the pages that need those.++++`npm run perf:budget` (the server's own render of "/", deterministic — before is `main` at 71ba7ec,++openvibe-shared 4.133.0):++++| | before | after |++|---|---:|---:|++| home HTML | 137.7 KB (23.8 br) | 138.6 KB (24.1 br) — the asset manifest now carries the chatUI feature |++| home JS | 26 files, 1310.5 KB (306.5 br) | **25 files, 955.2 KB (233.7 br)** |++| home CSS | 5 files, 177.5 KB (35.4 br) | 5 files, 177.5 KB (35.4 br) — `chat-shared.css` left with the chat |++++What the browser asked for, on a 390×844 phone (`tools/phone-speed.js` profile, local server):++++| page | before | after |++|---|---|---|++| `/` | 41 scripts (1787.3 KB) + 13 stylesheets (862.5 KB) | 40 scripts (1432.1 KB) + 12 stylesheets (843.6 KB) — **−374 KB, −14%** |++| `/@admin` | 50 scripts (2072.4 KB) + 13 stylesheets (815.9 KB) | 51 scripts (2076.8 KB) + 13 stylesheets (815.9 KB) — unchanged |++++The channel page is unchanged because the server still puts the chat in its HTML: a direct visit to++`/@x` downloads the chat in parallel with the rest of the page, exactly as before. What moved is++*when* it is paid for — a visit that starts on the home page no longer pays for a chat it may never++open. The cost is on the home → channel path inside one session: the chat is downloaded when the++channel is entered rather than at the home page, so on a cold phone the chat's first paint is about++0.5 s later (91.6 KB of Brotli at 1.6 Mbps); a repeat visit is unaffected (the browser has it), and++the loader's intent prefetch (hover, focus or touch on a channel link) warms it before the click.++++Timings on the throttled phone profile (4× CPU, 1.6 Mbps, 150 ms), two runs each, same box, both++states on local servers: the home page's DOMContentLoaded fell from 14078/14520 ms to 11488/12219 ms++(the page has 374 KB less JavaScript to parse — this held in every run). The LCP (the hero quip,++which waits for `/api/home/hero`) is inside the noise band of this shared box: the runs gave++3888/4964 ms before and 3952/15776 ms after, the second "after" run being an outlier on the API++call, so no LCP result is claimed. The channel page's DOMContentLoaded moved 5381/5458 →++7381/7491 ms: the chat's download moved from the home page to the channel page.++++`scripts/perf/check-budgets.js` now refuses to let `/js/chat.js`, `/js/chat-shared.js` or++`/css/chat-shared.css` back onto the home page, next to the size budgets it just lowered.++## Budgets`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly@@@ -1,6 +1,33 @@{"_comment": "Feature and route registry, read by server/web/assets.js (first paint: which assets the requested route needs) and by public/js/ov-loader.js (navigation: what to load before a route renders). Scripts in a feature run in the listed order, after the feature's dependencies. `fragment` names public/fragments/<name>.html, inserted into section #page-<section> before any of the feature's scripts run. `after` names a global function called once when the feature has loaded. `stubs` are global functions that inline handlers call; until the feature is loaded they load it and then call the real function. See docs/architecture.md#frontend-loading.","features": {++ "chatUI": {++ "_comment": "The chat UI: the socket, the messages, the panels. Only the pages that have chat ask for it (the routes below name it); the part of chat every page needs is public/js/chat-core.js.",++ "css": [++ "/css/chat-shared.css"++ ],++ "js": [++ "/js/chat-shared.js",++ "/js/chat.js"++ ],++ "stubs": [++ "sendChat",++ "toggleChatTTS",++ "toggleChatSettings",++ "toggleChatUsers",++ "toggleChatLimitsPopover",++ "toggleMobileChat",++ "popoutStreamChat",++ "popoutGlobalChat",++ "onChatModeChange",++ "fcwToggle",++ "fcwMinimize",++ "fcwClose",++ "fcwSendChat",++ "fcwStartDrag",++ "fcwStartResize"++ ]++ },"home": {"fragment": "home","css": [@@@ -20,7 +47,8 @@"idle": ["guide","player",-- "channel"++ "channel",++ "chatUI"]},"feeds": {@@@ -360,12 +388,14 @@{"path": "^/broadcast","features": [++ "chatUI","broadcast"]},{"path": "^/chat","features": [++ "chatUI","chatpage"]},@@@ -392,6 +422,7 @@{"path": "^/stream/","features": [++ "chatUI","channel"]},@@@ -404,6 +435,7 @@{"path": "^/@","features": [++ "chatUI","channel"]}@@@ -117,11 +117,13 @@their rules sit in the cascade exactly where they sat inside style.css. --><meta name="ov-css-anchor"><link rel="stylesheet" href="/css/i18n-star.css">-- <link rel="stylesheet" href="/css/chat-shared.css" media="print" onload="this.media='all';this.onload=null">++ <!-- chat-shared.css belongs to the chat (features.json → chatUI): the pages that have chat ask++ for it there, and a page without chat does not download it. --><link rel="stylesheet" href="/css/broadcast.css" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/cosmetics.css" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/messenger.css" media="print" onload="this.media='all';this.onload=null"><noscript>++ <!-- Without JavaScript the chat never fills, but the pages that have one still show it. --><link rel="stylesheet" href="/css/chat-shared.css"><link rel="stylesheet" href="/css/broadcast.css"><link rel="stylesheet" href="/css/cosmetics.css">@@@ -443,11 +445,12 @@<script src="/shared/navbar.js" defer></script><script src="/js/app.js" defer></script><script src="/js/ov-navbar-live.js" defer></script>--<script src="/js/chat-shared.js" defer></script><script src="/js/lang-detect.js" defer></script>--<script src="/js/chat.js" defer></script>++<script src="/js/chat-core.js" defer></script><!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->--<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->++<!-- ── Floating Global Chat Widget (visible on non-chat pages) ──++ The chat UI is a lazy feature (features.json → chatUI), so the handlers below are stubs++ until it has loaded: the first click fetches it and then does the thing. --><div id="floating-chat-widget" class="floating-chat-widget" style="display:none"><div class="fcw-header" onmousedown="fcwStartDrag(event)"><span><i class="fa-solid fa-comments"></i> Global Chat</span>@@@ -966,7 +966,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI// Offline: join the streamer's PERSISTENT chat room (not global) so// viewers can keep chatting + see history while the streamer is offline.-- initChat(null, ch.user_id);++ withChatUi(() => initChat(null, ch.user_id));// Hide stream tabs on offline channelsconst tabsC = document.getElementById('live-stream-tabs');@@@ -1924,7 +1924,7 @@ function activateChannelStream(stream) {console.error('[Player] init failed', e);}}-- if (typeof initChat === 'function') initChat(stream.id, stream.user_id || _activeChannelUserId);++ withChatUi(() => initChat(stream.id, stream.user_id || _activeChannelUserId));if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(stream.id);if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(stream.user_id);startUptime(stream.started_at);@@@ -3814,7 +3814,7 @@ async function openStream(streamId) {document.getElementById('follower-count').textContent = `${s.follower_count || 0} followers`;if (typeof initPlayer === 'function') initPlayer(s);-- if (typeof initChat === 'function') initChat(streamId, s.user_id || _activeChannelUserId);++ withChatUi(() => initChat(streamId, s.user_id || _activeChannelUserId));if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(streamId);if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(s.user_id);loadStreamGoals(streamId);@@@ -8,7 +8,7 @@function loadChatPage() {// Connect to global chat (no streamId). initChat loads the history itself — this page used to// load and render all 500 rows a second time right after it.-- initChat(null);++ withChatUi(() => initChat(null));// Global chat AI overview + timeline (refreshed periodically)loadGlobalChatAi();if (window._globalAiPollTimer) clearInterval(window._globalAiPollTimer);@@@ -87,13 +87,25 @@ function ovUserTag(u, label) {+ ` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ovUserMenu(event);}">${esc(name)}</span>`;}--/** Open the chat user menu; if chat has not loaded, fall back to the channel. */++/**++ * Open the chat user menu.++ *++ * The chat UI is a lazy feature (public/features.json → chatUI) and this page has no chat of its++ * own, so the menu is not here yet: fetch the chat UI and open the same menu. If it cannot be++ * loaded, fall back to the channel — the name is a link to it.++ */function ovUserMenu(event) {if (typeof showChatContextMenu === 'function') return showChatContextMenu(event);const el = event.currentTarget;const core = el && el.dataset && el.dataset.coreUsername;if (!core) return;event.preventDefault();++ if (window.ov && typeof window.ov.load === 'function') {++ window.ov.load('chatUI').then(() => {++ if (typeof showChatContextMenu === 'function') showChatContextMenu(event);++ }).catch(() => { navigate(`/@${core}`); });++ return;++ }navigate(`/@${core}`);}@@@ -851,7 +851,7 @@ function ensureBroadcastChat(streamId) {if (sidebar) sidebar.style.display = '';// The broadcast page is the signed-in streamer's own channel: say so, or chat takes it for someone else's and// never reads the streamer's own messages aloud (chat.js _isOwnChannelChat).-- if (typeof initChat === 'function' && streamId) initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null);++ if (streamId) withChatUi(() => initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null));}/* ── Sign-in gate ────────────────────────────────────────────── */nnew file mode 100644@@@ -0,0 +1,377 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Live — Chat core: the part of chat that every page needs++ ═══════════════════════════════════════════════════════════════++ public/js/chat.js is the chat UI — the socket, the messages, the panels, the moderation. It++ belongs where chat is (a channel, the chat page, the broadcast desk) and nowhere else, so it is++ a lazy feature (public/features.json → chatUI): a page without chat does not download it.++++ This file is the part every page does need, and nothing more:++ - toast(): the one-line wrapper every script on the site calls;++ - the chat settings (this browser's copy and the server's), because the floating chat button++ reads them and the server copy is synced on every page;++ - the floating chat button's visibility: the button sits in index.html on every page, and it++ has to appear on the pages that have no chat of their own;++ - the voice-call invite deep link (/?vcInvite=…), which a notification can land on any page.++++ Loaded before chat.js. Both are plain scripts, so they share one global scope: chat.js reads++ chatSettings and _fcwOpen from here, and this file calls back into chat.js only by name, and++ only when the function it needs is there.++ ═══════════════════════════════════════════════════════════════ */++++/** Run now if the document is already parsed, otherwise when it is. */++function _onReady(fn) {++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });++ else fn();++}++++function toast(message, type = 'info') {++ // Delegate to the app-level toast saved in app.js (loaded before this file).++ // Do NOT use window.toast here — this file's own function declaration overwrites++ // window.toast, so that check is always self-referential and causes infinite recursion.++ if (typeof window._appToast === 'function') {++ return window._appToast(message, type);++ }++ if (typeof console !== 'undefined' && console.log) {++ console.log(`[Chat toast ${type}] ${String(message)}`);++ }++}++++// ── Chat settings (persisted to localStorage) ────────────────++const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';++const CHAT_SETTINGS_DEFAULTS = {++ // Appearance++ showTimestamps: false,++ timestampFormat: '12h', // '12h' or '24h'++ fontSize: 'default', // 'small', 'default', 'large'++ showAvatars: true,++ showBadges: true,++ readableColors: false, // Force minimum contrast on name colors++ alternateBackground: false, // Zebra-stripe messages++ // Emotes++ animatedEmotes: true, // Show animated/GIF emotes++ emoteScale: 'default', // 'small', 'default', 'large'++ // Behavior++ showDeletedMessages: false, // Show <deleted> instead of hiding++ showSystemMessages: true, // Join/part/system messages++ mentionHighlight: true, // Highlight messages containing your @name++ autoScroll: true, // Auto-scroll to bottom on new messages++ autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)++ // Notifications++ flashOnMention: true, // Flash browser tab on @mention++ soundOnMention: false, // Play a sound on @mention++ autoDeclineCalls: false, // Auto-decline incoming voice calls++ // Widget++ showFloatingChat: true, // Show floating global chat button on non-chat pages++ fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player++ compactMode: false, // Compact message layout (popout & popout-compatible surfaces)++ // Cross-feed — show messages from other sources while in a stream chat++ showGlobalInStream: true, // Show global chat messages in stream chat (on by default)++ showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)++ showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)++ // TTS++ ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;++ // OFF prepends "." to your messages so TTS skips them (and mutes local playback).++ // Defaults ON so messages aren't dot-prefixed/muted-styled by default.++ streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live++ channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live++ ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's++ // own audio already carries its chat TTS (the streamer plays it), and a second copy in++ // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).++ // This browser only: not part of the synced chat.tts_defaults record.++ ttsVolume: 80, // TTS message volume (0–100)++ soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS++ // Chat sounds (soundboard + channel !sound commands) — independent of TTS++ soundsEnabled: true, // Play chat sound clips (default on)++ // Per-source TTS — which relay platforms contribute to TTS++ ttsSrcNative: true, // Native OpenVibe.Live chat++ ttsSrcRS: true, // RobotStreamer relay++ ttsSrcKick: true, // Kick relay++ ttsSrcYoutube: true, // YouTube relay++ ttsSrcTwitch: true, // Twitch relay++ // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.++ friendlyGlobal: null,++};++let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };++++const CHAT_SELF_DELETE_MINUTES = 3;++++function normalizeChatAutoDeleteMinutes(value) {++ const minutes = parseInt(value, 10);++ if (!Number.isFinite(minutes) || minutes <= 0) return 0;++ return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);++}++++let _settingsSyncTimer = null;++++function loadChatSettings() {++ try {++ const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));++ if (saved) {++ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };++ // Migration: the TTS toggle changed from a receiver-side mute to a++ // sender-side "prefix '.' to skip TTS" control that defaults ON. Old++ // saved settings may carry ttsEnabled:false from the previous default,++ // which would dot-prefix/mute every message — reset it once.++ if (!saved.ttsSendModeV2) {++ chatSettings.ttsEnabled = true;++ chatSettings.ttsSendModeV2 = true;++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }++ }++ }++ } catch { /* use defaults */ }++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);++ // Async server sync — merge server settings on top of local++ _syncSettingsFromServer();++}++function saveChatSettings() {++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }++ _applyChatUi();++ // Debounced push to server++ _debounceSyncSettingsToServer();++}++++/**++ * The chat UI paints itself from the settings. It is not on every page (it is a lazy feature),++ * so it is asked for by name — a page without chat simply has nothing to paint.++ */++function _applyChatUi() {++ if (typeof applyChatSettings === 'function') applyChatSettings();++ if (typeof syncSettingsPanelUI === 'function') syncSettingsPanelUI();++ if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();++}++++/**++ * Run `fn` with the chat UI in place.++ *++ * The chat UI (public/js/chat.js — the socket, the messages, the panels) is a lazy feature++ * (public/features.json → chatUI): only the pages that have chat ask for it. A page that has++ * chat but has not loaded it yet — the route's scripts and the chat's arrive side by side — asks++ * for it here first, so the socket is never missing when the page talks to it.++ */++function withChatUi(fn) {++ if (typeof initChat !== 'function' && window.ov && typeof window.ov.load === 'function') {++ window.ov.load('chatUI').then(() => { try { fn(); } catch (e) { console.warn('[chat]', e); } }, () => { });++ return;++ }++ try { fn(); } catch (e) { console.warn('[chat]', e); }++}++++// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),++// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the++// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the++// field, i.e. "Live's default"; Chat writes nothing when nothing changed.++const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };++function _chatPrefsFromSettings(s) {++ return {++ timestamps: s.showTimestamps ? true : null,++ compact: s.compactMode ? true : null,++ font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,++ show_badges: s.showBadges === false ? false : null,++ };++}++function _chatSettingsFromPrefs(p) {++ const out = {++ showTimestamps: p.timestamps === true,++ compactMode: p.compact === true,++ fontSize: 'default',++ showBadges: p.show_badges !== false,++ };++ if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';++ return out;++}++// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.++// A field at Live's default is sent as null (removed), so the record only holds what the person changed.++const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };++function _ttsModuleFromSettings(s) {++ const sources = {};++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;++ const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);++ return {++ send: s.ttsEnabled === false ? false : null,++ send_while_live: s.streamingTtsEnabled === false ? false : null,++ volume: pct(s.ttsVolume),++ sounds: s.soundsEnabled === false ? false : null,++ sound_volume: pct(s.soundVolume),++ sources: Object.keys(sources).length ? sources : null,++ };++}++function _settingsFromTtsModule(t) {++ const out = {++ ttsEnabled: t.send !== false,++ streamingTtsEnabled: t.send_while_live !== false,++ ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,++ soundsEnabled: t.sounds !== false,++ soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,++ };++ const src = t.sources && typeof t.sources === 'object' ? t.sources : {};++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;++ return out;++}++const CHAT_MODULES = [++ { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },++ { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },++];++function _pushChatPrefs(token) {++ return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {++ method: 'PUT',++ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },++ body: JSON.stringify({ [m.key]: m.from(chatSettings) }),++ }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));++}++/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */++function _syncChatPrefsFromChat(token) {++ for (const m of CHAT_MODULES) {++ fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })++ .then(r => r.ok ? r.json() : null)++ .then(data => {++ const rec = data && data[m.key];++ if (!rec || typeof rec !== 'object') return;++ if (!data.revision) {++ fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});++ return;++ }++ chatSettings = { ...chatSettings, ...m.to(rec) };++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }++ _applyChatUi();++ })++ .catch(() => { /* offline — use local */ });++ }++}++++function _syncSettingsFromServer() {++ const token = localStorage.getItem('token');++ if (!token) return;++ fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })++ .then(r => r.ok ? r.json() : null)++ .then(data => {++ if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {++ // Server wins on conflicts++ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }++ _applyChatUi();++ }++ })++ .catch(() => { /* offline — use local */ })++ .finally(() => _syncChatPrefsFromChat(token));++}++++function _debounceSyncSettingsToServer() {++ if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);++ _settingsSyncTimer = setTimeout(() => {++ const token = localStorage.getItem('token');++ if (!token) return;++ fetch('/api/auth/preferences', {++ method: 'PUT',++ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },++ body: JSON.stringify({ chatSettings })++ }).catch(() => { /* silent fail */ });++ _pushChatPrefs(token);++ }, 500);++}++++// ── Floating chat widget state ───────────────────────────────++let _fcwOpen = false;++++/** Show/hide the floating chat FAB on non-chat pages */++function _fcwUpdateVisibility() {++ const fab = document.getElementById('floating-chat-fab');++ const widget = document.getElementById('floating-chat-widget');++ const disabled = chatSettings && chatSettings.showFloatingChat === false;++++ // Hide on the global chat page (already has full chat)++ const chatPage = document.getElementById('page-chat');++ const isOnChatPage = chatPage && chatPage.classList.contains('active');++++ // Hide on live stream / channel pages (already have chat sidebar)++ const streamPage = document.getElementById('page-stream');++ const channelPage = document.getElementById('page-channel');++ const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))++ || (channelPage && channelPage.classList.contains('active'));++++ // Hide on broadcast page (has its own chat panel)++ const broadcastPage = document.getElementById('page-broadcast');++ const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');++++ if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {++ if (fab) fab.style.display = 'none';++ if (widget) widget.style.display = 'none';++ _fcwOpen = false;++ } else {++ // Not on chat/stream page — show FAB++ if (fab) fab.style.display = '';++ if (!_fcwOpen && widget) widget.style.display = 'none';++ }++}++++// The floating chat button follows the page: it shows on the pages that have no chat of their own.++// (The router announces page changes; this replaces one MutationObserver per page section.)++_onReady(() => {++ document.addEventListener('ov:page', _fcwUpdateVisibility);++ // Initial check++ setTimeout(_fcwUpdateVisibility, 100);++});++++async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {++ if (!channelId) return;++++ if (switchToChat && typeof showPage === 'function') {++ showPage('chat');++ }++++ // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.++ if (typeof vcJoinChannel !== 'function' && window.ov) {++ try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }++ }++ if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {++ window._pendingVcInvite = { channelId, channelName, at: Date.now() };++ toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');++ return;++ }++++ await vcFetchChannels();++ const ch = (vcState.channels || []).find(c => c.id === channelId);++ if (!ch) {++ toast('That call channel is no longer available', 'error');++ return;++ }++ if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {++ return;++ }++ // Through the same door as a click: leaves whatever channel we are in first.++ vcSelectChannel(ch.id);++}++++async function acceptVcInvite(channelId, channelName) {++ try {++ // The incoming-call overlay belongs to the chat UI; on a page without chat there is none.++ if (typeof window._dismissIncomingVcCallOverlay === 'function') window._dismissIncomingVcCallOverlay();++ await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);++ } catch (err) {++ toast(err.message || 'Failed to join call invite', 'error');++ }++}++++/**++ * Deep-link support from notifications: /?vcInvite=<channelId>.++ * The invite lands on whatever page the person was on, so the join happens here rather than in++ * the chat UI. A pending invite (the voice feature was not there yet) waits for the chat page.++ */++_onReady(() => {++ try {++ const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');++ if (inviteChannel) {++ setTimeout(() => {++ acceptVcInvite(inviteChannel, 'Voice Channel');++ }, 450);++ }++ if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {++ const pending = window._pendingVcInvite;++ window._pendingVcInvite = null;++ setTimeout(() => {++ acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');++ }, 450);++ }++ } catch { }++});@@@ -11,23 +11,12 @@ let chatRenderTargetId = null;// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.jsconst _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);--function toast(message, type = 'info') {-- // Delegate to the app-level toast saved in app.js (loaded before this file).-- // Do NOT use window.toast here — chat.js's own function declaration overwrites-- // window.toast, so that check is always self-referential and causes infinite recursion.-- if (typeof window._appToast === 'function') {-- return window._appToast(message, type);-- }-- if (typeof console !== 'undefined' && console.log) {-- console.log(`[Chat toast ${type}] ${String(message)}`);-- }--}++// ── toast() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.// ── Chat mode (Global / Voice Call) ──────────────────────────let chatMode = 'global'; // 'global' or 'voice'--// ── Floating chat widget state ───────────────────────────────--let _fcwOpen = false;++// ── the floating chat button state moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.let _fcwUnread = 0;let _fcwDragging = false;let _fcwDragOffset = { x: 0, y: 0 };@@@ -98,64 +87,9 @@ function relayBadgeHTML(platform) {return `<span class="chat-badge chat-relay-badge" data-tip="${b.label}" style="--relay-color:${b.color}"><i class="${b.icon}"></i></span>`;}--// ── Chat settings (persisted to localStorage) ────────────────--const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';--const CHAT_SETTINGS_DEFAULTS = {-- // Appearance-- showTimestamps: false,-- timestampFormat: '12h', // '12h' or '24h'-- fontSize: 'default', // 'small', 'default', 'large'-- showAvatars: true,-- showBadges: true,-- readableColors: false, // Force minimum contrast on name colors-- alternateBackground: false, // Zebra-stripe messages-- // Emotes-- animatedEmotes: true, // Show animated/GIF emotes-- emoteScale: 'default', // 'small', 'default', 'large'-- // Behavior-- showDeletedMessages: false, // Show <deleted> instead of hiding-- showSystemMessages: true, // Join/part/system messages-- mentionHighlight: true, // Highlight messages containing your @name-- autoScroll: true, // Auto-scroll to bottom on new messages-- autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)-- // Notifications-- flashOnMention: true, // Flash browser tab on @mention-- soundOnMention: false, // Play a sound on @mention-- autoDeclineCalls: false, // Auto-decline incoming voice calls-- // Widget-- showFloatingChat: true, // Show floating global chat button on non-chat pages-- fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player-- compactMode: false, // Compact message layout (popout & popout-compatible surfaces)-- // Cross-feed — show messages from other sources while in a stream chat-- showGlobalInStream: true, // Show global chat messages in stream chat (on by default)-- showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)-- showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)-- // TTS-- ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;-- // OFF prepends "." to your messages so TTS skips them (and mutes local playback).-- // Defaults ON so messages aren't dot-prefixed/muted-styled by default.-- streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live-- channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live-- ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's-- // own audio already carries its chat TTS (the streamer plays it), and a second copy in-- // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).-- // This browser only: not part of the synced chat.tts_defaults record.-- ttsVolume: 80, // TTS message volume (0–100)-- soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS-- // Chat sounds (soundboard + channel !sound commands) — independent of TTS-- soundsEnabled: true, // Play chat sound clips (default on)-- // Per-source TTS — which relay platforms contribute to TTS-- ttsSrcNative: true, // Native OpenVibe.Live chat-- ttsSrcRS: true, // RobotStreamer relay-- ttsSrcKick: true, // Kick relay-- ttsSrcYoutube: true, // YouTube relay-- ttsSrcTwitch: true, // Twitch relay-- // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.-- friendlyGlobal: null,--};--let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };++// ── the chat settings moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.let chatSettingsPanelOpen = false;--const CHAT_SELF_DELETE_MINUTES = 3;++// ── CHAT_SELF_DELETE_MINUTES moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.let chatSelfDeletePolicy = {allowAutoDelete: true,allowDeleteAll: true,@@@ -193,11 +127,7 @@ const MAIN_CHAT_MAX_MESSAGES = 500;let _fullscreenChatIdleTimer = null;let _fullscreenChatRecent = [];--function normalizeChatAutoDeleteMinutes(value) {-- const minutes = parseInt(value, 10);-- if (!Number.isFinite(minutes) || minutes <= 0) return 0;-- return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);--}++// ── normalizeChatAutoDeleteMinutes() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.function canUseViewerAutoDeleteHere() {return !chatStreamId || !!chatSelfDeletePolicy.allowAutoDelete;@@@ -207,153 +137,9 @@ function canUseViewerDeleteAllHere() {return !chatStreamId || !!chatSelfDeletePolicy.allowDeleteAll;}--let _settingsSyncTimer = null;----function loadChatSettings() {-- try {-- const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));-- if (saved) {-- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };-- // Migration: the TTS toggle changed from a receiver-side mute to a-- // sender-side "prefix '.' to skip TTS" control that defaults ON. Old-- // saved settings may carry ttsEnabled:false from the previous default,-- // which would dot-prefix/mute every message — reset it once.-- if (!saved.ttsSendModeV2) {-- chatSettings.ttsEnabled = true;-- chatSettings.ttsSendModeV2 = true;-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }-- }-- }-- } catch { /* use defaults */ }-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);-- // Async server sync — merge server settings on top of local-- _syncSettingsFromServer();--}--function saveChatSettings() {-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }-- applyChatSettings();-- // Debounced push to server-- _debounceSyncSettingsToServer();--}++// ── the settings sync timer moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.--// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),--// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the--// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the--// field, i.e. "Live's default"; Chat writes nothing when nothing changed.--const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };--function _chatPrefsFromSettings(s) {-- return {-- timestamps: s.showTimestamps ? true : null,-- compact: s.compactMode ? true : null,-- font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,-- show_badges: s.showBadges === false ? false : null,-- };--}--function _chatSettingsFromPrefs(p) {-- const out = {-- showTimestamps: p.timestamps === true,-- compactMode: p.compact === true,-- fontSize: 'default',-- showBadges: p.show_badges !== false,-- };-- if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';-- return out;--}--// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.--// A field at Live's default is sent as null (removed), so the record only holds what the person changed.--const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };--function _ttsModuleFromSettings(s) {-- const sources = {};-- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;-- const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);-- return {-- send: s.ttsEnabled === false ? false : null,-- send_while_live: s.streamingTtsEnabled === false ? false : null,-- volume: pct(s.ttsVolume),-- sounds: s.soundsEnabled === false ? false : null,-- sound_volume: pct(s.soundVolume),-- sources: Object.keys(sources).length ? sources : null,-- };--}--function _settingsFromTtsModule(t) {-- const out = {-- ttsEnabled: t.send !== false,-- streamingTtsEnabled: t.send_while_live !== false,-- ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,-- soundsEnabled: t.sounds !== false,-- soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,-- };-- const src = t.sources && typeof t.sources === 'object' ? t.sources : {};-- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;-- return out;--}--const CHAT_MODULES = [-- { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },-- { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },--];--function _pushChatPrefs(token) {-- return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {-- method: 'PUT',-- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },-- body: JSON.stringify({ [m.key]: m.from(chatSettings) }),-- }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));--}--/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */--function _syncChatPrefsFromChat(token) {-- for (const m of CHAT_MODULES) {-- fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })-- .then(r => r.ok ? r.json() : null)-- .then(data => {-- const rec = data && data[m.key];-- if (!rec || typeof rec !== 'object') return;-- if (!data.revision) {-- fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});-- return;-- }-- chatSettings = { ...chatSettings, ...m.to(rec) };-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }-- applyChatSettings();-- syncSettingsPanelUI();-- if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();-- })-- .catch(() => { /* offline — use local */ });-- }--}----function _syncSettingsFromServer() {-- const token = localStorage.getItem('token');-- if (!token) return;-- fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })-- .then(r => r.ok ? r.json() : null)-- .then(data => {-- if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {-- // Server wins on conflicts-- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }-- applyChatSettings();-- syncSettingsPanelUI();-- syncTTSToggleButtons();-- }-- })-- .catch(() => { /* offline — use local */ })-- .finally(() => _syncChatPrefsFromChat(token));--}----function _debounceSyncSettingsToServer() {-- if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);-- _settingsSyncTimer = setTimeout(() => {-- const token = localStorage.getItem('token');-- if (!token) return;-- fetch('/api/auth/preferences', {-- method: 'PUT',-- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },-- body: JSON.stringify({ chatSettings })-- }).catch(() => { /* silent fail */ });-- _pushChatPrefs(token);-- }, 500);--}++// ── the settings functions moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.function _vcEnsureAudioContext() {const Ctx = window.AudioContext || window.webkitAudioContext;@@@ -4275,44 +4061,7 @@ async function ctxCallUser(username, userId) {}}--async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {-- if (!channelId) return;---- if (switchToChat && typeof showPage === 'function') {-- showPage('chat');-- }---- // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.-- if (typeof vcJoinChannel !== 'function' && window.ov) {-- try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }-- }-- if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {-- window._pendingVcInvite = { channelId, channelName, at: Date.now() };-- toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');-- return;-- }---- await vcFetchChannels();-- const ch = (vcState.channels || []).find(c => c.id === channelId);-- if (!ch) {-- toast('That call channel is no longer available', 'error');-- return;-- }-- if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {-- return;-- }-- // Through the same door as a click: leaves whatever channel we are in first.-- vcSelectChannel(ch.id);--}----async function acceptVcInvite(channelId, channelName) {-- try {-- _dismissIncomingVcCallOverlay();-- await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);-- } catch (err) {-- toast(err.message || 'Failed to join call invite', 'error');-- }--}++// ── the voice-call invite join moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.function ctxViewChannel(username) {dismissContextMenu();@@@ -6404,36 +6153,7 @@ function updateChatModeVoiceOption(enabled) {FLOATING CHAT WIDGET═══════════════════════════════════════════════════════════════ */--/** Show/hide the floating chat FAB on non-chat pages */--function _fcwUpdateVisibility() {-- const fab = document.getElementById('floating-chat-fab');-- const widget = document.getElementById('floating-chat-widget');-- const disabled = chatSettings && chatSettings.showFloatingChat === false;---- // Hide on the global chat page (already has full chat)-- const chatPage = document.getElementById('page-chat');-- const isOnChatPage = chatPage && chatPage.classList.contains('active');---- // Hide on live stream / channel pages (already have chat sidebar)-- const streamPage = document.getElementById('page-stream');-- const channelPage = document.getElementById('page-channel');-- const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))-- || (channelPage && channelPage.classList.contains('active'));---- // Hide on broadcast page (has its own chat panel)-- const broadcastPage = document.getElementById('page-broadcast');-- const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');---- if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {-- if (fab) fab.style.display = 'none';-- if (widget) widget.style.display = 'none';-- _fcwOpen = false;-- } else {-- // Not on chat/stream page — show FAB-- if (fab) fab.style.display = '';-- if (!_fcwOpen && widget) widget.style.display = 'none';-- }--}++// ── _fcwUpdateVisibility() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.function fcwToggle() {_fcwOpen = !_fcwOpen;@@@ -6668,37 +6388,16 @@ function _fcwStopResize() {// Page markup that arrives later (ov-loader.js fragments) gets the same one-time setup the// boot-time markup got: font-size classes and scroll containment, GIF buttons.++// (The GIF pickers initialise when the chat extras load — features.json → chatExtras.after —++// not here: calling them on every page fetched /api/chat/gif/providers on every page, the home++// page included.)document.addEventListener('ov:fragment', () => {try { applyChatSettings(); } catch { /* */ }if (typeof initGifPickers === 'function') { try { initGifPickers(); } catch { /* */ } }});--// Hook into page navigation to show/hide FAB--document.addEventListener('DOMContentLoaded', () => {-- // GIF pickers initialise when the chat extras load (features.json → chatExtras.after); calling-- // it here fetched /api/chat/gif/providers on every page, the home page included.-- // The router announces page changes; this replaces one MutationObserver per page section.-- document.addEventListener('ov:page', _fcwUpdateVisibility);-- // Initial check-- setTimeout(_fcwUpdateVisibility, 100);---- // Deep-link support from notifications: /?vcInvite=<channelId>-- try {-- const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');-- if (inviteChannel) {-- setTimeout(() => {-- acceptVcInvite(inviteChannel, 'Voice Channel');-- }, 450);-- }-- if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {-- const pending = window._pendingVcInvite;-- window._pendingVcInvite = null;-- setTimeout(() => {-- acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');-- }, 450);-- }-- } catch { }--});++// The floating chat button's visibility and the ?vcInvite= deep link moved to chat-core.js++// (2026-11-07): both belong to every page, not to the chat UI.// ── Re-authenticate chat WebSocket on login/logout ───────────// When the user logs in or registers, the navbar updates but the existing@@@ -264,6 +264,8 @@<!-- OpenVibeChat (user menus, linkify, TTS link text): chat.js needs it, as on the main page. Without it everyname clicked in the popout stayed on "Loading..." (OpenVibeChat is not defined). --><script src="/js/chat-shared.js"></script>++ <!-- chat-core.js (the settings, toast, the floating button) comes before chat.js, as on the main page. -->++ <script src="/js/chat-core.js"></script><script src="/js/chat.js"></script><script src="/js/chat-autocomplete.js"></script><script>@@@ -19,17 +19,19 @@ const ROOT = path.join(__dirname, '..', '..');// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),// "css" the render-blocking stylesheets.const BUDGETS = {-- htmlRawKB: 137.7, // 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before-- htmlBrotliKB: 30,-- jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1310.5, // 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)-- jsBrotliKB: 306.5, // 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ htmlRawKB: 138.7, // 138.7 with the chatUI feature (2026-11-07: the asset manifest carries the registry, and the registry now names the chat UI and its stubs; the chat scripts and the chat stylesheet are gone from the home page) on openvibe-shared 4.133.0; 137.9 with chat-core.js (2026-11-07: one more script tag on the home page — the part of chat every page needs; the chat UI itself leaves in the next step of the same change); 137.7 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balance); htmlBrotliKB: 30,; 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before++ jsFiles: 25, // 25 with the chat UI gone (2026-11-07: the home page no longer loads public/js/chat.js and public/js/chat-shared.js — the chat is a lazy feature now, and the routes that have chat ask for it) on openvibe-shared 4.133.0; 27 with chat-core.js (2026-11-07: one more file on the home page — the part of chat every page needs); measured 23 (incl. route scripts for "/")++ jsRawKB: 955.2, // 955.2 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (326 KB) and public/js/chat-shared.js (35 KB) left the shell, public/js/chat-core.js (19 KB, the part of chat every page needs) stayed; measured 1314.2 before); 1314.2 with chat-core.js (2026-11-07: the part of chat every page needs — toast, the settings, the floating button, the call-invite link — moved out of chat.js into public/js/chat-core.js) on openvibe-shared 4.133.0 (1310.5); 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsBrotliKB: 233.7, // 233.7 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (91.6 KB brotli) and public/js/chat-shared.js left the shell; measured 308.3 before); 308.3 with chat-core.js (2026-11-07: the part of chat every page needs moved out of chat.js) on openvibe-shared 4.133.0 (306.5); 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};// Code that must never be part of the home page's first load.const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',-- '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];++ '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css',++ // The chat UI is a lazy feature (2026-11-07): the routes that have chat ask for it, the home page++ // does not. /js/chat-core.js stays — that is the part of chat every page needs.++ '/js/chat.js', '/js/chat-shared.js', '/css/chat-shared.css'];// perf-budget's measure() fetches from a running server; this script stays offline, so it reads// what the server would render for "/" and hands check() the same measurement shape.@@@ -215,6 +215,37 @@ const pageState = `(() => ({cdp.close();}++ // 3b2. The home page carries no chat: it never loads the chat UI as a script, and the global chat++ // button brings it when someone uses it. Once the page has settled, the idle prefetch may warm it++ // (a <link rel="prefetch">, type Other, skipped on Save-Data and 2G), so the channel most people open++ // next from here has its chat cached (Opus, 11-07: the home -> channel path is the common one).++ if (!ONLY_SIGNED && !ROUTES_ONLY) {++ // Wide: under 760 px the shared app bar takes the place of the floating buttons.++ const { cdp, errors, scripts } = await openPage(1366);++ await cdp.send('Page.navigate', { url: BASE + '/' });++ // Long enough for the idle prefetch (the loader waits for the page to settle, then idles).++ await sleep(SETTLE + 6000);++ const chatScripts = scripts.filter((u) => /\/js\/chat(-shared)?\.js(\?|$)/.test(u));++ if (chatScripts.length) fail(`the home page downloaded the chat UI: ${chatScripts.map((u) => u.split('/').pop()).join(', ')}`);++ else pass('the home page does not download the chat UI');++ const fab = await cdp.evaluate(`(() => { const b = document.getElementById('floating-chat-fab'); return { visible: !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0 }; })()`);++ if (!fab.visible) fail('the floating global chat button is not on the home page');++ else {++ await cdp.evaluate(`document.getElementById('floating-chat-fab').click()`);++ await sleep(SETTLE);++ const st = await cdp.evaluate(`(() => {++ const w = document.getElementById('floating-chat-widget');++ return { open: !!w && getComputedStyle(w).display !== 'none' && w.getBoundingClientRect().width > 0 };++ })()`);++ const brought = scripts.some((u) => /\/js\/chat\.js(\?|$)/.test(u));++ if (!brought) fail('the global chat button did not bring the chat UI');++ else if (!st.open) fail('the global chat widget did not open');++ else pass('the global chat button brings the chat UI and opens the widget');++ }++ if (errors.length) fail(`errors on the home page: ${errors.slice(0, 3).join(' | ')}`);++ cdp.close();++ }++// 3c. A guest on /broadcast gets the sign-in gate, not the slot workspace, and sends no write.if (!ONLY_SIGNED && !ROUTES_ONLY) {const { cdp, errors } = await openPage(412);@@@ -3,6 +3,9 @@// links and TTS text with OpenVibeChat. The popout chat page did not (2026-10-17): every name clicked there stayed on// "Loading..." with "OpenVibeChat is not defined".//++// The main page loads the chat UI as a lazy feature (public/features.json → chatUI) since 2026-11-07, so the++// order is checked there too: the feature's own scripts run in the order they are listed.++//// node test/chat-pages-scripts.test.jsconst assert = require('assert');const fs = require('fs');@@@ -11,10 +14,20 @@ const path = require('path');const pub = path.join(__dirname, '..', 'public');const pages = fs.readdirSync(pub).filter((f) => f.endsWith('.html')).map((f) => [f, fs.readFileSync(path.join(pub, f), 'utf8')]).filter(([, html]) => /<script[^>]+src="\/js\/chat\.js[?"]/.test(html));--assert.ok(pages.some(([f]) => f === 'popout-chat.html') && pages.some(([f]) => f === 'index.html'), 'the main page and the popout load chat.js');++assert.ok(pages.some(([f]) => f === 'popout-chat.html'), 'the popout loads chat.js');++assert.ok(!pages.some(([f]) => f === 'index.html'), 'the main page does not load chat.js itself (it is a lazy feature)');for (const [file, html] of pages) {const shared = html.search(/<script[^>]+src="\/js\/chat-shared\.js[?"]/);const chat = html.search(/<script[^>]+src="\/js\/chat\.js[?"]/);assert.ok(shared >= 0 && shared < chat, `${file} loads chat-shared.js before chat.js`);}console.log(`chat pages scripts: ${pages.length} pages load chat-shared.js before chat.js`);++++// The feature that carries the chat on the main page keeps the same order.++const reg = JSON.parse(fs.readFileSync(path.join(pub, 'features.json'), 'utf8'));++const chatUi = reg.features.chatUI;++assert.ok(chatUi, 'features.json needs a chatUI feature');++const shared = chatUi.js.indexOf('/js/chat-shared.js');++const chat = chatUi.js.indexOf('/js/chat.js');++assert.ok(shared >= 0 && chat >= 0 && shared < chat, 'the chatUI feature loads chat-shared.js before chat.js');++console.log('chat pages scripts: the chatUI feature loads chat-shared.js before chat.js');nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++/**++ * The chat UI is a lazy feature (public/features.json → chatUI): a page without chat does not++ * download public/js/chat.js (340 KB) or public/js/chat-shared.js. This checks the three ways it++ * could creep back:++ *++ * 1. the home page's own HTML must not name chat.js, chat-shared.js or chat-shared.css;++ * 2. the home page never loads the chat UI: no home feature depends on it, and it is not a++ * dependency of anything the home prefetches. It is prefetched itself, by name, in++ * home.idle (Opus, 11-07): a <link rel="prefetch"> once the page has settled, so the channel++ * most people open next from home has its chat cached; it never runs on the home page;++ * 3. every inline handler (onclick="…") in the shell or in a fragment that calls a function of++ * the chat UI must be listed in the chatUI feature's stubs, or the click does nothing on a++ * page where the chat has not loaded.++ *++ * node test/chat-ui-lazy.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const PUB = path.join(ROOT, 'public');++const reg = JSON.parse(fs.readFileSync(path.join(PUB, 'features.json'), 'utf8'));++const index = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');++const chatJs = fs.readFileSync(path.join(PUB, 'js', 'chat.js'), 'utf8');++let pass = 0;++const ok = (m) => { pass++; console.log(' ok -', m); };++++const CHAT_UI = reg.features.chatUI;++assert.ok(CHAT_UI, 'features.json needs a chatUI feature');++assert.ok(CHAT_UI.js.includes('/js/chat.js') && CHAT_UI.js.includes('/js/chat-shared.js'), 'chatUI carries the chat scripts');++assert.ok(CHAT_UI.css.includes('/css/chat-shared.css'), 'chatUI carries the chat stylesheet');++assert.ok(CHAT_UI.js.indexOf('/js/chat-shared.js') < CHAT_UI.js.indexOf('/js/chat.js'), 'chat-shared.js loads before chat.js (it builds the menus chat.js shows)');++++// 1. The home page names none of it.++// (The asset manifest lists every file with its hash; only a tag counts as a request. The++// <noscript> fallbacks are not fetched by a browser with JavaScript on.)++const assets = require('../server/web/assets');++const doc = assets.document('index.html').html;++const home = assets.renderRoute(doc, '/');++const noNoscript = (html) => html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, '');++for (const gone of ['<script src="/js/chat.js', '<script src="/js/chat-shared.js', '<link rel="stylesheet" href="/css/chat-shared.css']) {++ assert.ok(!noNoscript(home).includes(gone), `the home page must not ship ${gone}`);++ assert.ok(!noNoscript(index).includes(gone), `index.html must not load ${gone} eagerly`);++}++ok('the home page ships neither the chat scripts nor the chat stylesheet');++++// The pages that have chat ask for it.++for (const [url, want] of [['/@someone', '/js/app-channel.js'], ['/chat', '/js/app-chatpage.js'], ['/broadcast', '/js/broadcast.js']]) {++ const html = assets.renderRoute(doc, url);++ assert.ok(html.includes('<script src="/js/chat.js?v='), `${url} should load the chat UI`);++ assert.ok(html.includes('<script src="/js/chat-shared.js?v='), `${url} should load chat-shared.js`);++ assert.ok(html.includes(`<script src="${want}?v=`), `${url} should load ${want}`);++ assert.ok(html.indexOf('/js/chat.js?') < html.indexOf(`${want}?`), `${url}: the chat UI comes before ${want}`);++}++ok('the channel, chat and broadcast pages load the chat UI, and it comes first');++++// 2. The home page never loads the chat UI; its idle prefetch names it once, directly.++const featureDeps = (name, seen = new Set()) => {++ if (seen.has(name)) return seen;++ seen.add(name);++ for (const d of reg.features[name].deps || []) featureDeps(d, seen);++ return seen;++};++for (const route of reg.routes) {++ if (route.path !== '^/$') continue;++ for (const f of route.features) {++ const closure = featureDeps(f);++ assert.ok(!closure.has('chatUI'), `the home route's ${f} would pull the chat UI`);++ for (const idle of reg.features[f].idle || []) {++ if (idle === 'chatUI') continue;++ const idleClosure = featureDeps(idle);++ assert.ok(!idleClosure.has('chatUI'), `the home page's idle prefetch of ${idle} would pull the chat UI as a dependency`);++ }++ }++}++assert.ok((reg.features.home.idle || []).includes('chatUI'), 'the home page warms the chat UI at idle, by name');++ok('the home page never loads the chat UI, and warms it at idle');++++// 3. Every inline handler that calls the chat UI is stubbed.++const chatFns = new Set();++for (const m of chatJs.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)) chatFns.add(m[1]);++for (const m of chatJs.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)) chatFns.add(m[1]);++const stubs = new Set(CHAT_UI.stubs || []);++const markup = [index, ...fs.readdirSync(path.join(PUB, 'fragments')).map((f) => fs.readFileSync(path.join(PUB, 'fragments', f), 'utf8'))].join('\n');++const handlers = new Set();++for (const m of markup.matchAll(/\bon(?:click|mousedown|mouseup|contextmenu|change|input|keydown|submit|focus|focusin|blur|load|error)\s*=\s*"([^"]*)"/gi)) {++ for (const h of m[1].matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) handlers.add(h[1]);++}++const missing = [...handlers].filter((h) => chatFns.has(h) && !stubs.has(h));++assert.deepStrictEqual(missing, [], `these inline handlers call the chat UI but are not stubbed: ${missing.join(', ')}`);++ok(`${handlers.size} inline handlers checked; every one that calls the chat UI is stubbed`);++++// The stubs are functions the chat UI really defines (features-registry.test.js checks this too).++for (const s of stubs) assert.ok(chatFns.has(s), `the stub ${s} is not defined by the chat UI`);++++// The chat UI is not a dependency of anything: it is named by the routes that have chat, so the++// home page's prefetch of the channel page does not carry it.++for (const [name, f] of Object.entries(reg.features)) {++ if (name === 'chatUI') continue;++ assert.ok(!(f.deps || []).includes('chatUI'), `${name} depends on the chat UI`);++}++ok('the chat UI belongs to the routes, not to another feature');++++console.log(`${pass} checks passed`);@@@ -16,9 +16,12 @@ const fs = require('fs');const path = require('path');const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');++// The settings live in chat-core.js since 2026-11-07 (the part of chat every page needs); the++// panel and the viewer branches stay with the chat itself.++const chatCore = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat-core.js'), 'utf8');--assert.match(chat, /\n {4}ttsHearWatching: false, /, 'off by default');--assert.match(chat, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');++assert.match(chatCore, /\n {4}ttsHearWatching: false, /, 'off by default');++assert.match(chatCore, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');// Both viewer branches (browser speech and server audio) need the new setting.const viewerSpeak = chat.match(/\} else if \(([^)]*?)isChatTTSEnabled\(\) && !_isOwnTtsMessage\(msg\) && _isTTSEnabledForSource\(msg\.source_platform\)\) \{\n\s*(speakTTS|playTTSAudio)\(/g) || [];@@@ -31,7 +34,7 @@ assert.ok(chat.includes('<span>Read TTS Aloud On Channels I Watch</span>') && chassert.ok(chat.includes("if (key === 'ttsHearWatching' && !chatSettings.ttsHearWatching && typeof cancelAllTTS === 'function') cancelAllTTS();"));// Not part of the synced record (Chat's chat.tts_defaults schema has no such field).--const ttsModule = chat.slice(chat.indexOf('function _ttsModuleFromSettings'), chat.indexOf('const CHAT_MODULES'));++const ttsModule = chatCore.slice(chatCore.indexOf('function _ttsModuleFromSettings'), chatCore.indexOf('const CHAT_MODULES'));assert.ok(!ttsModule.includes('ttsHearWatching'), 'local to this browser');console.log('tts watching opt-in: viewers hear TTS from the stream only, unless they turn on reading it here');
@@ -49,6 +49,9 @@ public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov
@@ -49,6 +49,9 @@ public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
public/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs
public/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs
+public/js/chat-core.js the part of chat every page needs: toast(), the chat settings, the floating chat
+ button's visibility, the ?vcInvite= deep link. The chat UI itself (public/js/chat.js)
+ belongs to the pages that have chat.
```
```
- **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's
- **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's
@@ -70,6 +73,15 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -70,6 +73,15 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
Community cannot answer, the page is 200, never a false 404.
Community cannot answer, the page is 200, never a false 404.
- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the
- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the
stub loads the feature, then calls the real function.
stub loads the feature, then calls the real function.
+- **The chat is a lazy feature.** The chat UI (`public/js/chat.js` — the socket, the messages, the
+ panels, the moderation — with `public/js/chat-shared.js` and `public/css/chat-shared.css`) is 340 KB
+ of JavaScript, so only the pages that have chat ask for it: the routes `/@…`, `/stream/…`, `/chat`
+ and `/broadcast` name the feature `chatUI` (public/features.json). It is a route feature and not a
+ dependency of `channel`, so the home page's idle prefetch of the channel page does not carry it.
+ The part of chat that every page needs — `toast()`, the chat settings, the floating chat button's
+ visibility, the `?vcInvite=` deep link — is `public/js/chat-core.js`. The chat UI's inline handlers
+ (the floating button, the chat pane's buttons) are in the feature's `stubs`, so a click before it
+ has loaded fetches it and then does the thing (`test/chat-ui-lazy.test.js`).
- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files
- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files
(`<link rel=prefetch>`, download only); a route's `idle` list is prefetched after load. Skipped on
(`<link rel=prefetch>`, download only); a route's `idle` list is prefetched after load. Skipped on
Save-Data, 2G and low-memory devices.
Save-Data, 2G and low-memory devices.
@@ -201,6 +201,49 @@ The throttled-network `--phone` profile still shows the home's first paint at 3.
@@ -201,6 +201,49 @@ The throttled-network `--phone` profile still shows the home's first paint at 3.
share the 1.6 Mbps link with ~30 deferred scripts). Real-user data (#217) is the arbiter now: compare
share the 1.6 Mbps link with ~30 deferred scripts). Real-user data (#217) is the arbiter now: compare
`live_rum_p75{page="home",device="phone"}` before restructuring the stylesheets.
`live_rum_p75{page="home",device="phone"}` before restructuring the stylesheets.
+## Measurement 2026-11-07: the chat UI is a lazy feature
+
+The chat (`public/js/chat.js`, 326 KB, plus `chat-shared.js`) used to be in the home page's shell:
+every page — the home, the dashboard, a channel — downloaded the whole chat before it painted
+anything. It is now a feature (`public/features.json` → `chatUI`) that only the routes with chat
+ask for, and `public/js/chat-core.js` (19 KB: the toast, the chat settings, the floating button,
+the `?vcInvite=` link) stays in the shell for the pages that need those.
+
+`npm run perf:budget` (the server's own render of "/", deterministic — before is `main` at 71ba7ec,
+openvibe-shared 4.133.0):
+
+| | before | after |
+|---|---:|---:|
+| home HTML | 137.7 KB (23.8 br) | 138.6 KB (24.1 br) — the asset manifest now carries the chatUI feature |
+| home JS | 26 files, 1310.5 KB (306.5 br) | **25 files, 955.2 KB (233.7 br)** |
+| home CSS | 5 files, 177.5 KB (35.4 br) | 5 files, 177.5 KB (35.4 br) — `chat-shared.css` left with the chat |
+
+What the browser asked for, on a 390×844 phone (`tools/phone-speed.js` profile, local server):
+
+| page | before | after |
+|---|---|---|
+| `/` | 41 scripts (1787.3 KB) + 13 stylesheets (862.5 KB) | 40 scripts (1432.1 KB) + 12 stylesheets (843.6 KB) — **−374 KB, −14%** |
+| `/@admin` | 50 scripts (2072.4 KB) + 13 stylesheets (815.9 KB) | 51 scripts (2076.8 KB) + 13 stylesheets (815.9 KB) — unchanged |
+
+The channel page is unchanged because the server still puts the chat in its HTML: a direct visit to
+`/@x` downloads the chat in parallel with the rest of the page, exactly as before. What moved is
+*when* it is paid for — a visit that starts on the home page no longer pays for a chat it may never
+open. The cost is on the home → channel path inside one session: the chat is downloaded when the
+channel is entered rather than at the home page, so on a cold phone the chat's first paint is about
+0.5 s later (91.6 KB of Brotli at 1.6 Mbps); a repeat visit is unaffected (the browser has it), and
+the loader's intent prefetch (hover, focus or touch on a channel link) warms it before the click.
+
+Timings on the throttled phone profile (4× CPU, 1.6 Mbps, 150 ms), two runs each, same box, both
+states on local servers: the home page's DOMContentLoaded fell from 14078/14520 ms to 11488/12219 ms
+(the page has 374 KB less JavaScript to parse — this held in every run). The LCP (the hero quip,
+which waits for `/api/home/hero`) is inside the noise band of this shared box: the runs gave
+3888/4964 ms before and 3952/15776 ms after, the second "after" run being an outlier on the API
+call, so no LCP result is claimed. The channel page's DOMContentLoaded moved 5381/5458 →
+7381/7491 ms: the chat's download moved from the home page to the channel page.
+
+`scripts/perf/check-budgets.js` now refuses to let `/js/chat.js`, `/js/chat-shared.js` or
+`/css/chat-shared.css` back onto the home page, next to the size budgets it just lowered.
+
## Budgets
## Budgets
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
@@ -1,6 +1,33 @@
@@ -1,6 +1,33 @@
{
{
"_comment": "Feature and route registry, read by server/web/assets.js (first paint: which assets the requested route needs) and by public/js/ov-loader.js (navigation: what to load before a route renders). Scripts in a feature run in the listed order, after the feature's dependencies. `fragment` names public/fragments/<name>.html, inserted into section #page-<section> before any of the feature's scripts run. `after` names a global function called once when the feature has loaded. `stubs` are global functions that inline handlers call; until the feature is loaded they load it and then call the real function. See docs/architecture.md#frontend-loading.",
"_comment": "Feature and route registry, read by server/web/assets.js (first paint: which assets the requested route needs) and by public/js/ov-loader.js (navigation: what to load before a route renders). Scripts in a feature run in the listed order, after the feature's dependencies. `fragment` names public/fragments/<name>.html, inserted into section #page-<section> before any of the feature's scripts run. `after` names a global function called once when the feature has loaded. `stubs` are global functions that inline handlers call; until the feature is loaded they load it and then call the real function. See docs/architecture.md#frontend-loading.",
"features": {
"features": {
+ "chatUI": {
+ "_comment": "The chat UI: the socket, the messages, the panels. Only the pages that have chat ask for it (the routes below name it); the part of chat every page needs is public/js/chat-core.js.",
+ "css": [
+ "/css/chat-shared.css"
+ ],
+ "js": [
+ "/js/chat-shared.js",
+ "/js/chat.js"
+ ],
+ "stubs": [
+ "sendChat",
+ "toggleChatTTS",
+ "toggleChatSettings",
+ "toggleChatUsers",
+ "toggleChatLimitsPopover",
+ "toggleMobileChat",
+ "popoutStreamChat",
+ "popoutGlobalChat",
+ "onChatModeChange",
+ "fcwToggle",
+ "fcwMinimize",
+ "fcwClose",
+ "fcwSendChat",
+ "fcwStartDrag",
+ "fcwStartResize"
+ ]
+ },
"home": {
"home": {
"fragment": "home",
"fragment": "home",
"css": [
"css": [
@@ -20,7 +47,8 @@
@@ -20,7 +47,8 @@
"idle": [
"idle": [
"guide",
"guide",
"player",
"player",
- "channel"
+ "channel",
+ "chatUI"
]
]
},
},
"feeds": {
"feeds": {
@@ -360,12 +388,14 @@
@@ -360,12 +388,14 @@
{
{
"path": "^/broadcast",
"path": "^/broadcast",
"features": [
"features": [
+ "chatUI",
"broadcast"
"broadcast"
]
]
},
},
{
{
"path": "^/chat",
"path": "^/chat",
"features": [
"features": [
+ "chatUI",
"chatpage"
"chatpage"
]
]
},
},
@@ -392,6 +422,7 @@
@@ -392,6 +422,7 @@
{
{
"path": "^/stream/",
"path": "^/stream/",
"features": [
"features": [
+ "chatUI",
"channel"
"channel"
]
]
},
},
@@ -404,6 +435,7 @@
@@ -404,6 +435,7 @@
{
{
"path": "^/@",
"path": "^/@",
"features": [
"features": [
+ "chatUI",
"channel"
"channel"
]
]
}
}
@@ -117,11 +117,13 @@
@@ -117,11 +117,13 @@
their rules sit in the cascade exactly where they sat inside style.css. -->
their rules sit in the cascade exactly where they sat inside style.css. -->
<meta name="ov-css-anchor">
<meta name="ov-css-anchor">
<link rel="stylesheet" href="/css/i18n-star.css">
<link rel="stylesheet" href="/css/i18n-star.css">
- <link rel="stylesheet" href="/css/chat-shared.css" media="print" onload="this.media='all';this.onload=null">
+ <!-- chat-shared.css belongs to the chat (features.json → chatUI): the pages that have chat ask
+ for it there, and a page without chat does not download it. -->
<link rel="stylesheet" href="/css/broadcast.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css" media="print" onload="this.media='all';this.onload=null">
<noscript>
<noscript>
+ <!-- Without JavaScript the chat never fills, but the pages that have one still show it. -->
<link rel="stylesheet" href="/css/chat-shared.css">
<link rel="stylesheet" href="/css/chat-shared.css">
<link rel="stylesheet" href="/css/broadcast.css">
<link rel="stylesheet" href="/css/broadcast.css">
<link rel="stylesheet" href="/css/cosmetics.css">
<link rel="stylesheet" href="/css/cosmetics.css">
@@ -443,11 +445,12 @@
@@ -443,11 +445,12 @@
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/js/app.js" defer></script>
<script src="/js/app.js" defer></script>
<script src="/js/ov-navbar-live.js" defer></script>
<script src="/js/ov-navbar-live.js" defer></script>
-<script src="/js/chat-shared.js" defer></script>
<script src="/js/lang-detect.js" defer></script>
<script src="/js/lang-detect.js" defer></script>
-<script src="/js/chat.js" defer></script>
+<script src="/js/chat-core.js" defer></script>
<!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->
<!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->
-<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
+<!-- ── Floating Global Chat Widget (visible on non-chat pages) ──
+ The chat UI is a lazy feature (features.json → chatUI), so the handlers below are stubs
+ until it has loaded: the first click fetches it and then does the thing. -->
<div id="floating-chat-widget" class="floating-chat-widget" style="display:none">
<div id="floating-chat-widget" class="floating-chat-widget" style="display:none">
<div class="fcw-header" onmousedown="fcwStartDrag(event)">
<div class="fcw-header" onmousedown="fcwStartDrag(event)">
<span><i class="fa-solid fa-comments"></i> Global Chat</span>
<span><i class="fa-solid fa-comments"></i> Global Chat</span>
@@ -966,7 +966,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -966,7 +966,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// Offline: join the streamer's PERSISTENT chat room (not global) so
// Offline: join the streamer's PERSISTENT chat room (not global) so
// viewers can keep chatting + see history while the streamer is offline.
// viewers can keep chatting + see history while the streamer is offline.
- initChat(null, ch.user_id);
+ withChatUi(() => initChat(null, ch.user_id));
// Hide stream tabs on offline channels
// Hide stream tabs on offline channels
const tabsC = document.getElementById('live-stream-tabs');
const tabsC = document.getElementById('live-stream-tabs');
@@ -1924,7 +1924,7 @@ function activateChannelStream(stream) {
@@ -1924,7 +1924,7 @@ function activateChannelStream(stream) {
console.error('[Player] init failed', e);
console.error('[Player] init failed', e);
}
}
}
}
- if (typeof initChat === 'function') initChat(stream.id, stream.user_id || _activeChannelUserId);
+ withChatUi(() => initChat(stream.id, stream.user_id || _activeChannelUserId));
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(stream.id);
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(stream.id);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(stream.user_id);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(stream.user_id);
startUptime(stream.started_at);
startUptime(stream.started_at);
@@ -3814,7 +3814,7 @@ async function openStream(streamId) {
@@ -3814,7 +3814,7 @@ async function openStream(streamId) {
document.getElementById('follower-count').textContent = `${s.follower_count || 0} followers`;
document.getElementById('follower-count').textContent = `${s.follower_count || 0} followers`;
if (typeof initPlayer === 'function') initPlayer(s);
if (typeof initPlayer === 'function') initPlayer(s);
- if (typeof initChat === 'function') initChat(streamId, s.user_id || _activeChannelUserId);
+ withChatUi(() => initChat(streamId, s.user_id || _activeChannelUserId));
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(streamId);
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(streamId);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(s.user_id);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(s.user_id);
loadStreamGoals(streamId);
loadStreamGoals(streamId);
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
function loadChatPage() {
function loadChatPage() {
// Connect to global chat (no streamId). initChat loads the history itself — this page used to
// Connect to global chat (no streamId). initChat loads the history itself — this page used to
// load and render all 500 rows a second time right after it.
// load and render all 500 rows a second time right after it.
- initChat(null);
+ withChatUi(() => initChat(null));
// Global chat AI overview + timeline (refreshed periodically)
// Global chat AI overview + timeline (refreshed periodically)
loadGlobalChatAi();
loadGlobalChatAi();
if (window._globalAiPollTimer) clearInterval(window._globalAiPollTimer);
if (window._globalAiPollTimer) clearInterval(window._globalAiPollTimer);
@@ -87,13 +87,25 @@ function ovUserTag(u, label) {
@@ -87,13 +87,25 @@ function ovUserTag(u, label) {
+ ` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ovUserMenu(event);}">${esc(name)}</span>`;
+ ` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ovUserMenu(event);}">${esc(name)}</span>`;
}
}
-/** Open the chat user menu; if chat has not loaded, fall back to the channel. */
+/**
+ * Open the chat user menu.
+ *
+ * The chat UI is a lazy feature (public/features.json → chatUI) and this page has no chat of its
+ * own, so the menu is not here yet: fetch the chat UI and open the same menu. If it cannot be
+ * loaded, fall back to the channel — the name is a link to it.
+ */
function ovUserMenu(event) {
function ovUserMenu(event) {
if (typeof showChatContextMenu === 'function') return showChatContextMenu(event);
if (typeof showChatContextMenu === 'function') return showChatContextMenu(event);
const el = event.currentTarget;
const el = event.currentTarget;
const core = el && el.dataset && el.dataset.coreUsername;
const core = el && el.dataset && el.dataset.coreUsername;
if (!core) return;
if (!core) return;
event.preventDefault();
event.preventDefault();
+ if (window.ov && typeof window.ov.load === 'function') {
+ window.ov.load('chatUI').then(() => {
+ if (typeof showChatContextMenu === 'function') showChatContextMenu(event);
+ }).catch(() => { navigate(`/@${core}`); });
+ return;
+ }
navigate(`/@${core}`);
navigate(`/@${core}`);
}
}
@@ -851,7 +851,7 @@ function ensureBroadcastChat(streamId) {
@@ -851,7 +851,7 @@ function ensureBroadcastChat(streamId) {
if (sidebar) sidebar.style.display = '';
if (sidebar) sidebar.style.display = '';
// The broadcast page is the signed-in streamer's own channel: say so, or chat takes it for someone else's and
// The broadcast page is the signed-in streamer's own channel: say so, or chat takes it for someone else's and
// never reads the streamer's own messages aloud (chat.js _isOwnChannelChat).
// never reads the streamer's own messages aloud (chat.js _isOwnChannelChat).
- if (typeof initChat === 'function' && streamId) initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null);
+ if (streamId) withChatUi(() => initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null));
}
}
/* ── Sign-in gate ────────────────────────────────────────────── */
/* ── Sign-in gate ────────────────────────────────────────────── */
new file mode 100644
new file mode 100644
@@ -0,0 +1,377 @@
@@ -0,0 +1,377 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Live — Chat core: the part of chat that every page needs
+ ═══════════════════════════════════════════════════════════════
+ public/js/chat.js is the chat UI — the socket, the messages, the panels, the moderation. It
+ belongs where chat is (a channel, the chat page, the broadcast desk) and nowhere else, so it is
+ a lazy feature (public/features.json → chatUI): a page without chat does not download it.
+
+ This file is the part every page does need, and nothing more:
+ - toast(): the one-line wrapper every script on the site calls;
+ - the chat settings (this browser's copy and the server's), because the floating chat button
+ reads them and the server copy is synced on every page;
+ - the floating chat button's visibility: the button sits in index.html on every page, and it
+ has to appear on the pages that have no chat of their own;
+ - the voice-call invite deep link (/?vcInvite=…), which a notification can land on any page.
+
+ Loaded before chat.js. Both are plain scripts, so they share one global scope: chat.js reads
+ chatSettings and _fcwOpen from here, and this file calls back into chat.js only by name, and
+ only when the function it needs is there.
+ ═══════════════════════════════════════════════════════════════ */
+
+/** Run now if the document is already parsed, otherwise when it is. */
+function _onReady(fn) {
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
+ else fn();
+}
+
+function toast(message, type = 'info') {
+ // Delegate to the app-level toast saved in app.js (loaded before this file).
+ // Do NOT use window.toast here — this file's own function declaration overwrites
+ // window.toast, so that check is always self-referential and causes infinite recursion.
+ if (typeof window._appToast === 'function') {
+ return window._appToast(message, type);
+ }
+ if (typeof console !== 'undefined' && console.log) {
+ console.log(`[Chat toast ${type}] ${String(message)}`);
+ }
+}
+
+// ── Chat settings (persisted to localStorage) ────────────────
+const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';
+const CHAT_SETTINGS_DEFAULTS = {
+ // Appearance
+ showTimestamps: false,
+ timestampFormat: '12h', // '12h' or '24h'
+ fontSize: 'default', // 'small', 'default', 'large'
+ showAvatars: true,
+ showBadges: true,
+ readableColors: false, // Force minimum contrast on name colors
+ alternateBackground: false, // Zebra-stripe messages
+ // Emotes
+ animatedEmotes: true, // Show animated/GIF emotes
+ emoteScale: 'default', // 'small', 'default', 'large'
+ // Behavior
+ showDeletedMessages: false, // Show <deleted> instead of hiding
+ showSystemMessages: true, // Join/part/system messages
+ mentionHighlight: true, // Highlight messages containing your @name
+ autoScroll: true, // Auto-scroll to bottom on new messages
+ autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)
+ // Notifications
+ flashOnMention: true, // Flash browser tab on @mention
+ soundOnMention: false, // Play a sound on @mention
+ autoDeclineCalls: false, // Auto-decline incoming voice calls
+ // Widget
+ showFloatingChat: true, // Show floating global chat button on non-chat pages
+ fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player
+ compactMode: false, // Compact message layout (popout & popout-compatible surfaces)
+ // Cross-feed — show messages from other sources while in a stream chat
+ showGlobalInStream: true, // Show global chat messages in stream chat (on by default)
+ showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)
+ showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)
+ // TTS
+ ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;
+ // OFF prepends "." to your messages so TTS skips them (and mutes local playback).
+ // Defaults ON so messages aren't dot-prefixed/muted-styled by default.
+ streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
+ channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live
+ ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's
+ // own audio already carries its chat TTS (the streamer plays it), and a second copy in
+ // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).
+ // This browser only: not part of the synced chat.tts_defaults record.
+ ttsVolume: 80, // TTS message volume (0–100)
+ soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
+ // Chat sounds (soundboard + channel !sound commands) — independent of TTS
+ soundsEnabled: true, // Play chat sound clips (default on)
+ // Per-source TTS — which relay platforms contribute to TTS
+ ttsSrcNative: true, // Native OpenVibe.Live chat
+ ttsSrcRS: true, // RobotStreamer relay
+ ttsSrcKick: true, // Kick relay
+ ttsSrcYoutube: true, // YouTube relay
+ ttsSrcTwitch: true, // Twitch relay
+ // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.
+ friendlyGlobal: null,
+};
+let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };
+
+const CHAT_SELF_DELETE_MINUTES = 3;
+
+function normalizeChatAutoDeleteMinutes(value) {
+ const minutes = parseInt(value, 10);
+ if (!Number.isFinite(minutes) || minutes <= 0) return 0;
+ return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);
+}
+
+let _settingsSyncTimer = null;
+
+function loadChatSettings() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));
+ if (saved) {
+ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };
+ // Migration: the TTS toggle changed from a receiver-side mute to a
+ // sender-side "prefix '.' to skip TTS" control that defaults ON. Old
+ // saved settings may carry ttsEnabled:false from the previous default,
+ // which would dot-prefix/mute every message — reset it once.
+ if (!saved.ttsSendModeV2) {
+ chatSettings.ttsEnabled = true;
+ chatSettings.ttsSendModeV2 = true;
+ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }
+ }
+ }
+ } catch { /* use defaults */ }
+ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
+ // Async server sync — merge server settings on top of local
+ _syncSettingsFromServer();
+}
+function saveChatSettings() {
+ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
+ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
+ _applyChatUi();
+ // Debounced push to server
+ _debounceSyncSettingsToServer();
+}
+
+/**
+ * The chat UI paints itself from the settings. It is not on every page (it is a lazy feature),
+ * so it is asked for by name — a page without chat simply has nothing to paint.
+ */
+function _applyChatUi() {
+ if (typeof applyChatSettings === 'function') applyChatSettings();
+ if (typeof syncSettingsPanelUI === 'function') syncSettingsPanelUI();
+ if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
+}
+
+/**
+ * Run `fn` with the chat UI in place.
+ *
+ * The chat UI (public/js/chat.js — the socket, the messages, the panels) is a lazy feature
+ * (public/features.json → chatUI): only the pages that have chat ask for it. A page that has
+ * chat but has not loaded it yet — the route's scripts and the chat's arrive side by side — asks
+ * for it here first, so the socket is never missing when the page talks to it.
+ */
+function withChatUi(fn) {
+ if (typeof initChat !== 'function' && window.ov && typeof window.ov.load === 'function') {
+ window.ov.load('chatUI').then(() => { try { fn(); } catch (e) { console.warn('[chat]', e); } }, () => { });
+ return;
+ }
+ try { fn(); } catch (e) { console.warn('[chat]', e); }
+}
+
+// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),
+// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the
+// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
+// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
+const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
+function _chatPrefsFromSettings(s) {
+ return {
+ timestamps: s.showTimestamps ? true : null,
+ compact: s.compactMode ? true : null,
+ font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,
+ show_badges: s.showBadges === false ? false : null,
+ };
+}
+function _chatSettingsFromPrefs(p) {
+ const out = {
+ showTimestamps: p.timestamps === true,
+ compactMode: p.compact === true,
+ fontSize: 'default',
+ showBadges: p.show_badges !== false,
+ };
+ if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
+ return out;
+}
+// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.
+// A field at Live's default is sent as null (removed), so the record only holds what the person changed.
+const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };
+function _ttsModuleFromSettings(s) {
+ const sources = {};
+ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;
+ const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);
+ return {
+ send: s.ttsEnabled === false ? false : null,
+ send_while_live: s.streamingTtsEnabled === false ? false : null,
+ volume: pct(s.ttsVolume),
+ sounds: s.soundsEnabled === false ? false : null,
+ sound_volume: pct(s.soundVolume),
+ sources: Object.keys(sources).length ? sources : null,
+ };
+}
+function _settingsFromTtsModule(t) {
+ const out = {
+ ttsEnabled: t.send !== false,
+ streamingTtsEnabled: t.send_while_live !== false,
+ ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,
+ soundsEnabled: t.sounds !== false,
+ soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,
+ };
+ const src = t.sources && typeof t.sources === 'object' ? t.sources : {};
+ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;
+ return out;
+}
+const CHAT_MODULES = [
+ { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },
+ { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },
+];
+function _pushChatPrefs(token) {
+ return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ [m.key]: m.from(chatSettings) }),
+ }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));
+}
+/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */
+function _syncChatPrefsFromChat(token) {
+ for (const m of CHAT_MODULES) {
+ fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ const rec = data && data[m.key];
+ if (!rec || typeof rec !== 'object') return;
+ if (!data.revision) {
+ fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});
+ return;
+ }
+ chatSettings = { ...chatSettings, ...m.to(rec) };
+ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
+ _applyChatUi();
+ })
+ .catch(() => { /* offline — use local */ });
+ }
+}
+
+function _syncSettingsFromServer() {
+ const token = localStorage.getItem('token');
+ if (!token) return;
+ fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {
+ // Server wins on conflicts
+ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };
+ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
+ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
+ _applyChatUi();
+ }
+ })
+ .catch(() => { /* offline — use local */ })
+ .finally(() => _syncChatPrefsFromChat(token));
+}
+
+function _debounceSyncSettingsToServer() {
+ if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);
+ _settingsSyncTimer = setTimeout(() => {
+ const token = localStorage.getItem('token');
+ if (!token) return;
+ fetch('/api/auth/preferences', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ chatSettings })
+ }).catch(() => { /* silent fail */ });
+ _pushChatPrefs(token);
+ }, 500);
+}
+
+// ── Floating chat widget state ───────────────────────────────
+let _fcwOpen = false;
+
+/** Show/hide the floating chat FAB on non-chat pages */
+function _fcwUpdateVisibility() {
+ const fab = document.getElementById('floating-chat-fab');
+ const widget = document.getElementById('floating-chat-widget');
+ const disabled = chatSettings && chatSettings.showFloatingChat === false;
+
+ // Hide on the global chat page (already has full chat)
+ const chatPage = document.getElementById('page-chat');
+ const isOnChatPage = chatPage && chatPage.classList.contains('active');
+
+ // Hide on live stream / channel pages (already have chat sidebar)
+ const streamPage = document.getElementById('page-stream');
+ const channelPage = document.getElementById('page-channel');
+ const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))
+ || (channelPage && channelPage.classList.contains('active'));
+
+ // Hide on broadcast page (has its own chat panel)
+ const broadcastPage = document.getElementById('page-broadcast');
+ const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');
+
+ if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {
+ if (fab) fab.style.display = 'none';
+ if (widget) widget.style.display = 'none';
+ _fcwOpen = false;
+ } else {
+ // Not on chat/stream page — show FAB
+ if (fab) fab.style.display = '';
+ if (!_fcwOpen && widget) widget.style.display = 'none';
+ }
+}
+
+// The floating chat button follows the page: it shows on the pages that have no chat of their own.
+// (The router announces page changes; this replaces one MutationObserver per page section.)
+_onReady(() => {
+ document.addEventListener('ov:page', _fcwUpdateVisibility);
+ // Initial check
+ setTimeout(_fcwUpdateVisibility, 100);
+});
+
+async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {
+ if (!channelId) return;
+
+ if (switchToChat && typeof showPage === 'function') {
+ showPage('chat');
+ }
+
+ // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.
+ if (typeof vcJoinChannel !== 'function' && window.ov) {
+ try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }
+ }
+ if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {
+ window._pendingVcInvite = { channelId, channelName, at: Date.now() };
+ toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');
+ return;
+ }
+
+ await vcFetchChannels();
+ const ch = (vcState.channels || []).find(c => c.id === channelId);
+ if (!ch) {
+ toast('That call channel is no longer available', 'error');
+ return;
+ }
+ if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
+ return;
+ }
+ // Through the same door as a click: leaves whatever channel we are in first.
+ vcSelectChannel(ch.id);
+}
+
+async function acceptVcInvite(channelId, channelName) {
+ try {
+ // The incoming-call overlay belongs to the chat UI; on a page without chat there is none.
+ if (typeof window._dismissIncomingVcCallOverlay === 'function') window._dismissIncomingVcCallOverlay();
+ await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);
+ } catch (err) {
+ toast(err.message || 'Failed to join call invite', 'error');
+ }
+}
+
+/**
+ * Deep-link support from notifications: /?vcInvite=<channelId>.
+ * The invite lands on whatever page the person was on, so the join happens here rather than in
+ * the chat UI. A pending invite (the voice feature was not there yet) waits for the chat page.
+ */
+_onReady(() => {
+ try {
+ const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');
+ if (inviteChannel) {
+ setTimeout(() => {
+ acceptVcInvite(inviteChannel, 'Voice Channel');
+ }, 450);
+ }
+ if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {
+ const pending = window._pendingVcInvite;
+ window._pendingVcInvite = null;
+ setTimeout(() => {
+ acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');
+ }, 450);
+ }
+ } catch { }
+});
@@ -11,23 +11,12 @@ let chatRenderTargetId = null;
@@ -11,23 +11,12 @@ let chatRenderTargetId = null;
// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.js
// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.js
const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);
const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);
-function toast(message, type = 'info') {
- // Delegate to the app-level toast saved in app.js (loaded before this file).
- // Do NOT use window.toast here — chat.js's own function declaration overwrites
- // window.toast, so that check is always self-referential and causes infinite recursion.
- if (typeof window._appToast === 'function') {
- return window._appToast(message, type);
- }
- if (typeof console !== 'undefined' && console.log) {
- console.log(`[Chat toast ${type}] ${String(message)}`);
- }
-}
+// ── toast() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
// ── Chat mode (Global / Voice Call) ──────────────────────────
// ── Chat mode (Global / Voice Call) ──────────────────────────
let chatMode = 'global'; // 'global' or 'voice'
let chatMode = 'global'; // 'global' or 'voice'
-// ── Floating chat widget state ───────────────────────────────
-let _fcwOpen = false;
+// ── the floating chat button state moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let _fcwUnread = 0;
let _fcwUnread = 0;
let _fcwDragging = false;
let _fcwDragging = false;
let _fcwDragOffset = { x: 0, y: 0 };
let _fcwDragOffset = { x: 0, y: 0 };
@@ -98,64 +87,9 @@ function relayBadgeHTML(platform) {
@@ -98,64 +87,9 @@ function relayBadgeHTML(platform) {
return `<span class="chat-badge chat-relay-badge" data-tip="${b.label}" style="--relay-color:${b.color}"><i class="${b.icon}"></i></span>`;
return `<span class="chat-badge chat-relay-badge" data-tip="${b.label}" style="--relay-color:${b.color}"><i class="${b.icon}"></i></span>`;
}
}
-// ── Chat settings (persisted to localStorage) ────────────────
-const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';
-const CHAT_SETTINGS_DEFAULTS = {
- // Appearance
- showTimestamps: false,
- timestampFormat: '12h', // '12h' or '24h'
- fontSize: 'default', // 'small', 'default', 'large'
- showAvatars: true,
- showBadges: true,
- readableColors: false, // Force minimum contrast on name colors
- alternateBackground: false, // Zebra-stripe messages
- // Emotes
- animatedEmotes: true, // Show animated/GIF emotes
- emoteScale: 'default', // 'small', 'default', 'large'
- // Behavior
- showDeletedMessages: false, // Show <deleted> instead of hiding
- showSystemMessages: true, // Join/part/system messages
- mentionHighlight: true, // Highlight messages containing your @name
- autoScroll: true, // Auto-scroll to bottom on new messages
- autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)
- // Notifications
- flashOnMention: true, // Flash browser tab on @mention
- soundOnMention: false, // Play a sound on @mention
- autoDeclineCalls: false, // Auto-decline incoming voice calls
- // Widget
- showFloatingChat: true, // Show floating global chat button on non-chat pages
- fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player
- compactMode: false, // Compact message layout (popout & popout-compatible surfaces)
- // Cross-feed — show messages from other sources while in a stream chat
- showGlobalInStream: true, // Show global chat messages in stream chat (on by default)
- showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)
- showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)
- // TTS
- ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;
- // OFF prepends "." to your messages so TTS skips them (and mutes local playback).
- // Defaults ON so messages aren't dot-prefixed/muted-styled by default.
- streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
- channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live
- ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's
- // own audio already carries its chat TTS (the streamer plays it), and a second copy in
- // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).
- // This browser only: not part of the synced chat.tts_defaults record.
- ttsVolume: 80, // TTS message volume (0–100)
- soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
- // Chat sounds (soundboard + channel !sound commands) — independent of TTS
- soundsEnabled: true, // Play chat sound clips (default on)
- // Per-source TTS — which relay platforms contribute to TTS
- ttsSrcNative: true, // Native OpenVibe.Live chat
- ttsSrcRS: true, // RobotStreamer relay
- ttsSrcKick: true, // Kick relay
- ttsSrcYoutube: true, // YouTube relay
- ttsSrcTwitch: true, // Twitch relay
- // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.
- friendlyGlobal: null,
-};
-let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };
+// ── the chat settings moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let chatSettingsPanelOpen = false;
let chatSettingsPanelOpen = false;
-const CHAT_SELF_DELETE_MINUTES = 3;
+// ── CHAT_SELF_DELETE_MINUTES moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let chatSelfDeletePolicy = {
let chatSelfDeletePolicy = {
allowAutoDelete: true,
allowAutoDelete: true,
allowDeleteAll: true,
allowDeleteAll: true,
@@ -193,11 +127,7 @@ const MAIN_CHAT_MAX_MESSAGES = 500;
@@ -193,11 +127,7 @@ const MAIN_CHAT_MAX_MESSAGES = 500;
let _fullscreenChatIdleTimer = null;
let _fullscreenChatIdleTimer = null;
let _fullscreenChatRecent = [];
let _fullscreenChatRecent = [];
-function normalizeChatAutoDeleteMinutes(value) {
- const minutes = parseInt(value, 10);
- if (!Number.isFinite(minutes) || minutes <= 0) return 0;
- return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);
-}
+// ── normalizeChatAutoDeleteMinutes() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function canUseViewerAutoDeleteHere() {
function canUseViewerAutoDeleteHere() {
return !chatStreamId || !!chatSelfDeletePolicy.allowAutoDelete;
return !chatStreamId || !!chatSelfDeletePolicy.allowAutoDelete;
@@ -207,153 +137,9 @@ function canUseViewerDeleteAllHere() {
@@ -207,153 +137,9 @@ function canUseViewerDeleteAllHere() {
return !chatStreamId || !!chatSelfDeletePolicy.allowDeleteAll;
return !chatStreamId || !!chatSelfDeletePolicy.allowDeleteAll;
}
}
-let _settingsSyncTimer = null;
-
-function loadChatSettings() {
- try {
- const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));
- if (saved) {
- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };
- // Migration: the TTS toggle changed from a receiver-side mute to a
- // sender-side "prefix '.' to skip TTS" control that defaults ON. Old
- // saved settings may carry ttsEnabled:false from the previous default,
- // which would dot-prefix/mute every message — reset it once.
- if (!saved.ttsSendModeV2) {
- chatSettings.ttsEnabled = true;
- chatSettings.ttsSendModeV2 = true;
- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }
- }
- }
- } catch { /* use defaults */ }
- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
- // Async server sync — merge server settings on top of local
- _syncSettingsFromServer();
-}
-function saveChatSettings() {
- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
- applyChatSettings();
- // Debounced push to server
- _debounceSyncSettingsToServer();
-}
+// ── the settings sync timer moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
-// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),
-// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the
-// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
-// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
-const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
-function _chatPrefsFromSettings(s) {
- return {
- timestamps: s.showTimestamps ? true : null,
- compact: s.compactMode ? true : null,
- font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,
- show_badges: s.showBadges === false ? false : null,
- };
-}
-function _chatSettingsFromPrefs(p) {
- const out = {
- showTimestamps: p.timestamps === true,
- compactMode: p.compact === true,
- fontSize: 'default',
- showBadges: p.show_badges !== false,
- };
- if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
- return out;
-}
-// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.
-// A field at Live's default is sent as null (removed), so the record only holds what the person changed.
-const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };
-function _ttsModuleFromSettings(s) {
- const sources = {};
- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;
- const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);
- return {
- send: s.ttsEnabled === false ? false : null,
- send_while_live: s.streamingTtsEnabled === false ? false : null,
- volume: pct(s.ttsVolume),
- sounds: s.soundsEnabled === false ? false : null,
- sound_volume: pct(s.soundVolume),
- sources: Object.keys(sources).length ? sources : null,
- };
-}
-function _settingsFromTtsModule(t) {
- const out = {
- ttsEnabled: t.send !== false,
- streamingTtsEnabled: t.send_while_live !== false,
- ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,
- soundsEnabled: t.sounds !== false,
- soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,
- };
- const src = t.sources && typeof t.sources === 'object' ? t.sources : {};
- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;
- return out;
-}
-const CHAT_MODULES = [
- { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },
- { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },
-];
-function _pushChatPrefs(token) {
- return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
- body: JSON.stringify({ [m.key]: m.from(chatSettings) }),
- }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));
-}
-/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */
-function _syncChatPrefsFromChat(token) {
- for (const m of CHAT_MODULES) {
- fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- const rec = data && data[m.key];
- if (!rec || typeof rec !== 'object') return;
- if (!data.revision) {
- fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});
- return;
- }
- chatSettings = { ...chatSettings, ...m.to(rec) };
- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
- applyChatSettings();
- syncSettingsPanelUI();
- if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
- })
- .catch(() => { /* offline — use local */ });
- }
-}
-
-function _syncSettingsFromServer() {
- const token = localStorage.getItem('token');
- if (!token) return;
- fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {
- // Server wins on conflicts
- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };
- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
- applyChatSettings();
- syncSettingsPanelUI();
- syncTTSToggleButtons();
- }
- })
- .catch(() => { /* offline — use local */ })
- .finally(() => _syncChatPrefsFromChat(token));
-}
-
-function _debounceSyncSettingsToServer() {
- if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);
- _settingsSyncTimer = setTimeout(() => {
- const token = localStorage.getItem('token');
- if (!token) return;
- fetch('/api/auth/preferences', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
- body: JSON.stringify({ chatSettings })
- }).catch(() => { /* silent fail */ });
- _pushChatPrefs(token);
- }, 500);
-}
+// ── the settings functions moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function _vcEnsureAudioContext() {
function _vcEnsureAudioContext() {
const Ctx = window.AudioContext || window.webkitAudioContext;
const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -4275,44 +4061,7 @@ async function ctxCallUser(username, userId) {
@@ -4275,44 +4061,7 @@ async function ctxCallUser(username, userId) {
}
}
}
}
-async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {
- if (!channelId) return;
-
- if (switchToChat && typeof showPage === 'function') {
- showPage('chat');
- }
-
- // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.
- if (typeof vcJoinChannel !== 'function' && window.ov) {
- try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }
- }
- if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {
- window._pendingVcInvite = { channelId, channelName, at: Date.now() };
- toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');
- return;
- }
-
- await vcFetchChannels();
- const ch = (vcState.channels || []).find(c => c.id === channelId);
- if (!ch) {
- toast('That call channel is no longer available', 'error');
- return;
- }
- if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
- return;
- }
- // Through the same door as a click: leaves whatever channel we are in first.
- vcSelectChannel(ch.id);
-}
-
-async function acceptVcInvite(channelId, channelName) {
- try {
- _dismissIncomingVcCallOverlay();
- await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);
- } catch (err) {
- toast(err.message || 'Failed to join call invite', 'error');
- }
-}
+// ── the voice-call invite join moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function ctxViewChannel(username) {
function ctxViewChannel(username) {
dismissContextMenu();
dismissContextMenu();
@@ -6404,36 +6153,7 @@ function updateChatModeVoiceOption(enabled) {
@@ -6404,36 +6153,7 @@ function updateChatModeVoiceOption(enabled) {
FLOATING CHAT WIDGET
FLOATING CHAT WIDGET
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-/** Show/hide the floating chat FAB on non-chat pages */
-function _fcwUpdateVisibility() {
- const fab = document.getElementById('floating-chat-fab');
- const widget = document.getElementById('floating-chat-widget');
- const disabled = chatSettings && chatSettings.showFloatingChat === false;
-
- // Hide on the global chat page (already has full chat)
- const chatPage = document.getElementById('page-chat');
- const isOnChatPage = chatPage && chatPage.classList.contains('active');
-
- // Hide on live stream / channel pages (already have chat sidebar)
- const streamPage = document.getElementById('page-stream');
- const channelPage = document.getElementById('page-channel');
- const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))
- || (channelPage && channelPage.classList.contains('active'));
-
- // Hide on broadcast page (has its own chat panel)
- const broadcastPage = document.getElementById('page-broadcast');
- const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');
-
- if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {
- if (fab) fab.style.display = 'none';
- if (widget) widget.style.display = 'none';
- _fcwOpen = false;
- } else {
- // Not on chat/stream page — show FAB
- if (fab) fab.style.display = '';
- if (!_fcwOpen && widget) widget.style.display = 'none';
- }
-}
+// ── _fcwUpdateVisibility() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function fcwToggle() {
function fcwToggle() {
_fcwOpen = !_fcwOpen;
_fcwOpen = !_fcwOpen;
@@ -6668,37 +6388,16 @@ function _fcwStopResize() {
@@ -6668,37 +6388,16 @@ function _fcwStopResize() {
// Page markup that arrives later (ov-loader.js fragments) gets the same one-time setup the
// Page markup that arrives later (ov-loader.js fragments) gets the same one-time setup the
// boot-time markup got: font-size classes and scroll containment, GIF buttons.
// boot-time markup got: font-size classes and scroll containment, GIF buttons.
+// (The GIF pickers initialise when the chat extras load — features.json → chatExtras.after —
+// not here: calling them on every page fetched /api/chat/gif/providers on every page, the home
+// page included.)
document.addEventListener('ov:fragment', () => {
document.addEventListener('ov:fragment', () => {
try { applyChatSettings(); } catch { /* */ }
try { applyChatSettings(); } catch { /* */ }
if (typeof initGifPickers === 'function') { try { initGifPickers(); } catch { /* */ } }
if (typeof initGifPickers === 'function') { try { initGifPickers(); } catch { /* */ } }
});
});
-// Hook into page navigation to show/hide FAB
-document.addEventListener('DOMContentLoaded', () => {
- // GIF pickers initialise when the chat extras load (features.json → chatExtras.after); calling
- // it here fetched /api/chat/gif/providers on every page, the home page included.
- // The router announces page changes; this replaces one MutationObserver per page section.
- document.addEventListener('ov:page', _fcwUpdateVisibility);
- // Initial check
- setTimeout(_fcwUpdateVisibility, 100);
-
- // Deep-link support from notifications: /?vcInvite=<channelId>
- try {
- const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');
- if (inviteChannel) {
- setTimeout(() => {
- acceptVcInvite(inviteChannel, 'Voice Channel');
- }, 450);
- }
- if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {
- const pending = window._pendingVcInvite;
- window._pendingVcInvite = null;
- setTimeout(() => {
- acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');
- }, 450);
- }
- } catch { }
-});
+// The floating chat button's visibility and the ?vcInvite= deep link moved to chat-core.js
+// (2026-11-07): both belong to every page, not to the chat UI.
// ── Re-authenticate chat WebSocket on login/logout ───────────
// ── Re-authenticate chat WebSocket on login/logout ───────────
// When the user logs in or registers, the navbar updates but the existing
// When the user logs in or registers, the navbar updates but the existing
@@ -264,6 +264,8 @@
@@ -264,6 +264,8 @@
<!-- OpenVibeChat (user menus, linkify, TTS link text): chat.js needs it, as on the main page. Without it every
<!-- OpenVibeChat (user menus, linkify, TTS link text): chat.js needs it, as on the main page. Without it every
name clicked in the popout stayed on "Loading..." (OpenVibeChat is not defined). -->
name clicked in the popout stayed on "Loading..." (OpenVibeChat is not defined). -->
<script src="/js/chat-shared.js"></script>
<script src="/js/chat-shared.js"></script>
+ <!-- chat-core.js (the settings, toast, the floating button) comes before chat.js, as on the main page. -->
+ <script src="/js/chat-core.js"></script>
<script src="/js/chat.js"></script>
<script src="/js/chat.js"></script>
<script src="/js/chat-autocomplete.js"></script>
<script src="/js/chat-autocomplete.js"></script>
<script>
<script>
@@ -19,17 +19,19 @@ const ROOT = path.join(__dirname, '..', '..');
@@ -19,17 +19,19 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
// "css" the render-blocking stylesheets.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 137.7, // 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
- htmlBrotliKB: 30,
- jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1310.5, // 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
- jsBrotliKB: 306.5, // 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ htmlRawKB: 138.7, // 138.7 with the chatUI feature (2026-11-07: the asset manifest carries the registry, and the registry now names the chat UI and its stubs; the chat scripts and the chat stylesheet are gone from the home page) on openvibe-shared 4.133.0; 137.9 with chat-core.js (2026-11-07: one more script tag on the home page — the part of chat every page needs; the chat UI itself leaves in the next step of the same change); 137.7 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balance); htmlBrotliKB: 30,; 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
+ jsFiles: 25, // 25 with the chat UI gone (2026-11-07: the home page no longer loads public/js/chat.js and public/js/chat-shared.js — the chat is a lazy feature now, and the routes that have chat ask for it) on openvibe-shared 4.133.0; 27 with chat-core.js (2026-11-07: one more file on the home page — the part of chat every page needs); measured 23 (incl. route scripts for "/")
+ jsRawKB: 955.2, // 955.2 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (326 KB) and public/js/chat-shared.js (35 KB) left the shell, public/js/chat-core.js (19 KB, the part of chat every page needs) stayed; measured 1314.2 before); 1314.2 with chat-core.js (2026-11-07: the part of chat every page needs — toast, the settings, the floating button, the call-invite link — moved out of chat.js into public/js/chat-core.js) on openvibe-shared 4.133.0 (1310.5); 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsBrotliKB: 233.7, // 233.7 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (91.6 KB brotli) and public/js/chat-shared.js left the shell; measured 308.3 before); 308.3 with chat-core.js (2026-11-07: the part of chat every page needs moved out of chat.js) on openvibe-shared 4.133.0 (306.5); 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
// Code that must never be part of the home page's first load.
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
- '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
+ '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css',
+ // The chat UI is a lazy feature (2026-11-07): the routes that have chat ask for it, the home page
+ // does not. /js/chat-core.js stays — that is the part of chat every page needs.
+ '/js/chat.js', '/js/chat-shared.js', '/css/chat-shared.css'];
// perf-budget's measure() fetches from a running server; this script stays offline, so it reads
// perf-budget's measure() fetches from a running server; this script stays offline, so it reads
// what the server would render for "/" and hands check() the same measurement shape.
// what the server would render for "/" and hands check() the same measurement shape.
@@ -215,6 +215,37 @@ const pageState = `(() => ({
@@ -215,6 +215,37 @@ const pageState = `(() => ({
cdp.close();
cdp.close();
}
}
+ // 3b2. The home page carries no chat: it never loads the chat UI as a script, and the global chat
+ // button brings it when someone uses it. Once the page has settled, the idle prefetch may warm it
+ // (a <link rel="prefetch">, type Other, skipped on Save-Data and 2G), so the channel most people open
+ // next from here has its chat cached (Opus, 11-07: the home -> channel path is the common one).
+ if (!ONLY_SIGNED && !ROUTES_ONLY) {
+ // Wide: under 760 px the shared app bar takes the place of the floating buttons.
+ const { cdp, errors, scripts } = await openPage(1366);
+ await cdp.send('Page.navigate', { url: BASE + '/' });
+ // Long enough for the idle prefetch (the loader waits for the page to settle, then idles).
+ await sleep(SETTLE + 6000);
+ const chatScripts = scripts.filter((u) => /\/js\/chat(-shared)?\.js(\?|$)/.test(u));
+ if (chatScripts.length) fail(`the home page downloaded the chat UI: ${chatScripts.map((u) => u.split('/').pop()).join(', ')}`);
+ else pass('the home page does not download the chat UI');
+ const fab = await cdp.evaluate(`(() => { const b = document.getElementById('floating-chat-fab'); return { visible: !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0 }; })()`);
+ if (!fab.visible) fail('the floating global chat button is not on the home page');
+ else {
+ await cdp.evaluate(`document.getElementById('floating-chat-fab').click()`);
+ await sleep(SETTLE);
+ const st = await cdp.evaluate(`(() => {
+ const w = document.getElementById('floating-chat-widget');
+ return { open: !!w && getComputedStyle(w).display !== 'none' && w.getBoundingClientRect().width > 0 };
+ })()`);
+ const brought = scripts.some((u) => /\/js\/chat\.js(\?|$)/.test(u));
+ if (!brought) fail('the global chat button did not bring the chat UI');
+ else if (!st.open) fail('the global chat widget did not open');
+ else pass('the global chat button brings the chat UI and opens the widget');
+ }
+ if (errors.length) fail(`errors on the home page: ${errors.slice(0, 3).join(' | ')}`);
+ cdp.close();
+ }
+
// 3c. A guest on /broadcast gets the sign-in gate, not the slot workspace, and sends no write.
// 3c. A guest on /broadcast gets the sign-in gate, not the slot workspace, and sends no write.
if (!ONLY_SIGNED && !ROUTES_ONLY) {
if (!ONLY_SIGNED && !ROUTES_ONLY) {
const { cdp, errors } = await openPage(412);
const { cdp, errors } = await openPage(412);
@@ -3,6 +3,9 @@
@@ -3,6 +3,9 @@
// links and TTS text with OpenVibeChat. The popout chat page did not (2026-10-17): every name clicked there stayed on
// links and TTS text with OpenVibeChat. The popout chat page did not (2026-10-17): every name clicked there stayed on
// "Loading..." with "OpenVibeChat is not defined".
// "Loading..." with "OpenVibeChat is not defined".
//
//
+// The main page loads the chat UI as a lazy feature (public/features.json → chatUI) since 2026-11-07, so the
+// order is checked there too: the feature's own scripts run in the order they are listed.
+//
// node test/chat-pages-scripts.test.js
// node test/chat-pages-scripts.test.js
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -11,10 +14,20 @@ const path = require('path');
@@ -11,10 +14,20 @@ const path = require('path');
const pub = path.join(__dirname, '..', 'public');
const pub = path.join(__dirname, '..', 'public');
const pages = fs.readdirSync(pub).filter((f) => f.endsWith('.html')).map((f) => [f, fs.readFileSync(path.join(pub, f), 'utf8')])
const pages = fs.readdirSync(pub).filter((f) => f.endsWith('.html')).map((f) => [f, fs.readFileSync(path.join(pub, f), 'utf8')])
.filter(([, html]) => /<script[^>]+src="\/js\/chat\.js[?"]/.test(html));
.filter(([, html]) => /<script[^>]+src="\/js\/chat\.js[?"]/.test(html));
-assert.ok(pages.some(([f]) => f === 'popout-chat.html') && pages.some(([f]) => f === 'index.html'), 'the main page and the popout load chat.js');
+assert.ok(pages.some(([f]) => f === 'popout-chat.html'), 'the popout loads chat.js');
+assert.ok(!pages.some(([f]) => f === 'index.html'), 'the main page does not load chat.js itself (it is a lazy feature)');
for (const [file, html] of pages) {
for (const [file, html] of pages) {
const shared = html.search(/<script[^>]+src="\/js\/chat-shared\.js[?"]/);
const shared = html.search(/<script[^>]+src="\/js\/chat-shared\.js[?"]/);
const chat = html.search(/<script[^>]+src="\/js\/chat\.js[?"]/);
const chat = html.search(/<script[^>]+src="\/js\/chat\.js[?"]/);
assert.ok(shared >= 0 && shared < chat, `${file} loads chat-shared.js before chat.js`);
assert.ok(shared >= 0 && shared < chat, `${file} loads chat-shared.js before chat.js`);
}
}
console.log(`chat pages scripts: ${pages.length} pages load chat-shared.js before chat.js`);
console.log(`chat pages scripts: ${pages.length} pages load chat-shared.js before chat.js`);
+
+// The feature that carries the chat on the main page keeps the same order.
+const reg = JSON.parse(fs.readFileSync(path.join(pub, 'features.json'), 'utf8'));
+const chatUi = reg.features.chatUI;
+assert.ok(chatUi, 'features.json needs a chatUI feature');
+const shared = chatUi.js.indexOf('/js/chat-shared.js');
+const chat = chatUi.js.indexOf('/js/chat.js');
+assert.ok(shared >= 0 && chat >= 0 && shared < chat, 'the chatUI feature loads chat-shared.js before chat.js');
+console.log('chat pages scripts: the chatUI feature loads chat-shared.js before chat.js');
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+/**
+ * The chat UI is a lazy feature (public/features.json → chatUI): a page without chat does not
+ * download public/js/chat.js (340 KB) or public/js/chat-shared.js. This checks the three ways it
+ * could creep back:
+ *
+ * 1. the home page's own HTML must not name chat.js, chat-shared.js or chat-shared.css;
+ * 2. the home page never loads the chat UI: no home feature depends on it, and it is not a
+ * dependency of anything the home prefetches. It is prefetched itself, by name, in
+ * home.idle (Opus, 11-07): a <link rel="prefetch"> once the page has settled, so the channel
+ * most people open next from home has its chat cached; it never runs on the home page;
+ * 3. every inline handler (onclick="…") in the shell or in a fragment that calls a function of
+ * the chat UI must be listed in the chatUI feature's stubs, or the click does nothing on a
+ * page where the chat has not loaded.
+ *
+ * node test/chat-ui-lazy.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const PUB = path.join(ROOT, 'public');
+const reg = JSON.parse(fs.readFileSync(path.join(PUB, 'features.json'), 'utf8'));
+const index = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');
+const chatJs = fs.readFileSync(path.join(PUB, 'js', 'chat.js'), 'utf8');
+let pass = 0;
+const ok = (m) => { pass++; console.log(' ok -', m); };
+
+const CHAT_UI = reg.features.chatUI;
+assert.ok(CHAT_UI, 'features.json needs a chatUI feature');
+assert.ok(CHAT_UI.js.includes('/js/chat.js') && CHAT_UI.js.includes('/js/chat-shared.js'), 'chatUI carries the chat scripts');
+assert.ok(CHAT_UI.css.includes('/css/chat-shared.css'), 'chatUI carries the chat stylesheet');
+assert.ok(CHAT_UI.js.indexOf('/js/chat-shared.js') < CHAT_UI.js.indexOf('/js/chat.js'), 'chat-shared.js loads before chat.js (it builds the menus chat.js shows)');
+
+// 1. The home page names none of it.
+// (The asset manifest lists every file with its hash; only a tag counts as a request. The
+// <noscript> fallbacks are not fetched by a browser with JavaScript on.)
+const assets = require('../server/web/assets');
+const doc = assets.document('index.html').html;
+const home = assets.renderRoute(doc, '/');
+const noNoscript = (html) => html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, '');
+for (const gone of ['<script src="/js/chat.js', '<script src="/js/chat-shared.js', '<link rel="stylesheet" href="/css/chat-shared.css']) {
+ assert.ok(!noNoscript(home).includes(gone), `the home page must not ship ${gone}`);
+ assert.ok(!noNoscript(index).includes(gone), `index.html must not load ${gone} eagerly`);
+}
+ok('the home page ships neither the chat scripts nor the chat stylesheet');
+
+// The pages that have chat ask for it.
+for (const [url, want] of [['/@someone', '/js/app-channel.js'], ['/chat', '/js/app-chatpage.js'], ['/broadcast', '/js/broadcast.js']]) {
+ const html = assets.renderRoute(doc, url);
+ assert.ok(html.includes('<script src="/js/chat.js?v='), `${url} should load the chat UI`);
+ assert.ok(html.includes('<script src="/js/chat-shared.js?v='), `${url} should load chat-shared.js`);
+ assert.ok(html.includes(`<script src="${want}?v=`), `${url} should load ${want}`);
+ assert.ok(html.indexOf('/js/chat.js?') < html.indexOf(`${want}?`), `${url}: the chat UI comes before ${want}`);
+}
+ok('the channel, chat and broadcast pages load the chat UI, and it comes first');
+
+// 2. The home page never loads the chat UI; its idle prefetch names it once, directly.
+const featureDeps = (name, seen = new Set()) => {
+ if (seen.has(name)) return seen;
+ seen.add(name);
+ for (const d of reg.features[name].deps || []) featureDeps(d, seen);
+ return seen;
+};
+for (const route of reg.routes) {
+ if (route.path !== '^/$') continue;
+ for (const f of route.features) {
+ const closure = featureDeps(f);
+ assert.ok(!closure.has('chatUI'), `the home route's ${f} would pull the chat UI`);
+ for (const idle of reg.features[f].idle || []) {
+ if (idle === 'chatUI') continue;
+ const idleClosure = featureDeps(idle);
+ assert.ok(!idleClosure.has('chatUI'), `the home page's idle prefetch of ${idle} would pull the chat UI as a dependency`);
+ }
+ }
+}
+assert.ok((reg.features.home.idle || []).includes('chatUI'), 'the home page warms the chat UI at idle, by name');
+ok('the home page never loads the chat UI, and warms it at idle');
+
+// 3. Every inline handler that calls the chat UI is stubbed.
+const chatFns = new Set();
+for (const m of chatJs.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)) chatFns.add(m[1]);
+for (const m of chatJs.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)) chatFns.add(m[1]);
+const stubs = new Set(CHAT_UI.stubs || []);
+const markup = [index, ...fs.readdirSync(path.join(PUB, 'fragments')).map((f) => fs.readFileSync(path.join(PUB, 'fragments', f), 'utf8'))].join('\n');
+const handlers = new Set();
+for (const m of markup.matchAll(/\bon(?:click|mousedown|mouseup|contextmenu|change|input|keydown|submit|focus|focusin|blur|load|error)\s*=\s*"([^"]*)"/gi)) {
+ for (const h of m[1].matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) handlers.add(h[1]);
+}
+const missing = [...handlers].filter((h) => chatFns.has(h) && !stubs.has(h));
+assert.deepStrictEqual(missing, [], `these inline handlers call the chat UI but are not stubbed: ${missing.join(', ')}`);
+ok(`${handlers.size} inline handlers checked; every one that calls the chat UI is stubbed`);
+
+// The stubs are functions the chat UI really defines (features-registry.test.js checks this too).
+for (const s of stubs) assert.ok(chatFns.has(s), `the stub ${s} is not defined by the chat UI`);
+
+// The chat UI is not a dependency of anything: it is named by the routes that have chat, so the
+// home page's prefetch of the channel page does not carry it.
+for (const [name, f] of Object.entries(reg.features)) {
+ if (name === 'chatUI') continue;
+ assert.ok(!(f.deps || []).includes('chatUI'), `${name} depends on the chat UI`);
+}
+ok('the chat UI belongs to the routes, not to another feature');
+
+console.log(`${pass} checks passed`);
@@ -16,9 +16,12 @@ const fs = require('fs');
@@ -16,9 +16,12 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
+// The settings live in chat-core.js since 2026-11-07 (the part of chat every page needs); the
+// panel and the viewer branches stay with the chat itself.
+const chatCore = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat-core.js'), 'utf8');
-assert.match(chat, /\n {4}ttsHearWatching: false, /, 'off by default');
-assert.match(chat, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');
+assert.match(chatCore, /\n {4}ttsHearWatching: false, /, 'off by default');
+assert.match(chatCore, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');
// Both viewer branches (browser speech and server audio) need the new setting.
// Both viewer branches (browser speech and server audio) need the new setting.
const viewerSpeak = chat.match(/\} else if \(([^)]*?)isChatTTSEnabled\(\) && !_isOwnTtsMessage\(msg\) && _isTTSEnabledForSource\(msg\.source_platform\)\) \{\n\s*(speakTTS|playTTSAudio)\(/g) || [];
const viewerSpeak = chat.match(/\} else if \(([^)]*?)isChatTTSEnabled\(\) && !_isOwnTtsMessage\(msg\) && _isTTSEnabledForSource\(msg\.source_platform\)\) \{\n\s*(speakTTS|playTTSAudio)\(/g) || [];
@@ -31,7 +34,7 @@ assert.ok(chat.includes('<span>Read TTS Aloud On Channels I Watch</span>') && ch
@@ -31,7 +34,7 @@ assert.ok(chat.includes('<span>Read TTS Aloud On Channels I Watch</span>') && ch
assert.ok(chat.includes("if (key === 'ttsHearWatching' && !chatSettings.ttsHearWatching && typeof cancelAllTTS === 'function') cancelAllTTS();"));
assert.ok(chat.includes("if (key === 'ttsHearWatching' && !chatSettings.ttsHearWatching && typeof cancelAllTTS === 'function') cancelAllTTS();"));
// Not part of the synced record (Chat's chat.tts_defaults schema has no such field).
// Not part of the synced record (Chat's chat.tts_defaults schema has no such field).
-const ttsModule = chat.slice(chat.indexOf('function _ttsModuleFromSettings'), chat.indexOf('const CHAT_MODULES'));
+const ttsModule = chatCore.slice(chatCore.indexOf('function _ttsModuleFromSettings'), chatCore.indexOf('const CHAT_MODULES'));
assert.ok(!ttsModule.includes('ttsHearWatching'), 'local to this browser');
assert.ok(!ttsModule.includes('ttsHearWatching'), 'local to this browser');
console.log('tts watching opt-in: viewers hear TTS from the stream only, unless they turn on reading it here');
console.log('tts watching opt-in: viewers hear TTS from the stream only, unless they turn on reading it here');