openvibers/openvibe.live Commits
Chat spoilers: ||text|| and /spoiler show as a bar until clicked (site, popout, kiosk; a solid bar on the OBS overlay), masked in reply snippets and TTS (#284)
Changes
@@@ -470,3 +470,23 @@.csp-info-btn { background: none; border: 0; padding: 2px 4px; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; }.csp-info-btn:hover, .csp-info-btn[aria-expanded="true"] { color: var(--accent-light, #a78bfa); }.csp-info-text { margin: 2px 0 8px; padding: 8px 10px; border-radius: 8px; font-size: 0.74rem; line-height: 1.45; color: var(--text-secondary); background: color-mix(in srgb, var(--accent, #8b5cf6) 9%, transparent); }++++/* Spoilers (||text|| or /spoiler): hidden until clicked; emotes and links inside stay hidden too. */++.chat-spoiler {++ background: var(--text-muted, #6b7280);++ color: transparent;++ border-radius: 4px;++ padding: 0 2px;++ cursor: pointer;++ user-select: none;++ transition: background 0.15s;++}++.chat-spoiler:hover { background: var(--text-secondary, #9ca3af); }++.chat-spoiler:not(.revealed) * { visibility: hidden; }++.chat-spoiler:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.chat-spoiler.revealed {++ background: color-mix(in srgb, currentColor 12%, transparent);++ color: inherit;++ cursor: auto;++ user-select: text;++}@@@ -66,6 +66,7 @@},"chatExtras": {"js": [++ "/js/chat-spoilers.js","/js/emotes.js","/js/gif-picker.js","/js/chat-autocomplete.js"@@@ -21,6 +21,7 @@ const SLASH_COMMANDS = [{ name: 'viewers', desc: 'Show viewer count' },{ name: 'uptime', desc: 'Show stream uptime' },{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },++ { name: 'spoiler', desc: 'Hide a message until clicked (or ||text|| inline)', usage: '/spoiler <text>' },{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },{ name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },nnew file mode 100644@@@ -0,0 +1,58 @@++/**++ * OpenVibe.Live — chat spoilers, as on Discord: ||hidden text|| anywhere in a message, and /spoiler <text>, which++ * OpenVibe.Chat sends as one. A hidden part shows as a bar until clicked (or Enter/Space on it).++ *++ * The raw text is split before any HTML is made, so a marker can never land inside a link's or an emote's attribute;++ * each part is then rendered by the caller's own renderer (emotes.js parseEmotes, the kiosk's linkify) and a hidden++ * one is wrapped in .chat-spoiler (chat-shared.css). Loaded with the chat (features.json chatExtras), the popout and++ * the kiosk, not on the home page's first load.++ */++(function () {++ 'use strict';++ const SPOILER_RE = /\|\|([\s\S]+?)\|\|/g;++++ /** [{ spoiler, text }]: only a non-empty ||…|| pair is a spoiler; anything else stays text. */++ function split(text) {++ const raw = String(text ?? '');++ const out = [];++ let last = 0;++ for (const m of raw.matchAll(SPOILER_RE)) {++ if (!m[1].trim()) continue;++ if (m.index > last) out.push({ spoiler: false, text: raw.slice(last, m.index) });++ out.push({ spoiler: true, text: m[1] });++ last = m.index + m[0].length;++ }++ if (last < raw.length || !out.length) out.push({ spoiler: false, text: raw.slice(last) });++ return out;++ }++++ /** Render text with spoilers: `render` turns one plain part into safe HTML. */++ function withSpoilers(text, render) {++ return split(text).map((p) => (p.spoiler++ ? `<span class="chat-spoiler" role="button" tabindex="0" aria-label="Spoiler, select to show">${render(p.text)}</span>`++ : render(p.text))).join('');++ }++++ /** Plain text with every spoiler's content replaced (reply snippets, TTS): nothing hidden shows or is spoken. */++ function mask(text) {++ return String(text ?? '').replace(SPOILER_RE, (m, inner) => (inner.trim() ? '[spoiler]' : m));++ }++++ // A click (or Enter/Space) on a hidden spoiler shows it and does nothing else: a link inside it does not open.++ function reveal(e) {++ const s = e.target && e.target.closest ? e.target.closest('.chat-spoiler:not(.revealed)') : null;++ if (!s || (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ')) return;++ e.preventDefault();++ e.stopPropagation();++ s.classList.add('revealed');++ s.removeAttribute('role');++ s.removeAttribute('tabindex');++ s.removeAttribute('aria-label');++ }++++ if (!window.OpenVibeSpoilers) {++ document.addEventListener('click', reveal, true);++ document.addEventListener('keydown', reveal, true);++ }++ window.OpenVibeSpoilers = { split, withSpoilers, mask };++})();@@@ -7,11 +7,9 @@ let chatStreamId = null;let chatChannelUserId = null; // streamer's user id — stable room across slots + offlinelet chatRenderTargetId = null;--/* `esc` used to be redefined here. Both copies were top-level function declarations in classic-- scripts, so whichever loaded last won — this one did, which is the only reason app.js's weaker-- version (it did not escape double quotes) never caused a problem. app.js now owns the single-- correct implementation; relying on load order for an escaping function is not something to-- keep. */++// `esc` is app.js's (one escaping function, not two racing on load order).++// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.js++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).@@@ -3811,7 +3809,7 @@ function buildChatMessageEl(msg, opts = {}) {let replyHtml = '';if (msg.reply_to) {const replyUser = esc(msg.reply_to.username || 'unknown');-- const replySnippet = esc(msg.reply_to.message || '');++ const replySnippet = esc(_spoilFree(msg.reply_to.message || ''));const replyMsgId = msg.reply_to.id ? `data-reply-target="${esc(String(msg.reply_to.id))}"` : '';replyHtml = `<div class="chat-reply-header" ${replyMsgId} onclick="scrollToReplyTarget(this)"><i class="fa-solid fa-reply fa-flip-horizontal"></i> <span class="chat-reply-user">@${replyUser}</span> <span class="chat-reply-snippet">${replySnippet}</span></div>`;}@@@ -4534,7 +4532,7 @@ function setChatReply(replyData) {area.prepend(bar);}}-- bar.innerHTML = `<div class="chat-reply-bar-content"><i class="fa-solid fa-reply fa-flip-horizontal"></i> Replying to <strong>${esc(replyData.username)}</strong><span class="chat-reply-bar-snippet">${esc(replyData.message)}</span></div><button class="chat-reply-bar-close" onclick="clearChatReply()" title="Cancel reply"><i class="fa-solid fa-xmark"></i></button>`;++ bar.innerHTML = `<div class="chat-reply-bar-content"><i class="fa-solid fa-reply fa-flip-horizontal"></i> Replying to <strong>${esc(replyData.username)}</strong><span class="chat-reply-bar-snippet">${esc(_spoilFree(replyData.message))}</span></div><button class="chat-reply-bar-close" onclick="clearChatReply()" title="Cancel reply"><i class="fa-solid fa-xmark"></i></button>`;bar.style.display = '';});// Focus the relevant input@@@ -6446,7 +6444,7 @@ function speakTTS(text, voiceFX, username) {// "." prefix = TTS explicitly skipped (regardless of any per-viewer setting).if (String(text || '').trimStart().startsWith('.')) return;// Replace real links with TTS-friendly descriptions (shared with broadcast.js + server).-- let cleanText = OpenVibeChat.ttsReplaceLinks(text, username);++ let cleanText = OpenVibeChat.ttsReplaceLinks(_spoilFree(text), username);// Cap TTS to the streamer's max TTS length.const _ttsMax = _ttsMaxLen();if (cleanText.length > _ttsMax) cleanText = cleanText.slice(0, _ttsMax);@@@ -145,8 +145,12 @@ function _substituteKickEmotes(text) {return parts;}--/* ── Parse emote codes + linkify URLs in a message string → HTML ── */++/* ── Parse emote codes + linkify URLs in a message string → HTML (||spoilers|| through chat-spoilers.js) ── */function parseEmotes(text) {++ const sp = window.OpenVibeSpoilers;++ return sp ? sp.withSpoilers(text, _parseEmotesPart) : _parseEmotesPart(String(text ?? ''));++}++function _parseEmotesPart(text) {// First pass: expand Kick inline [emote:name:id] tags regardless of loaded stateconst segments = _substituteKickEmotes(text);@@@ -649,6 +649,7 @@ button{font:inherit}</div><script src="/js/chat-shared.js"></script>++<script src="/js/chat-spoilers.js"></script><script>(() => {"use strict";@@@ -906,7 +907,7 @@ button{font:inherit}`data-uid="${esc(String(m.user_id||""))}"`,`data-platform="${esc(plat)}"`,`data-ext="${esc(ext)}"`,`data-color="${esc(nameColor)}"`].join(" ");html += `<span class="name" ${da} style="color:${esc(nameColor)}" title="Right-click for options">${esc(label)}</span> `;-- html += `<span class="msg-text">${OpenVibeChat.linkify(m.message||"")}</span>`;++ html += `<span class="msg-text">${OpenVibeSpoilers.withSpoilers(m.message||"", OpenVibeChat.linkify)}</span>`;el.innerHTML = html;}messages.appendChild(el);@@@ -317,6 +317,8 @@ body.no-shadow .msg { text-shadow: none; }transition: opacity 0.3s;}#status.hidden { opacity: 0; }++ .spoiler { background: rgba(255, 255, 255, 0.35); color: transparent; border-radius: 4px; padding: 0 2px; }++ .spoiler * { visibility: hidden; }</style></head><body>@@@ -415,7 +417,19 @@ body.no-shadow .msg { text-shadow: none; }// ── Emote parsing ──────────────────────────────────────────// Kick inline emote format: [emote:<id>:<name>] (numeric id first).const KICK_EMOTE_RE = /\[emote:(\d+):([^\]:]+)\]/g;++ // ||spoilers|| stay a solid bar here: nobody watching the stream can click to show them.++ const SPOILER_RE = /\|\|([\s\S]+?)\|\|/g;function parseEmotes(text) {++ let out = '';++ let last = 0;++ for (const m of String(text).matchAll(SPOILER_RE)) {++ if (!m[1].trim()) continue;++ out += parseEmotesPart(text.slice(last, m.index)) + `<span class="spoiler">${parseEmotesPart(m[1])}</span>`;++ last = m.index + m[0].length;++ }++ return out + parseEmotesPart(String(text).slice(last));++ }++ function parseEmotesPart(text) {// First expand Kick inline emotes (independent of the loaded emote map).const parts = [];let last = 0, m;@@@ -635,7 +649,7 @@ body.no-shadow .msg { text-shadow: none; }let replyHtml = '';if (msg.reply_to) {const replyUser = esc(msg.reply_to.username || 'unknown');-- const replySnippet = esc(msg.reply_to.message || '');++ const replySnippet = esc(String(msg.reply_to.message || '').replace(SPOILER_RE, (m, inner) => (inner.trim() ? '[spoiler]' : m)));replyHtml = `<div style="font-size:0.72em;opacity:0.6;margin-bottom:1px"><i class="fa-solid fa-reply fa-flip-horizontal" style="font-size:0.6em"></i> @${replyUser} ${replySnippet}</div>`;}@@@ -252,6 +252,7 @@<!-- Chat feature scripts — mirror index.html so the popout has full parity.Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. --><script src="/js/app.js"></script>++ <script src="/js/chat-spoilers.js"></script><script src="/js/emotes.js"></script><script src="/js/gif-picker.js"></script><script src="/js/channel-uploads.js"></script>nnew file mode 100644@@@ -0,0 +1,114 @@++/**++ * Spoilers in chat (a viewer, 2026-10-20: "add /spoiler command — discord has it"). ||hidden text|| anywhere in a++ * message, or /spoiler <text> (OpenVibe.Chat sends that as ||text||), shows as a bar until clicked.++ *++ * Runs the real public/js/chat-spoilers.js, chat-shared.js and emotes.js in a sandbox: the split happens on the raw text++ * before any HTML (a marker can never land inside a link's or an emote's attribute), emotes and links inside a spoiler++ * stay inside its span, a click or Enter shows it without opening a link inside, reply snippets and TTS mask it, and++ * the OBS overlay draws a bar nobody can click.++ *++ * node test/chat-spoilers.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++++const listeners = {};++const ctx = {++ console, URL, Map, Set, Promise, setTimeout, clearTimeout,++ localStorage: { getItem: () => null, setItem() {} },++ document: {++ addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push({ fn, capture }); },++ createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, classList: { add() {} } }),++ body: { appendChild() {} },++ getElementById: () => null,++ querySelector: () => null,++ },++};++ctx.window = ctx;++ctx.addEventListener = () => {};++vm.createContext(ctx);++vm.runInContext(read('public/js/chat-shared.js'), ctx);++vm.runInContext(read('public/js/chat-spoilers.js'), ctx);++vm.runInContext(`${read('public/js/emotes.js')}\nthis.__setEmotes = (map) => { emoteMap = map; emotesLoaded = true; };`, ctx);++const chat = ctx.OpenVibeChat;++const sp = ctx.OpenVibeSpoilers;++++// The split: only non-empty ||…|| pairs; an unmatched or empty pair stays text.++assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('the ending ||he was the ghost|| lol'))), [++ { spoiler: false, text: 'the ending ' }, { spoiler: true, text: 'he was the ghost' }, { spoiler: false, text: ' lol' },++]);++assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('a || b'))), [{ spoiler: false, text: 'a || b' }]);++assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('|| ||'))), [{ spoiler: false, text: '|| ||' }]);++++// The kiosk (its linkify through withSpoilers): a link inside a spoiler stays inside the span, and the text is escaped.++const kioskLinkify = (t) => sp.withSpoilers(t, chat.linkify);++const html = kioskLinkify('see ||https://example.com/end <b>x</b>|| now');++assert.match(html, /^see <span class="chat-spoiler" role="button" tabindex="0" aria-label="Spoiler, select to show"><a class="chat-link" href="https:\/\/example\.com\/end"/);++assert.ok(html.includes('<b>x</b></span> now'), 'escaped, and the span closes after the hidden part');++++// A marker inside what looks like a URL is split first: no span in any attribute, no || left in an href.++const tricky = kioskLinkify('https://x.com/a||b" onmouseover="alert(1)||');++assert.ok(!/href="[^"]*(<|\|\|)/.test(tricky), 'nothing from a spoiler reaches an attribute');++assert.ok(!/onmouseover="alert/.test(tricky), 'the would-be handler stays escaped text');++++// The site's renderer: emotes and links inside a spoiler render inside its span.++ctx.__setEmotes(new Map([['Kappa', { url: '/e/kappa.png' }]]));++const site = ctx.parseEmotes('lol ||Kappa he dies https://spoil.er|| Kappa');++assert.match(site, /^lol <span class="chat-spoiler"[^>]*><img class="chat-emote" src="\/e\/kappa\.png"[^>]*> he dies <a class="chat-link" href="https:\/\/spoil\.er"[^>]*>https:\/\/spoil\.er<\/a><\/span> <img class="chat-emote"/);++assert.strictEqual(ctx.parseEmotes('no spoiler here'), 'no spoiler here');++++// Showing one: a capture-phase listener, so a link inside a hidden spoiler only reveals it.++const reveal = (listeners.click || []).find((l) => l.capture);++assert.ok(reveal && (listeners.keydown || []).some((l) => l.capture), 'click and keydown listeners in the capture phase');++const attrs = new Set(['role', 'tabindex', 'aria-label']);++const span = { classList: { list: new Set(), add(c) { this.list.add(c); } }, removeAttribute: (a) => attrs.delete(a) };++const ev = (extra) => ({ target: { closest: (sel) => (sel === '.chat-spoiler:not(.revealed)' && !span.classList.list.has('revealed') ? span : null) }, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...extra });++const keyTab = ev({ type: 'keydown', key: 'Tab' });++listeners.keydown.find((l) => l.capture).fn(keyTab);++assert.ok(!span.classList.list.has('revealed') && !keyTab.prevented, 'Tab moves on; only Enter or Space shows it');++const click = ev({ type: 'click' });++reveal.fn(click);++assert.ok(span.classList.list.has('revealed') && click.prevented && click.stopped, 'shown; the click goes no further (a link inside does not open)');++assert.strictEqual(attrs.size, 0, 'no longer a button once shown');++const again = ev({ type: 'click' });++reveal.fn(again);++assert.ok(!again.prevented, 'a shown spoiler is ordinary text: its links work');++++// Nothing hidden shows in a reply snippet or is read aloud.++assert.strictEqual(sp.mask('lol ||he dies|| ok'), 'lol [spoiler] ok');++const chatJs = read('public/js/chat.js');++assert.ok(chatJs.includes('const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);'));++assert.ok(chatJs.includes("esc(_spoilFree(msg.reply_to.message || ''))"), 'reply headers mask spoilers');++assert.ok(chatJs.includes('esc(_spoilFree(replyData.message))'), 'the reply bar masks spoilers');++assert.ok(chatJs.includes('OpenVibeChat.ttsReplaceLinks(_spoilFree(text), username)'), 'TTS does not read a spoiler aloud');++++// Loaded with the chat, the popout and the kiosk, before what renders with it; not on the home page's first load.++const features = JSON.parse(read('public/features.json'));++const extras = (features.features || features).chatExtras.js;++assert.ok(extras.indexOf('/js/chat-spoilers.js') === 0 && extras.includes('/js/emotes.js'));++const popout = read('public/popout-chat.html');++assert.ok(popout.indexOf('/js/chat-spoilers.js') < popout.indexOf('/js/emotes.js'));++const kiosk = read('public/kiosk.html');++assert.ok(kiosk.includes('<script src="/js/chat-spoilers.js"></script>'));++assert.ok(kiosk.includes('OpenVibeSpoilers.withSpoilers(m.message||"", OpenVibeChat.linkify)'));++assert.ok(!read('public/index.html').includes('chat-spoilers.js'), 'not on the home page');++++// The OBS overlay: a bar, never clickable, and its reply snippets masked.++const obs = read('public/obs/chat.html');++assert.match(obs, /`<span class="spoiler">\$\{parseEmotesPart\(m\[1\]\)\}<\/span>`/);++assert.match(obs, /\.spoiler \{ background: rgba\(255, 255, 255, 0\.35\); color: transparent;/);++assert.match(obs, /msg\.reply_to\.message \|\| ''\)\.replace\(SPOILER_RE/);++++// Styles, and the command in the autocomplete list.++const css = read('public/css/chat-shared.css');++assert.match(css, /\.chat-spoiler \{[^}]*color: transparent;/);++assert.match(css, /\.chat-spoiler:not\(\.revealed\) \* \{ visibility: hidden; \}/);++assert.match(read('public/js/chat-autocomplete.js'), /name: 'spoiler'/);++++console.log('chat spoilers: all checks passed');
@@ -470,3 +470,23 @@
@@ -470,3 +470,23 @@
.csp-info-btn { background: none; border: 0; padding: 2px 4px; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; }
.csp-info-btn { background: none; border: 0; padding: 2px 4px; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; }
.csp-info-btn:hover, .csp-info-btn[aria-expanded="true"] { color: var(--accent-light, #a78bfa); }
.csp-info-btn:hover, .csp-info-btn[aria-expanded="true"] { color: var(--accent-light, #a78bfa); }
.csp-info-text { margin: 2px 0 8px; padding: 8px 10px; border-radius: 8px; font-size: 0.74rem; line-height: 1.45; color: var(--text-secondary); background: color-mix(in srgb, var(--accent, #8b5cf6) 9%, transparent); }
.csp-info-text { margin: 2px 0 8px; padding: 8px 10px; border-radius: 8px; font-size: 0.74rem; line-height: 1.45; color: var(--text-secondary); background: color-mix(in srgb, var(--accent, #8b5cf6) 9%, transparent); }
+
+/* Spoilers (||text|| or /spoiler): hidden until clicked; emotes and links inside stay hidden too. */
+.chat-spoiler {
+ background: var(--text-muted, #6b7280);
+ color: transparent;
+ border-radius: 4px;
+ padding: 0 2px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s;
+}
+.chat-spoiler:hover { background: var(--text-secondary, #9ca3af); }
+.chat-spoiler:not(.revealed) * { visibility: hidden; }
+.chat-spoiler:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.chat-spoiler.revealed {
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ color: inherit;
+ cursor: auto;
+ user-select: text;
+}
@@ -66,6 +66,7 @@
@@ -66,6 +66,7 @@
},
},
"chatExtras": {
"chatExtras": {
"js": [
"js": [
+ "/js/chat-spoilers.js",
"/js/emotes.js",
"/js/emotes.js",
"/js/gif-picker.js",
"/js/gif-picker.js",
"/js/chat-autocomplete.js"
"/js/chat-autocomplete.js"
@@ -21,6 +21,7 @@ const SLASH_COMMANDS = [
@@ -21,6 +21,7 @@ const SLASH_COMMANDS = [
{ name: 'viewers', desc: 'Show viewer count' },
{ name: 'viewers', desc: 'Show viewer count' },
{ name: 'uptime', desc: 'Show stream uptime' },
{ name: 'uptime', desc: 'Show stream uptime' },
{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },
{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },
+ { name: 'spoiler', desc: 'Hide a message until clicked (or ||text|| inline)', usage: '/spoiler <text>' },
{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },
{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },
{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },
{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },
{ name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },
{ name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+/**
+ * OpenVibe.Live — chat spoilers, as on Discord: ||hidden text|| anywhere in a message, and /spoiler <text>, which
+ * OpenVibe.Chat sends as one. A hidden part shows as a bar until clicked (or Enter/Space on it).
+ *
+ * The raw text is split before any HTML is made, so a marker can never land inside a link's or an emote's attribute;
+ * each part is then rendered by the caller's own renderer (emotes.js parseEmotes, the kiosk's linkify) and a hidden
+ * one is wrapped in .chat-spoiler (chat-shared.css). Loaded with the chat (features.json chatExtras), the popout and
+ * the kiosk, not on the home page's first load.
+ */
+(function () {
+ 'use strict';
+ const SPOILER_RE = /\|\|([\s\S]+?)\|\|/g;
+
+ /** [{ spoiler, text }]: only a non-empty ||…|| pair is a spoiler; anything else stays text. */
+ function split(text) {
+ const raw = String(text ?? '');
+ const out = [];
+ let last = 0;
+ for (const m of raw.matchAll(SPOILER_RE)) {
+ if (!m[1].trim()) continue;
+ if (m.index > last) out.push({ spoiler: false, text: raw.slice(last, m.index) });
+ out.push({ spoiler: true, text: m[1] });
+ last = m.index + m[0].length;
+ }
+ if (last < raw.length || !out.length) out.push({ spoiler: false, text: raw.slice(last) });
+ return out;
+ }
+
+ /** Render text with spoilers: `render` turns one plain part into safe HTML. */
+ function withSpoilers(text, render) {
+ return split(text).map((p) => (p.spoiler
+ ? `<span class="chat-spoiler" role="button" tabindex="0" aria-label="Spoiler, select to show">${render(p.text)}</span>`
+ : render(p.text))).join('');
+ }
+
+ /** Plain text with every spoiler's content replaced (reply snippets, TTS): nothing hidden shows or is spoken. */
+ function mask(text) {
+ return String(text ?? '').replace(SPOILER_RE, (m, inner) => (inner.trim() ? '[spoiler]' : m));
+ }
+
+ // A click (or Enter/Space) on a hidden spoiler shows it and does nothing else: a link inside it does not open.
+ function reveal(e) {
+ const s = e.target && e.target.closest ? e.target.closest('.chat-spoiler:not(.revealed)') : null;
+ if (!s || (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ s.classList.add('revealed');
+ s.removeAttribute('role');
+ s.removeAttribute('tabindex');
+ s.removeAttribute('aria-label');
+ }
+
+ if (!window.OpenVibeSpoilers) {
+ document.addEventListener('click', reveal, true);
+ document.addEventListener('keydown', reveal, true);
+ }
+ window.OpenVibeSpoilers = { split, withSpoilers, mask };
+})();
@@ -7,11 +7,9 @@ let chatStreamId = null;
@@ -7,11 +7,9 @@ let chatStreamId = null;
let chatChannelUserId = null; // streamer's user id — stable room across slots + offline
let chatChannelUserId = null; // streamer's user id — stable room across slots + offline
let chatRenderTargetId = null;
let chatRenderTargetId = null;
-/* `esc` used to be redefined here. Both copies were top-level function declarations in classic
- scripts, so whichever loaded last won — this one did, which is the only reason app.js's weaker
- version (it did not escape double quotes) never caused a problem. app.js now owns the single
- correct implementation; relying on load order for an escaping function is not something to
- keep. */
+// `esc` is app.js's (one escaping function, not two racing on load order).
+// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.js
+const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);
function toast(message, type = 'info') {
function toast(message, type = 'info') {
// Delegate to the app-level toast saved in app.js (loaded before this file).
// Delegate to the app-level toast saved in app.js (loaded before this file).
@@ -3811,7 +3809,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -3811,7 +3809,7 @@ function buildChatMessageEl(msg, opts = {}) {
let replyHtml = '';
let replyHtml = '';
if (msg.reply_to) {
if (msg.reply_to) {
const replyUser = esc(msg.reply_to.username || 'unknown');
const replyUser = esc(msg.reply_to.username || 'unknown');
- const replySnippet = esc(msg.reply_to.message || '');
+ const replySnippet = esc(_spoilFree(msg.reply_to.message || ''));
const replyMsgId = msg.reply_to.id ? `data-reply-target="${esc(String(msg.reply_to.id))}"` : '';
const replyMsgId = msg.reply_to.id ? `data-reply-target="${esc(String(msg.reply_to.id))}"` : '';
replyHtml = `<div class="chat-reply-header" ${replyMsgId} onclick="scrollToReplyTarget(this)"><i class="fa-solid fa-reply fa-flip-horizontal"></i> <span class="chat-reply-user">@${replyUser}</span> <span class="chat-reply-snippet">${replySnippet}</span></div>`;
replyHtml = `<div class="chat-reply-header" ${replyMsgId} onclick="scrollToReplyTarget(this)"><i class="fa-solid fa-reply fa-flip-horizontal"></i> <span class="chat-reply-user">@${replyUser}</span> <span class="chat-reply-snippet">${replySnippet}</span></div>`;
}
}
@@ -4534,7 +4532,7 @@ function setChatReply(replyData) {
@@ -4534,7 +4532,7 @@ function setChatReply(replyData) {
area.prepend(bar);
area.prepend(bar);
}
}
}
}
- bar.innerHTML = `<div class="chat-reply-bar-content"><i class="fa-solid fa-reply fa-flip-horizontal"></i> Replying to <strong>${esc(replyData.username)}</strong><span class="chat-reply-bar-snippet">${esc(replyData.message)}</span></div><button class="chat-reply-bar-close" onclick="clearChatReply()" title="Cancel reply"><i class="fa-solid fa-xmark"></i></button>`;
+ bar.innerHTML = `<div class="chat-reply-bar-content"><i class="fa-solid fa-reply fa-flip-horizontal"></i> Replying to <strong>${esc(replyData.username)}</strong><span class="chat-reply-bar-snippet">${esc(_spoilFree(replyData.message))}</span></div><button class="chat-reply-bar-close" onclick="clearChatReply()" title="Cancel reply"><i class="fa-solid fa-xmark"></i></button>`;
bar.style.display = '';
bar.style.display = '';
});
});
// Focus the relevant input
// Focus the relevant input
@@ -6446,7 +6444,7 @@ function speakTTS(text, voiceFX, username) {
@@ -6446,7 +6444,7 @@ function speakTTS(text, voiceFX, username) {
// "." prefix = TTS explicitly skipped (regardless of any per-viewer setting).
// "." prefix = TTS explicitly skipped (regardless of any per-viewer setting).
if (String(text || '').trimStart().startsWith('.')) return;
if (String(text || '').trimStart().startsWith('.')) return;
// Replace real links with TTS-friendly descriptions (shared with broadcast.js + server).
// Replace real links with TTS-friendly descriptions (shared with broadcast.js + server).
- let cleanText = OpenVibeChat.ttsReplaceLinks(text, username);
+ let cleanText = OpenVibeChat.ttsReplaceLinks(_spoilFree(text), username);
// Cap TTS to the streamer's max TTS length.
// Cap TTS to the streamer's max TTS length.
const _ttsMax = _ttsMaxLen();
const _ttsMax = _ttsMaxLen();
if (cleanText.length > _ttsMax) cleanText = cleanText.slice(0, _ttsMax);
if (cleanText.length > _ttsMax) cleanText = cleanText.slice(0, _ttsMax);
@@ -145,8 +145,12 @@ function _substituteKickEmotes(text) {
@@ -145,8 +145,12 @@ function _substituteKickEmotes(text) {
return parts;
return parts;
}
}
-/* ── Parse emote codes + linkify URLs in a message string → HTML ── */
+/* ── Parse emote codes + linkify URLs in a message string → HTML (||spoilers|| through chat-spoilers.js) ── */
function parseEmotes(text) {
function parseEmotes(text) {
+ const sp = window.OpenVibeSpoilers;
+ return sp ? sp.withSpoilers(text, _parseEmotesPart) : _parseEmotesPart(String(text ?? ''));
+}
+function _parseEmotesPart(text) {
// First pass: expand Kick inline [emote:name:id] tags regardless of loaded state
// First pass: expand Kick inline [emote:name:id] tags regardless of loaded state
const segments = _substituteKickEmotes(text);
const segments = _substituteKickEmotes(text);
@@ -649,6 +649,7 @@ button{font:inherit}
@@ -649,6 +649,7 @@ button{font:inherit}
</div>
</div>
<script src="/js/chat-shared.js"></script>
<script src="/js/chat-shared.js"></script>
+<script src="/js/chat-spoilers.js"></script>
<script>
<script>
(() => {
(() => {
"use strict";
"use strict";
@@ -906,7 +907,7 @@ button{font:inherit}
@@ -906,7 +907,7 @@ button{font:inherit}
`data-uid="${esc(String(m.user_id||""))}"`,`data-platform="${esc(plat)}"`,`data-ext="${esc(ext)}"`,
`data-uid="${esc(String(m.user_id||""))}"`,`data-platform="${esc(plat)}"`,`data-ext="${esc(ext)}"`,
`data-color="${esc(nameColor)}"`].join(" ");
`data-color="${esc(nameColor)}"`].join(" ");
html += `<span class="name" ${da} style="color:${esc(nameColor)}" title="Right-click for options">${esc(label)}</span> `;
html += `<span class="name" ${da} style="color:${esc(nameColor)}" title="Right-click for options">${esc(label)}</span> `;
- html += `<span class="msg-text">${OpenVibeChat.linkify(m.message||"")}</span>`;
+ html += `<span class="msg-text">${OpenVibeSpoilers.withSpoilers(m.message||"", OpenVibeChat.linkify)}</span>`;
el.innerHTML = html;
el.innerHTML = html;
}
}
messages.appendChild(el);
messages.appendChild(el);
@@ -317,6 +317,8 @@ body.no-shadow .msg { text-shadow: none; }
@@ -317,6 +317,8 @@ body.no-shadow .msg { text-shadow: none; }
transition: opacity 0.3s;
transition: opacity 0.3s;
}
}
#status.hidden { opacity: 0; }
#status.hidden { opacity: 0; }
+ .spoiler { background: rgba(255, 255, 255, 0.35); color: transparent; border-radius: 4px; padding: 0 2px; }
+ .spoiler * { visibility: hidden; }
</style>
</style>
</head>
</head>
<body>
<body>
@@ -415,7 +417,19 @@ body.no-shadow .msg { text-shadow: none; }
@@ -415,7 +417,19 @@ body.no-shadow .msg { text-shadow: none; }
// ── Emote parsing ──────────────────────────────────────────
// ── Emote parsing ──────────────────────────────────────────
// Kick inline emote format: [emote:<id>:<name>] (numeric id first).
// Kick inline emote format: [emote:<id>:<name>] (numeric id first).
const KICK_EMOTE_RE = /\[emote:(\d+):([^\]:]+)\]/g;
const KICK_EMOTE_RE = /\[emote:(\d+):([^\]:]+)\]/g;
+ // ||spoilers|| stay a solid bar here: nobody watching the stream can click to show them.
+ const SPOILER_RE = /\|\|([\s\S]+?)\|\|/g;
function parseEmotes(text) {
function parseEmotes(text) {
+ let out = '';
+ let last = 0;
+ for (const m of String(text).matchAll(SPOILER_RE)) {
+ if (!m[1].trim()) continue;
+ out += parseEmotesPart(text.slice(last, m.index)) + `<span class="spoiler">${parseEmotesPart(m[1])}</span>`;
+ last = m.index + m[0].length;
+ }
+ return out + parseEmotesPart(String(text).slice(last));
+ }
+ function parseEmotesPart(text) {
// First expand Kick inline emotes (independent of the loaded emote map).
// First expand Kick inline emotes (independent of the loaded emote map).
const parts = [];
const parts = [];
let last = 0, m;
let last = 0, m;
@@ -635,7 +649,7 @@ body.no-shadow .msg { text-shadow: none; }
@@ -635,7 +649,7 @@ body.no-shadow .msg { text-shadow: none; }
let replyHtml = '';
let replyHtml = '';
if (msg.reply_to) {
if (msg.reply_to) {
const replyUser = esc(msg.reply_to.username || 'unknown');
const replyUser = esc(msg.reply_to.username || 'unknown');
- const replySnippet = esc(msg.reply_to.message || '');
+ const replySnippet = esc(String(msg.reply_to.message || '').replace(SPOILER_RE, (m, inner) => (inner.trim() ? '[spoiler]' : m)));
replyHtml = `<div style="font-size:0.72em;opacity:0.6;margin-bottom:1px"><i class="fa-solid fa-reply fa-flip-horizontal" style="font-size:0.6em"></i> @${replyUser} ${replySnippet}</div>`;
replyHtml = `<div style="font-size:0.72em;opacity:0.6;margin-bottom:1px"><i class="fa-solid fa-reply fa-flip-horizontal" style="font-size:0.6em"></i> @${replyUser} ${replySnippet}</div>`;
}
}
@@ -252,6 +252,7 @@
@@ -252,6 +252,7 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
+ <script src="/js/chat-spoilers.js"></script>
<script src="/js/emotes.js"></script>
<script src="/js/emotes.js"></script>
<script src="/js/gif-picker.js"></script>
<script src="/js/gif-picker.js"></script>
<script src="/js/channel-uploads.js"></script>
<script src="/js/channel-uploads.js"></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+/**
+ * Spoilers in chat (a viewer, 2026-10-20: "add /spoiler command — discord has it"). ||hidden text|| anywhere in a
+ * message, or /spoiler <text> (OpenVibe.Chat sends that as ||text||), shows as a bar until clicked.
+ *
+ * Runs the real public/js/chat-spoilers.js, chat-shared.js and emotes.js in a sandbox: the split happens on the raw text
+ * before any HTML (a marker can never land inside a link's or an emote's attribute), emotes and links inside a spoiler
+ * stay inside its span, a click or Enter shows it without opening a link inside, reply snippets and TTS mask it, and
+ * the OBS overlay draws a bar nobody can click.
+ *
+ * node test/chat-spoilers.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+
+const listeners = {};
+const ctx = {
+ console, URL, Map, Set, Promise, setTimeout, clearTimeout,
+ localStorage: { getItem: () => null, setItem() {} },
+ document: {
+ addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push({ fn, capture }); },
+ createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, classList: { add() {} } }),
+ body: { appendChild() {} },
+ getElementById: () => null,
+ querySelector: () => null,
+ },
+};
+ctx.window = ctx;
+ctx.addEventListener = () => {};
+vm.createContext(ctx);
+vm.runInContext(read('public/js/chat-shared.js'), ctx);
+vm.runInContext(read('public/js/chat-spoilers.js'), ctx);
+vm.runInContext(`${read('public/js/emotes.js')}\nthis.__setEmotes = (map) => { emoteMap = map; emotesLoaded = true; };`, ctx);
+const chat = ctx.OpenVibeChat;
+const sp = ctx.OpenVibeSpoilers;
+
+// The split: only non-empty ||…|| pairs; an unmatched or empty pair stays text.
+assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('the ending ||he was the ghost|| lol'))), [
+ { spoiler: false, text: 'the ending ' }, { spoiler: true, text: 'he was the ghost' }, { spoiler: false, text: ' lol' },
+]);
+assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('a || b'))), [{ spoiler: false, text: 'a || b' }]);
+assert.deepStrictEqual(JSON.parse(JSON.stringify(sp.split('|| ||'))), [{ spoiler: false, text: '|| ||' }]);
+
+// The kiosk (its linkify through withSpoilers): a link inside a spoiler stays inside the span, and the text is escaped.
+const kioskLinkify = (t) => sp.withSpoilers(t, chat.linkify);
+const html = kioskLinkify('see ||https://example.com/end <b>x</b>|| now');
+assert.match(html, /^see <span class="chat-spoiler" role="button" tabindex="0" aria-label="Spoiler, select to show"><a class="chat-link" href="https:\/\/example\.com\/end"/);
+assert.ok(html.includes('<b>x</b></span> now'), 'escaped, and the span closes after the hidden part');
+
+// A marker inside what looks like a URL is split first: no span in any attribute, no || left in an href.
+const tricky = kioskLinkify('https://x.com/a||b" onmouseover="alert(1)||');
+assert.ok(!/href="[^"]*(<|\|\|)/.test(tricky), 'nothing from a spoiler reaches an attribute');
+assert.ok(!/onmouseover="alert/.test(tricky), 'the would-be handler stays escaped text');
+
+// The site's renderer: emotes and links inside a spoiler render inside its span.
+ctx.__setEmotes(new Map([['Kappa', { url: '/e/kappa.png' }]]));
+const site = ctx.parseEmotes('lol ||Kappa he dies https://spoil.er|| Kappa');
+assert.match(site, /^lol <span class="chat-spoiler"[^>]*><img class="chat-emote" src="\/e\/kappa\.png"[^>]*> he dies <a class="chat-link" href="https:\/\/spoil\.er"[^>]*>https:\/\/spoil\.er<\/a><\/span> <img class="chat-emote"/);
+assert.strictEqual(ctx.parseEmotes('no spoiler here'), 'no spoiler here');
+
+// Showing one: a capture-phase listener, so a link inside a hidden spoiler only reveals it.
+const reveal = (listeners.click || []).find((l) => l.capture);
+assert.ok(reveal && (listeners.keydown || []).some((l) => l.capture), 'click and keydown listeners in the capture phase');
+const attrs = new Set(['role', 'tabindex', 'aria-label']);
+const span = { classList: { list: new Set(), add(c) { this.list.add(c); } }, removeAttribute: (a) => attrs.delete(a) };
+const ev = (extra) => ({ target: { closest: (sel) => (sel === '.chat-spoiler:not(.revealed)' && !span.classList.list.has('revealed') ? span : null) }, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...extra });
+const keyTab = ev({ type: 'keydown', key: 'Tab' });
+listeners.keydown.find((l) => l.capture).fn(keyTab);
+assert.ok(!span.classList.list.has('revealed') && !keyTab.prevented, 'Tab moves on; only Enter or Space shows it');
+const click = ev({ type: 'click' });
+reveal.fn(click);
+assert.ok(span.classList.list.has('revealed') && click.prevented && click.stopped, 'shown; the click goes no further (a link inside does not open)');
+assert.strictEqual(attrs.size, 0, 'no longer a button once shown');
+const again = ev({ type: 'click' });
+reveal.fn(again);
+assert.ok(!again.prevented, 'a shown spoiler is ordinary text: its links work');
+
+// Nothing hidden shows in a reply snippet or is read aloud.
+assert.strictEqual(sp.mask('lol ||he dies|| ok'), 'lol [spoiler] ok');
+const chatJs = read('public/js/chat.js');
+assert.ok(chatJs.includes('const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);'));
+assert.ok(chatJs.includes("esc(_spoilFree(msg.reply_to.message || ''))"), 'reply headers mask spoilers');
+assert.ok(chatJs.includes('esc(_spoilFree(replyData.message))'), 'the reply bar masks spoilers');
+assert.ok(chatJs.includes('OpenVibeChat.ttsReplaceLinks(_spoilFree(text), username)'), 'TTS does not read a spoiler aloud');
+
+// Loaded with the chat, the popout and the kiosk, before what renders with it; not on the home page's first load.
+const features = JSON.parse(read('public/features.json'));
+const extras = (features.features || features).chatExtras.js;
+assert.ok(extras.indexOf('/js/chat-spoilers.js') === 0 && extras.includes('/js/emotes.js'));
+const popout = read('public/popout-chat.html');
+assert.ok(popout.indexOf('/js/chat-spoilers.js') < popout.indexOf('/js/emotes.js'));
+const kiosk = read('public/kiosk.html');
+assert.ok(kiosk.includes('<script src="/js/chat-spoilers.js"></script>'));
+assert.ok(kiosk.includes('OpenVibeSpoilers.withSpoilers(m.message||"", OpenVibeChat.linkify)'));
+assert.ok(!read('public/index.html').includes('chat-spoilers.js'), 'not on the home page');
+
+// The OBS overlay: a bar, never clickable, and its reply snippets masked.
+const obs = read('public/obs/chat.html');
+assert.match(obs, /`<span class="spoiler">\$\{parseEmotesPart\(m\[1\]\)\}<\/span>`/);
+assert.match(obs, /\.spoiler \{ background: rgba\(255, 255, 255, 0\.35\); color: transparent;/);
+assert.match(obs, /msg\.reply_to\.message \|\| ''\)\.replace\(SPOILER_RE/);
+
+// Styles, and the command in the autocomplete list.
+const css = read('public/css/chat-shared.css');
+assert.match(css, /\.chat-spoiler \{[^}]*color: transparent;/);
+assert.match(css, /\.chat-spoiler:not\(\.revealed\) \* \{ visibility: hidden; \}/);
+assert.match(read('public/js/chat-autocomplete.js'), /name: 'spoiler'/);
+
+console.log('chat spoilers: all checks passed');