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)

OP OpenVibers committed 24 days ago · cde310ba6b · 10 files, +223 −11

Changes

ddiff --git a/public/css/chat-shared.css b/public/css/chat-shared.css
iindex 174fae38..15788845 100644
---- a/public/css/chat-shared.css
++++ b/public/css/chat-shared.css
@@@ -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;
++}
ddiff --git a/public/features.json b/public/features.json
iindex 6c87c08d..e123484b 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -66,6 +66,7 @@
},
"chatExtras": {
"js": [
++ "/js/chat-spoilers.js",
"/js/emotes.js",
"/js/gif-picker.js",
"/js/chat-autocomplete.js"
ddiff --git a/public/js/chat-autocomplete.js b/public/js/chat-autocomplete.js
iindex 104f204a..3fe9c22f 100644
---- a/public/js/chat-autocomplete.js
++++ b/public/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]' },
ddiff --git a/public/js/chat-spoilers.js b/public/js/chat-spoilers.js
nnew file mode 100644
iindex 00000000..c5e0667d
---- /dev/null
++++ b/public/js/chat-spoilers.js
@@@ -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 };
++})();
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex f395031f..1c50e823 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -7,11 +7,9 @@ let chatStreamId = null;
let chatChannelUserId = null; // streamer's user id — stable room across slots + offline
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') {
// 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);
ddiff --git a/public/js/emotes.js b/public/js/emotes.js
iindex fbdab72d..d5a077d1 100644
---- a/public/js/emotes.js
++++ b/public/js/emotes.js
@@@ -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 state
const segments = _substituteKickEmotes(text);
ddiff --git a/public/kiosk.html b/public/kiosk.html
iindex 249d780a..9d1fbb2f 100644
---- a/public/kiosk.html
++++ b/public/kiosk.html
@@@ -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);
ddiff --git a/public/obs/chat.html b/public/obs/chat.html
iindex 890f9221..cc236123 100644
---- a/public/obs/chat.html
++++ b/public/obs/chat.html
@@@ -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>`;
}
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 56e4fc76..7b77e59c 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -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>
ddiff --git a/test/chat-spoilers.test.js b/test/chat-spoilers.test.js
nnew file mode 100644
iindex 00000000..679914e0
---- /dev/null
++++ b/test/chat-spoilers.test.js
@@@ -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('&lt;b&gt;x&lt;/b&gt;</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');