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)
Changes
@@@ -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; }@@@ -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';@@@ -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`@@@ -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. */@@@ -181,4 +181,19 @@ t('CF-Connecting-IP / X-Forwarded-For are believed only from Cloudflare', asyncawait 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());@@@ -266,4 +266,15 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, withassert.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> <b></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(); });
@@ -250,3 +250,8 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -250,3 +250,8 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-goto { margin: 0; padding-left: 18px; }
.oc-goto { margin: 0; padding-left: 18px; }
.oc-goto li { margin: 4px 0; }
.oc-goto li { margin: 4px 0; }
.oc-goto a { font-weight: 700; }
.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; }
@@ -25,9 +25,28 @@
@@ -25,9 +25,28 @@
if (!isNaN(d)) t.dateTime = d.toISOString();
if (!isNaN(d)) t.dateTime = d.toISOString();
return t;
return t;
}
}
+ // ||Spoilers||: each hidden part goes in a span.oc-spoiler (pages.js renders the same), shown once selected.
function textWithLinks(parent, text) {
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;
var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
- text = String(text || '');
while ((m = re.exec(text))) {
while ((m = re.exec(text))) {
if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
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';
var a = el('a', null, m[0]); a.href = m[0]; a.rel = 'nofollow ugc noopener'; a.target = '_blank';
@@ -777,6 +777,15 @@ class ChatServer {
@@ -777,6 +777,15 @@ class ChatServer {
return;
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 ────────────────────────────────────
// ── Chat commands ────────────────────────────────────
if (text.startsWith('/')) {
if (text.startsWith('/')) {
await this.handleCommand(ws, client, text);
await this.handleCommand(ws, client, text);
@@ -1617,7 +1626,7 @@ class ChatServer {
@@ -1617,7 +1626,7 @@ class ChatServer {
case 'help':
case 'help':
this.sendTo(ws, {
this.sendTo(ws, {
type: 'system',
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` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
(await this.canModerate(client)
(await this.canModerate(client)
? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
@@ -70,7 +70,21 @@ const hhmm = (ts) => {
@@ -70,7 +70,21 @@ const hhmm = (ts) => {
return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };
return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };
};
};
/** Plain text with http(s) links, escaped. */
/** 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) {
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>`);
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. */
/** The author's badge, from the staff map (ADR-022): staff run the site, moderators moderate chat. */
@@ -181,4 +181,19 @@ t('CF-Connecting-IP / X-Forwarded-For are believed only from Cloudflare', async
@@ -181,4 +181,19 @@ t('CF-Connecting-IP / X-Forwarded-For are believed only from Cloudflare', async
await h.ctx.invalidateBans();
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());
t.run(() => h && h.close());
@@ -266,4 +266,15 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, with
@@ -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);
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> <b></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(); });
t.run(async () => { if (h && h.close) await h.close(); });