openvibers/openvibe.community Commits

Community: names wear what their people wear — author names carry their subject, and openvibe-shared items.js draws their name effect (loaded only on pages that show someone) (#29)

OP OpenVibers committed 9 Oct 2026 · 7793771bf3 · 7 files, +97 −9

Changes

ddiff --git a/public/js/community.js b/public/js/community.js
iindex 31b34fa..49253ba 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -172,6 +172,28 @@
window.addEventListener('pagehide', function () { es.close(); }, { signal });
}
}
++ // ── What people wear ───────────────────────────────────────
++ // Names marked data-ov-subject wear their owner's name effect (openvibe-shared items.js, OpenVibe.Inventory; plan
++ // T21). The script and its stylesheet load only on a page that shows someone, then every page move decorates again.
++ function wearItems() {
++ var meta = document.querySelector('meta[name="ov-items"]');
++ if (!meta || !document.querySelector('[data-ov-subject]')) return;
++ if (window.OpenVibeItems) { window.OpenVibeItems.decorate(document); return; }
++ if (document.getElementById('ov-items-js')) return;
++ var urls = (meta.getAttribute('content') || '').split(' ');
++ if (urls.length !== 2) return;
++ var css = document.createElement('link');
++ css.rel = 'stylesheet';
++ css.href = urls[1];
++ document.head.appendChild(css);
++ var s = document.createElement('script');
++ s.id = 'ov-items-js';
++ s.src = urls[0];
++ s.onload = function () { if (window.OpenVibeItems) window.OpenVibeItems.decorate(document); };
++ document.head.appendChild(s);
++ }
++
wirePage();
-- document.addEventListener('ov:boost:load', wirePage);
++ wearItems();
++ document.addEventListener('ov:boost:load', function () { wirePage(); wearItems(); });
})();
ddiff --git a/server/app.js b/server/app.js
iindex ef0c53e..c510f75 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -98,7 +98,7 @@ async function createApp(opts = {}) {
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com', 'data:'],
// Screenshots serve from openvibe.media (which may 302 to object storage); avatars from Live/Network.
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
-- connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://cloudflareinsights.com'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.media', 'https://openvibe.events', 'https://inventory.openvibe.network', 'https://cloudflareinsights.com'], // inventory: what people wear (items.js)
// The Network's hidden /sso/check frame: how a visitor who is signed in elsewhere gets signed in here.
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 6c9df29..320f2a5 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -12,7 +12,7 @@ const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
--const { timeTag, fmtDate, num, avatarUrl, authorHtml } = require('./pages');
++const { timeTag, fmtDate, num, avatarUrl, authorHtml, wearName } = require('./pages');
const f = require('./forum');
const STYLE_LABELS = { forum: 'Forum', feed: 'Feed' };
@@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
function boardRow(sp) {
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const last = sp.last_post
-- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
++ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
@@@ -82,7 +82,7 @@ function topicRow(t, space) {
const base = `/s/${space.slug}/t/${t.slug}`;
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire-flame-curved' : 'fa-comment';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
-- const who = (a) => esc(a ? (a.display_name || a.username || 'someone') : 'someone');
++ const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
return `<tr class="${t.pinned ? 'sticky' : ''}">
<td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
<td class="topic-main">${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
@@@ -180,7 +180,7 @@ function authorPanel(p) {
const st = p.author_stats;
return `<aside class="postbit-author">
${pic ? `<img class="postbit-avatar" src="${esc(pic)}" alt="" width="64" height="64" loading="lazy">` : `<span class="postbit-avatar postbit-letter" aria-hidden="true">${initial}</span>`}
-- <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${esc(name)}</a>` : esc(name)}</span>
++ <span class="postbit-name">${a.username ? `<a href="https://openvibe.live/@${encodeURIComponent(a.username)}" rel="noopener">${wearName(a, name)}</a>` : wearName(a, name)}</span>
${a.is_ai ? '<span class="badge badge-ai" title="Written by AI, not by a person">AI</span>' : a.is_system ? '<span class="badge">OpenVibe</span>' : a.is_relay ? '<span class="badge badge-relay" title="Written on Discord and relayed here">Discord</span>' : ''}
${st ? `<dl class="postbit-stats"><div><dt>Posts</dt><dd>${num(st.posts)}</dd></div>${monthYear(st.first_post_at) ? `<div><dt>Since</dt><dd>${esc(monthYear(st.first_post_at))}</dd></div>` : ''}</dl>
${(st.ratings || []).length ? `<p class="postbit-ratings" aria-label="Ratings received">${st.ratings.map((r) => `<span title="${esc(r.label)}">${r.emoji} ${num(r.count)}</span>`).join(' ')}</p>` : ''}` : ''}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 728dd5a..3d4566b 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -138,6 +138,8 @@ function renderPage(o) {
`<link rel="stylesheet" href="${asset('css/community.css')}">`,
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
`<script src="${asset('js/community.js')}" defer></script>`,
++ // openvibe-shared items.js + items.css: community.js loads them only on a page that shows someone.
++ `<meta name="ov-items" content="${escapeHtml(ovServe.url('items.js'))} ${escapeHtml(ovServe.url('items.css'))}">`,
`<meta name="ov-boost" content="community@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].filter(Boolean).join('\n'),
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 23d8127..690eefe 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -53,6 +53,15 @@ function avatarUrl(p, size = 96) {
if (p.avatar_url) return /^https?:\/\//i.test(p.avatar_url) ? p.avatar_url : `${config.liveUrl}${p.avatar_url.startsWith('/') ? '' : '/'}${p.avatar_url}`;
return p.username ? `${config.networkUrl}/avatar/${encodeURIComponent(p.username)}?s=${size}` : null;
}
++// A person's Network subject: their name then wears what they wear (openvibe-shared items.js, loaded by community.js).
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++function wearerOf(p) {
++ if (!p || p.is_ai || p.is_relay || p.is_system || p.origin === 'ai') return null;
++ const s = p.subject || p.owner_subject || p.author_subject || null;
++ return SUBJECT_RE.test(String(s || '')) ? s : null;
++}
++/** A name as escaped text, inside a span marked with its person's subject when it has one (for items.js). */
++const wearName = (p, text) => { const w = wearerOf(p); return w ? `<span data-ov-subject="${esc(w)}">${esc(text)}</span>` : esc(text); };
function authorHtml(p, { link = true } = {}) {
const name = authorName(p);
const initial = esc(name.trim()[0] || '?').toUpperCase();
@@@ -63,7 +72,8 @@ function authorHtml(p, { link = true } = {}) {
const avatar = (avatarSrc || netAvatar)
? `<img class="avatar" src="${esc(avatarSrc || netAvatar)}" alt="" loading="lazy" width="22" height="22">`
: `<span class="avatar avatar-letter" aria-hidden="true">${initial}</span>`;
-- const inner = `${avatar}<span>${esc(name)}</span>`;
++ const wearer = wearerOf(p);
++ const inner = `${avatar}${wearer ? wearName(p, name) : `<span>${esc(name)}</span>`}`;
if (!link || !p.username) return `<span class="author">${inner}</span>`;
return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
}
@@@ -436,4 +446,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
--module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl };
++module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl, wearName };
ddiff --git a/test/forum-style.test.js b/test/forum-style.test.js
iindex e887518..9331ff6 100644
---- a/test/forum-style.test.js
++++ b/test/forum-style.test.js
@@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.last_post.author.username, 'sam');
const page = (await call('/s/general')).text;
-- assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && page.includes('Started by Alex'));
++ assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
});
ddiff --git a/test/wear-items.test.js b/test/wear-items.test.js
nnew file mode 100644
iindex 0000000..a5a2298
---- /dev/null
++++ b/test/wear-items.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * What people wear, on Community (plan T21 "equip everywhere"): a person's name carries their Network subject
++ * (data-ov-subject) so openvibe-shared items.js gives it their name effect; AI, relayed and system authors never do.
++ * Every page names items.js + items.css in <meta name="ov-items"> (community.js loads them only when a page shows
++ * someone), and the CSP lets the page read OpenVibe.Inventory's public equipped sets.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++const { authorHtml } = require('../server/render/pages');
++
++const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++
++(async () => {
++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++
++ await check('a person\'s name carries their subject; AI, relayed and system names do not', async () => {
++ const S = 'usr_01JZ0000000000000000000AAA';
++ assert.match(authorHtml({ subject: S, username: 'ana', display_name: 'Ana' }), new RegExp(`<span data-ov-subject="${S}">Ana</span>`));
++ assert.match(authorHtml({ owner_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a paste\'s owner');
++ assert.match(authorHtml({ author_subject: S, username: 'ana', display_name: 'Ana' }), /data-ov-subject=/, 'a comment\'s author');
++ for (const p of [{ subject: S, display_name: 'OpenVibe AI', is_ai: true }, { owner_subject: S, display_name: 'AI', origin: 'ai' }, { subject: S, display_name: 'x', is_relay: true }, { subject: S, display_name: 'OpenVibe', is_system: true }, { subject: 'not-a-subject', display_name: 'y' }]) {
++ assert.ok(!/data-ov-subject/.test(authorHtml(p, { link: false })), JSON.stringify(p));
++ }
++ });
++
++ await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {
++ const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
++ assert.strictEqual(ok.status, 303, ok.text);
++ const r = await t.get(ok.headers.get('location'));
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`<span data-ov-subject="${alex.subject_id}">Alex</span>`), `the author name is marked`);
++ assert.match(r.text, /<meta name="ov-items" content="\/shared\/items\.js\?v=[A-Za-z0-9_-]+ \/shared\/items\.css\?v=[A-Za-z0-9_-]+">/);
++ assert.match(r.headers.get('content-security-policy') || '', /connect-src[^;]*https:\/\/inventory\.openvibe\.network/);
++ const js = await t.get('/shared/items.js');
++ assert.strictEqual(js.status, 200, 'the site serves openvibe-shared items.js');
++ assert.strictEqual((await t.get('/shared/items.css')).status, 200);
++ });
++
++ await check('community.js loads items.js only when a page shows someone, and again after each page move', async () => {
++ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'community.js'), 'utf8');
++ assert.match(src, /querySelector\('\[data-ov-subject\]'\)/);
++ assert.match(src, /OpenVibeItems\.decorate\(document\)/);
++ assert.match(src, /ov:boost:load', function \(\) \{ wirePage\(\); wearItems\(\); \}/);
++ });
++
++ await t.close();
++ done();
++})();