openvibers/openvibe.chat Commits
Chat: openvibe.chat draws the name effect each person wears (from Live's decor; openvibe-shared items.css), in the page and in live messages (#36)
Changes
@@@ -56,6 +56,12 @@clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);}++ // A name with the effect its person wears (m.nameFX from Live's decor; openvibe-shared items.css draws it).++ function wearing(m, text) {++ 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);++ }++// ── Global chat ──function globalItem(m) {var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';@@@ -63,7 +69,7 @@var handle = m.core_username || (m.user_id ? m.username : null);var name = m.display_name || m.username || m.anon_id || 'someone';var who;-- if (handle) { who = el('a', 'oc-name', name); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color); }++ if (handle) { who = el('a', 'oc-name'); who.appendChild(wearing(m, name)); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color); }else who = el('span', 'oc-name oc-anon', name);li.appendChild(who);var b = badge(m.role); if (b) li.appendChild(b);@@@ -176,7 +182,7 @@function roomItem(m) {var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));-- var who = el('a', 'oc-name', m.display_name || m.username || 'someone');++ var who = el('a', 'oc-name'); who.appendChild(wearing(m, m.display_name || m.username || 'someone'));who.href = LIVE + '/@' + encodeURIComponent(m.username || '');if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color);li.appendChild(who);@@@ -87,6 +87,7 @@ function renderPage(o) {${head}${appIcon.headTags({ site: 'network' })}<link rel="stylesheet" href="${asset('chat.css')}">++<link rel="stylesheet" href="${ovServe.url('items.css')}"><script src="${ovServe.url('theme-loader.js')}" defer></script><script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script>@@@ -76,10 +76,14 @@ function roleBadge(role) {if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';return '';}++// 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);function globalMessage(m) {const t = hhmm(m.timestamp);const handle = m.core_username || (m.user_id ? m.username : null);-- const name = esc(m.display_name || m.username || m.anon_id || 'someone');++ 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 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>`;@@@ -158,6 +162,14 @@ function createWebRoutes({ config }) {// Not the lines of people this reader blocked on the network (chat/network-blocks.js).const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));++ // What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The++ // page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just++ // stay plain until the cache is warm.++ 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; }++ } 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"><label for="oc-input" class="oc-sr">Message to everyone</label>@@@ -626,4 +638,4 @@ ${attachList}`,return router;}--module.exports = { createWebRoutes, apiBridge, globalMessage, linkify };++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass };@@@ -218,4 +218,24 @@ t('the page scripts present the session token in the first message of every sock}});++t('what people wear: a name effect from Live\'s decor is drawn on the name, with the shared stylesheet; a bad token never is', async () => {++ // Two people this suite has not seen yet, so nothing of theirs is in the decor cache.++ const 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 });++ 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.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);++ const { nameFxClass } = require('../server/web/pages');++ assert.strictEqual(nameFxClass({ nameFX: { cssClass: 'name-fx-galaxy' } }), 'name-fx-galaxy');++ assert.strictEqual(nameFxClass({ nameFX: { cssClass: 'NAME-fx-x' } }), null);++ assert.strictEqual(nameFxClass({}), null);++});++t.run(async () => { if (h && h.close) await h.close(); });
@@ -56,6 +56,12 @@
@@ -56,6 +56,12 @@
clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);
clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);
}
}
+ // A name with the effect its person wears (m.nameFX from Live's decor; openvibe-shared items.css draws it).
+ function wearing(m, text) {
+ 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);
+ }
+
// ── Global chat ──
// ── Global chat ──
function globalItem(m) {
function globalItem(m) {
var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';
var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';
@@ -63,7 +69,7 @@
@@ -63,7 +69,7 @@
var handle = m.core_username || (m.user_id ? m.username : null);
var handle = m.core_username || (m.user_id ? m.username : null);
var name = m.display_name || m.username || m.anon_id || 'someone';
var name = m.display_name || m.username || m.anon_id || 'someone';
var who;
var who;
- if (handle) { who = el('a', 'oc-name', name); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color); }
+ if (handle) { who = el('a', 'oc-name'); who.appendChild(wearing(m, name)); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color); }
else who = el('span', 'oc-name oc-anon', name);
else who = el('span', 'oc-name oc-anon', name);
li.appendChild(who);
li.appendChild(who);
var b = badge(m.role); if (b) li.appendChild(b);
var b = badge(m.role); if (b) li.appendChild(b);
@@ -176,7 +182,7 @@
@@ -176,7 +182,7 @@
function roomItem(m) {
function roomItem(m) {
var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
- var who = el('a', 'oc-name', m.display_name || m.username || 'someone');
+ var who = el('a', 'oc-name'); who.appendChild(wearing(m, m.display_name || m.username || 'someone'));
who.href = LIVE + '/@' + encodeURIComponent(m.username || '');
who.href = LIVE + '/@' + encodeURIComponent(m.username || '');
if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color);
if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.setProperty('--nc', m.profile_color);
li.appendChild(who);
li.appendChild(who);
@@ -87,6 +87,7 @@ function renderPage(o) {
@@ -87,6 +87,7 @@ function renderPage(o) {
${head}
${head}
${appIcon.headTags({ site: 'network' })}
${appIcon.headTags({ site: 'network' })}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${asset('chat.css')}">
+<link rel="stylesheet" href="${ovServe.url('items.css')}">
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
@@ -76,10 +76,14 @@ function roleBadge(role) {
@@ -76,10 +76,14 @@ function roleBadge(role) {
if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';
if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';
return '';
return '';
}
}
+// 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);
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 name = esc(m.display_name || m.username || m.anon_id || 'someone');
+ 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')}">${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>`;
@@ -158,6 +162,14 @@ function createWebRoutes({ config }) {
@@ -158,6 +162,14 @@ function createWebRoutes({ config }) {
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
+ // What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The
+ // page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just
+ // stay plain until the cache is warm.
+ 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; }
+ } 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">
<label for="oc-input" class="oc-sr">Message to everyone</label>
<label for="oc-input" class="oc-sr">Message to everyone</label>
@@ -626,4 +638,4 @@ ${attachList}`,
@@ -626,4 +638,4 @@ ${attachList}`,
return router;
return router;
}
}
-module.exports = { createWebRoutes, apiBridge, globalMessage, linkify };
+module.exports = { createWebRoutes, apiBridge, globalMessage, linkify, nameFxClass };
@@ -218,4 +218,24 @@ t('the page scripts present the session token in the first message of every sock
@@ -218,4 +218,24 @@ t('the page scripts present the session token in the first message of every sock
}
}
});
});
+t('what people wear: a name effect from Live\'s decor is drawn on the name, with the shared stylesheet; a bad token never is', async () => {
+ // Two people this suite has not seen yet, so nothing of theirs is in the decor cache.
+ const 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 });
+ 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.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);
+ const { nameFxClass } = require('../server/web/pages');
+ assert.strictEqual(nameFxClass({ nameFX: { cssClass: 'name-fx-galaxy' } }), 'name-fx-galaxy');
+ assert.strictEqual(nameFxClass({ nameFX: { cssClass: 'NAME-fx-x' } }), null);
+ assert.strictEqual(nameFxClass({}), null);
+});
+
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });