openvibers/openvibe.chat Commits
Chat: openvibe.chat draws the hat each person wears just before their name (from Live's decor; openvibe-shared items.css) (#38)
Changes
@@@ -57,9 +57,19 @@}// A name with the effect its person wears (m.nameFX from Live's decor; openvibe-shared items.css draws it).++ // Their hat (m.hatFX) goes just before the name, outside the effect so a gradient cannot hide it.function wearing(m, text) {++ var frag = document.createDocumentFragment();++ var h = m && m.hatFX;++ var glyph = h && typeof h.hatChar === 'string' ? h.hatChar.slice(0, 8) : '';++ if (glyph) {++ var hat = el('span', 'ov-hat' + (['float', 'pulse', 'warp'].indexOf(h.animated) >= 0 ? ' ov-hat-' + h.animated : ''), glyph);++ hat.setAttribute('aria-hidden', 'true');++ frag.appendChild(hat);++ }var cls = m && m.nameFX && /^name-fx-[a-z]{2,24}$/.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null;-- return cls ? el('span', 'ov-fx ' + cls, text) : document.createTextNode(text);++ frag.appendChild(cls ? el('span', 'ov-fx ' + cls, text) : document.createTextNode(text));++ return frag;}// ── Global chat ──@@@ -79,12 +79,20 @@ function roleBadge(role) {// The name effect a person wears (OpenVibe.Inventory through Live's decor): a class openvibe-shared items.css draws.const NAME_FX_RE = /^name-fx-[a-z]{2,24}$/;const nameFxClass = (m) => (m && m.nameFX && NAME_FX_RE.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null);++// The hat they wear (m.hatFX: its emoji and Live's float/pulse/warp motion), just before the name (items.css .ov-hat).++const HAT_MOTION = ['float', 'pulse', 'warp'];++function hatHtml(m) {++ const h = m && m.hatFX;++ const glyph = h && typeof h.hatChar === 'string' ? h.hatChar.slice(0, 8) : '';++ if (!glyph) return '';++ return `<span class="ov-hat${HAT_MOTION.includes(h.animated) ? ` ov-hat-${h.animated}` : ''}" aria-hidden="true">${esc(glyph)}</span>`;++}function globalMessage(m) {const t = hhmm(m.timestamp);const handle = m.core_username || (m.user_id ? m.username : null);const fx = nameFxClass(m);const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');-- const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;++ const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;}@@@ -168,7 +176,12 @@ function createWebRoutes({ config }) {try {const warm = ctx.ensureDecor(shown.map((m) => m.user_id).filter(Boolean)).catch(() => {});await Promise.race([warm, new Promise((r) => { const tm = setTimeout(r, 250); if (tm.unref) tm.unref(); })]);-- for (const m of shown) if (m.user_id && !m.nameFX) { const fx = ctx.getCosmeticProfile(m.user_id).nameFX; if (fx) m.nameFX = fx; }++ for (const m of shown) {++ if (!m.user_id) continue;++ const prof = ctx.getCosmeticProfile(m.user_id);++ if (!m.nameFX && prof.nameFX) m.nameFX = prof.nameFX;++ if (!m.hatFX && prof.hatFX) m.hatFX = prof.hatFX;++ }} catch { /* the names stay plain */ }const composer = actor.kind === 'user'? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">@@@ -638,4 +651,4 @@ ${attachList}`,return router;}--module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass };++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml };@@@ -223,12 +223,13 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, withconst dave = h.addUser('dave', { subject: ids.newId('user') });const erin = h.addUser('erin', { subject: ids.newId('user') });for (const u of [dave, erin]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }-- h.live.decor.set(Number(dave.id), { cosmetic: { nameFX: { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' } }, tag: null });-- h.live.decor.set(Number(erin.id), { cosmetic: { nameFX: { itemId: 'x', cssClass: 'evil" onload="x' } }, tag: null });++ h.live.decor.set(Number(dave.id), { cosmetic: { nameFX: { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' }, hatFX: { itemId: 'hat_crown', hatChar: '👑', animated: 'pulse' } }, tag: null });++ h.live.decor.set(Number(erin.id), { cosmetic: { nameFX: { itemId: 'x', cssClass: 'evil" onload="x' }, hatFX: { itemId: 'y', hatChar: '<b>', animated: 'spin" x="' } }, tag: null });assert.strictEqual((await req('POST', '/send', form(dave, { message: 'wearing my rainbow' }))).status, 303);assert.strictEqual((await req('POST', '/send', form(erin, { message: 'and mine' }))).status, 303);const r = await req('GET', '/');-- assert.match(r.text, /<span class="ov-fx name-fx-rainbow">Dave<\/span>/, 'dave\'s name wears its effect');++ assert.match(r.text, /<span class="ov-hat ov-hat-pulse" aria-hidden="true">👑<\/span><span class="ov-fx name-fx-rainbow">Dave<\/span>/, 'dave\'s crown, then his name in its effect');++ assert.match(r.text, /<span class="ov-hat" aria-hidden="true"><b><\/span>Erin/, 'a hat is text, and an unknown motion is no class');assert.ok(!/onload=/.test(r.text) && !/ov-fx[^"]*">Erin/i.test(r.text) && /Erin/.test(r.text), 'a token of the wrong shape is never a class');assert.match(r.text, /<link rel="stylesheet" href="\/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);assert.strictEqual((await req('GET', '/shared/items.css')).status, 200);
@@ -57,9 +57,19 @@
@@ -57,9 +57,19 @@
}
}
// A name with the effect its person wears (m.nameFX from Live's decor; openvibe-shared items.css draws it).
// A name with the effect its person wears (m.nameFX from Live's decor; openvibe-shared items.css draws it).
+ // Their hat (m.hatFX) goes just before the name, outside the effect so a gradient cannot hide it.
function wearing(m, text) {
function wearing(m, text) {
+ var frag = document.createDocumentFragment();
+ var h = m && m.hatFX;
+ var glyph = h && typeof h.hatChar === 'string' ? h.hatChar.slice(0, 8) : '';
+ if (glyph) {
+ var hat = el('span', 'ov-hat' + (['float', 'pulse', 'warp'].indexOf(h.animated) >= 0 ? ' ov-hat-' + h.animated : ''), glyph);
+ hat.setAttribute('aria-hidden', 'true');
+ frag.appendChild(hat);
+ }
var cls = m && m.nameFX && /^name-fx-[a-z]{2,24}$/.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null;
var cls = m && m.nameFX && /^name-fx-[a-z]{2,24}$/.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null;
- return cls ? el('span', 'ov-fx ' + cls, text) : document.createTextNode(text);
+ frag.appendChild(cls ? el('span', 'ov-fx ' + cls, text) : document.createTextNode(text));
+ return frag;
}
}
// ── Global chat ──
// ── Global chat ──
@@ -79,12 +79,20 @@ function roleBadge(role) {
@@ -79,12 +79,20 @@ function roleBadge(role) {
// The name effect a person wears (OpenVibe.Inventory through Live's decor): a class openvibe-shared items.css draws.
// The name effect a person wears (OpenVibe.Inventory through Live's decor): a class openvibe-shared items.css draws.
const NAME_FX_RE = /^name-fx-[a-z]{2,24}$/;
const NAME_FX_RE = /^name-fx-[a-z]{2,24}$/;
const nameFxClass = (m) => (m && m.nameFX && NAME_FX_RE.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null);
const nameFxClass = (m) => (m && m.nameFX && NAME_FX_RE.test(String(m.nameFX.cssClass || '')) ? m.nameFX.cssClass : null);
+// The hat they wear (m.hatFX: its emoji and Live's float/pulse/warp motion), just before the name (items.css .ov-hat).
+const HAT_MOTION = ['float', 'pulse', 'warp'];
+function hatHtml(m) {
+ const h = m && m.hatFX;
+ const glyph = h && typeof h.hatChar === 'string' ? h.hatChar.slice(0, 8) : '';
+ if (!glyph) return '';
+ return `<span class="ov-hat${HAT_MOTION.includes(h.animated) ? ` ov-hat-${h.animated}` : ''}" aria-hidden="true">${esc(glyph)}</span>`;
+}
function globalMessage(m) {
function globalMessage(m) {
const t = hhmm(m.timestamp);
const t = hhmm(m.timestamp);
const handle = m.core_username || (m.user_id ? m.username : null);
const handle = m.core_username || (m.user_id ? m.username : null);
const fx = nameFxClass(m);
const fx = nameFxClass(m);
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
- const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
+ const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;
return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;
}
}
@@ -168,7 +176,12 @@ function createWebRoutes({ config }) {
@@ -168,7 +176,12 @@ function createWebRoutes({ config }) {
try {
try {
const warm = ctx.ensureDecor(shown.map((m) => m.user_id).filter(Boolean)).catch(() => {});
const warm = ctx.ensureDecor(shown.map((m) => m.user_id).filter(Boolean)).catch(() => {});
await Promise.race([warm, new Promise((r) => { const tm = setTimeout(r, 250); if (tm.unref) tm.unref(); })]);
await Promise.race([warm, new Promise((r) => { const tm = setTimeout(r, 250); if (tm.unref) tm.unref(); })]);
- for (const m of shown) if (m.user_id && !m.nameFX) { const fx = ctx.getCosmeticProfile(m.user_id).nameFX; if (fx) m.nameFX = fx; }
+ for (const m of shown) {
+ if (!m.user_id) continue;
+ const prof = ctx.getCosmeticProfile(m.user_id);
+ if (!m.nameFX && prof.nameFX) m.nameFX = prof.nameFX;
+ if (!m.hatFX && prof.hatFX) m.hatFX = prof.hatFX;
+ }
} catch { /* the names stay plain */ }
} catch { /* the names stay plain */ }
const composer = actor.kind === 'user'
const composer = actor.kind === 'user'
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
@@ -638,4 +651,4 @@ ${attachList}`,
@@ -638,4 +651,4 @@ ${attachList}`,
return router;
return router;
}
}
-module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass };
+module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass, hatHtml };
@@ -223,12 +223,13 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, with
@@ -223,12 +223,13 @@ t('what people wear: a name effect from Live\'s decor is drawn on the name, with
const dave = h.addUser('dave', { subject: ids.newId('user') });
const dave = h.addUser('dave', { subject: ids.newId('user') });
const erin = h.addUser('erin', { subject: ids.newId('user') });
const erin = h.addUser('erin', { subject: ids.newId('user') });
for (const u of [dave, erin]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
for (const u of [dave, erin]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
- h.live.decor.set(Number(dave.id), { cosmetic: { nameFX: { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' } }, tag: null });
- h.live.decor.set(Number(erin.id), { cosmetic: { nameFX: { itemId: 'x', cssClass: 'evil" onload="x' } }, tag: null });
+ h.live.decor.set(Number(dave.id), { cosmetic: { nameFX: { itemId: 'fx_rainbow', cssClass: 'name-fx-rainbow' }, hatFX: { itemId: 'hat_crown', hatChar: '👑', animated: 'pulse' } }, tag: null });
+ h.live.decor.set(Number(erin.id), { cosmetic: { nameFX: { itemId: 'x', cssClass: 'evil" onload="x' }, hatFX: { itemId: 'y', hatChar: '<b>', animated: 'spin" x="' } }, tag: null });
assert.strictEqual((await req('POST', '/send', form(dave, { message: 'wearing my rainbow' }))).status, 303);
assert.strictEqual((await req('POST', '/send', form(dave, { message: 'wearing my rainbow' }))).status, 303);
assert.strictEqual((await req('POST', '/send', form(erin, { message: 'and mine' }))).status, 303);
assert.strictEqual((await req('POST', '/send', form(erin, { message: 'and mine' }))).status, 303);
const r = await req('GET', '/');
const r = await req('GET', '/');
- assert.match(r.text, /<span class="ov-fx name-fx-rainbow">Dave<\/span>/, 'dave\'s name wears its effect');
+ assert.match(r.text, /<span class="ov-hat ov-hat-pulse" aria-hidden="true">👑<\/span><span class="ov-fx name-fx-rainbow">Dave<\/span>/, 'dave\'s crown, then his name in its effect');
+ assert.match(r.text, /<span class="ov-hat" aria-hidden="true"><b><\/span>Erin/, 'a hat is text, and an unknown motion is no class');
assert.ok(!/onload=/.test(r.text) && !/ov-fx[^"]*">Erin/i.test(r.text) && /Erin/.test(r.text), 'a token of the wrong shape is never a class');
assert.ok(!/onload=/.test(r.text) && !/ov-fx[^"]*">Erin/i.test(r.text) && /Erin/.test(r.text), 'a token of the wrong shape is never a class');
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);
assert.strictEqual((await req('GET', '/shared/items.css')).status, 200);
assert.strictEqual((await req('GET', '/shared/items.css')).status, 200);