openvibers/openvibe.chat Commits

Spoilers: /spoiler <text> sends ||text|| on the ordinary path, and openvibe.chat renders ||spoilers|| hidden until selected (split before escaping, so no marker reaches an attribute) (#122)

OP OpenVibers committed 24 days ago · 799acd0861 · 6 files, +75 −2

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 29e517d..52a480e 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -250,3 +250,8 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-goto { margin: 0; padding-left: 18px; }
.oc-goto li { margin: 4px 0; }
.oc-goto a { font-weight: 700; }
++
++/* Spoilers (||text|| or /spoiler): a bar until selected; links inside stay hidden and unclickable until then. */
++.oc-spoiler { background: #6b7280; color: transparent; border-radius: 4px; padding: 0 2px; cursor: pointer; }
++.oc-spoiler:not(.revealed):not(:focus) * { visibility: hidden; }
++.oc-spoiler:focus, .oc-spoiler.revealed { background: rgba(127, 127, 127, 0.15); color: inherit; cursor: auto; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex dc7c92b..795539d 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -25,9 +25,28 @@
if (!isNaN(d)) t.dateTime = d.toISOString();
return t;
}
++ // ||Spoilers||: each hidden part goes in a span.oc-spoiler (pages.js renders the same), shown once selected.
function textWithLinks(parent, text) {
++ var raw = String(text || ''), sre = /\|\|([\s\S]+?)\|\|/g, last = 0, m;
++ while ((m = sre.exec(raw))) {
++ if (!m[1].trim()) continue;
++ linksIn(parent, raw.slice(last, m.index));
++ var s = el('span', 'oc-spoiler'); s.tabIndex = 0; s.setAttribute('role', 'button'); s.setAttribute('aria-label', 'Spoiler, select to show');
++ linksIn(s, m[1]); parent.appendChild(s);
++ last = m.index + m[0].length;
++ }
++ linksIn(parent, raw.slice(last));
++ }
++ function reveal(e) {
++ var s = e.target && e.target.closest ? e.target.closest('.oc-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('aria-label');
++ }
++ document.addEventListener('click', reveal, true);
++ document.addEventListener('keydown', reveal, true);
++ function linksIn(parent, text) {
var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
-- text = String(text || '');
while ((m = re.exec(text))) {
if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
var a = el('a', null, m[0]); a.href = m[0]; a.rel = 'nofollow ugc noopener'; a.target = '_blank';
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 6dc5fcb..d67f8f3 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -777,6 +777,15 @@ class ChatServer {
return;
}
++ // ── /spoiler <text>: Discord's shorthand for a message hidden until clicked. It is sent as ||text|| (the
++ // markup works anywhere in a message), so it takes the ordinary path below: filters, limits, history.
++ const spoiler = /^\/spoiler(?:\s+([\s\S]*))?$/i.exec(text);
++ if (spoiler) {
++ const hidden = (spoiler[1] || '').replace(/\|\|/g, '').trim();
++ if (!hidden) { this.sendTo(ws, { type: 'system', message: 'Usage: /spoiler <text> (or ||text|| anywhere in a message)' }); return; }
++ text = `||${hidden}||`;
++ }
++
// ── Chat commands ────────────────────────────────────
if (text.startsWith('/')) {
await this.handleCommand(ws, client, text);
@@@ -1617,7 +1626,7 @@ class ChatServer {
case 'help':
this.sendTo(ws, {
type: 'system',
-- message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
++ message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>, /spoiler <text> (or ||text||)` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
(await this.canModerate(client)
? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex f50e201..3524159 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -70,7 +70,21 @@ const hhmm = (ts) => {
return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };
};
/** Plain text with http(s) links, escaped. */
++// ||Spoilers|| (and /spoiler, sent as one): hidden until selected. The raw text is split before escaping, so a marker
++// never lands inside a link; without JavaScript a spoiler shows while it has focus, chat.js keeps it shown.
++const SPOILER_RE = /\|\|([\s\S]+?)\|\|/g;
function linkify(text) {
++ const raw = String(text ?? '');
++ let out = '';
++ let last = 0;
++ for (const m of raw.matchAll(SPOILER_RE)) {
++ if (!m[1].trim()) continue;
++ out += linkifyPart(raw.slice(last, m.index)) + `<span class="oc-spoiler" tabindex="0" role="button" aria-label="Spoiler, select to show">${linkifyPart(m[1])}</span>`;
++ last = m.index + m[0].length;
++ }
++ return out + linkifyPart(raw.slice(last));
++}
++function linkifyPart(text) {
return esc(text).replace(/https?:\/\/[^\s<>"']+/g, (u) => `<a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a>`);
}
/** The author's badge, from the staff map (ADR-022): staff run the site, moderators moderate chat. */
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 706ccb8..91d1984 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -181,4 +181,19 @@ t('CF-Connecting-IP / X-Forwarded-For are believed only from Cloudflare', async
await h.ctx.invalidateBans();
});
++t('/spoiler sends the message hidden as ||text||, on the ordinary path; with no text it answers how to use it', async () => {
++ const viewer = await joined({ ip: '198.51.100.30' });
++ const sender = await joined({ ip: '198.51.100.31' }); // signed out: alice's slow-mode state belongs to a later case
++ sender.sendJson({ type: 'chat', message: '/spoiler the ghost was ||Bob' });
++ const got = await viewer.next((m) => m.type === 'chat' && /ghost/.test(m.message || ''));
++ assert.strictEqual(got.message, '||the ghost was Bob||', 'one spoiler: markers inside are dropped');
++ assert.ok(!got.is_action, 'an ordinary message, not a /me action');
++ await h.sleep(1100);
++ sender.sendJson({ type: 'chat', message: '/SPOILER ' });
++ await sender.next((m) => m.type === 'system' && /^Usage: \/spoiler <text>/.test(m.message));
++ assert.ok(await viewer.none((m) => m.type === 'chat' && /^\|\|/.test(m.message || '') && m !== got), 'nothing sent');
++ sender.close();
++ viewer.close();
++});
++
t.run(() => h && h.close());
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 3af3bbb..17a9854 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -266,4 +266,15 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, with
assert.strictEqual(nameFxClass({}), null);
});
++t('||spoilers|| render hidden on the pages: split before escaping, links inside stay inside, no marker in an attribute', async () => {
++ const { linkify } = require('../server/web/pages');
++ const html = linkify('the end: ||he was the ghost https://spoil.er/x <b>|| ok');
++ assert.strictEqual(html, 'the end: <span class="oc-spoiler" tabindex="0" role="button" aria-label="Spoiler, select to show">he was the ghost <a href="https://spoil.er/x" rel="nofollow ugc noopener" target="_blank">https://spoil.er/x</a> &lt;b&gt;</span> ok');
++ assert.strictEqual(linkify('a || b'), 'a || b', 'an unmatched pair stays text');
++ const tricky = linkify('https://x.com/a||b" onmouseover="x||');
++ assert.ok(!/href="[^"]*(<|\|\|)/.test(tricky) && !/onmouseover="x/.test(tricky), 'nothing from a spoiler reaches an attribute');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '../public/web/chat.css'), 'utf8');
++ assert.match(css, /\.oc-spoiler:not\(\.revealed\):not\(:focus\) \* \{ visibility: hidden; \}/);
++});
++
t.run(async () => { if (h && h.close) await h.close(); });