openvibers/openvibe.network Commits

Network: a profile wears what its person wears — their name effect on their name (openvibe-shared items.css) and their hat on their picture (#100)

OP OpenVibers committed 9 Oct 2026 · b5e3e718b9 · 2 files, +19 −7

Changes

ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex 5d422e69..4902832a 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -28,6 +28,7 @@ const TIMEOUT_MS = 4000;
const MAX_ITEMS = 400; // two pages of Inventory's 200; the rest is one link away on OpenVibe.Inventory
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
++const NAME_FX_RE = /^name-fx-[a-z]{2,24}$/; // live.name_effect.css@1 tokens (openvibe-shared items.css)
const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
@@@ -217,6 +218,12 @@ ${sorted.map((g) => {
function renderPage(u, inv) {
const name = u.display_name || u.username;
const isPublic = !!inv;
++ // What they wear shows on the profile itself: the name effect on their name (items.css), the hat on the picture.
++ const worn = (kind) => (isPublic ? inv.showcase.find((i) => i.kind === kind) : null);
++ const fx = worn('live.name_effect');
++ const fxClass = fx && NAME_FX_RE.test(String((fx.art || {}).token || '')) ? fx.art.token : null;
++ const hat = worn('live.hat');
++ const hatGlyph = hat && (hat.art || {}).emoji ? String(hat.art.emoji).slice(0, 8) : null;
const color = COLOR_RE.test(String(u.profile_color || '')) ? u.profile_color : '#3b82f6';
const since = monthOf(u.created_at);
const bio = isPublic ? String(u.bio || '').trim().slice(0, 500) : '';
@@@ -253,6 +260,7 @@ ${isPublic ? '' : '<meta name="robots" content="noindex">'}
${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
<meta name="color-scheme" content="dark light">
<script src="/shared/theme-loader.js" defer></script>
++${fxClass ? '<link rel="stylesheet" href="/shared/items.css">' : ''}
${ld}
<style>
*,*::before,*::after{box-sizing:border-box}
@@@ -264,6 +272,7 @@ a{color:var(--accent)}
.pf-hero{position:relative;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg-secondary)}
.pf-banner{height:96px;background:linear-gradient(120deg,color-mix(in srgb,var(--pf-color) 70%,transparent),color-mix(in srgb,var(--pf-color) 18%,transparent) 60%,transparent),var(--bg-card)}
.pf-head{display:flex;gap:20px;align-items:flex-end;padding:0 24px 20px;margin-top:-52px;flex-wrap:wrap}
++.pf-pic{position:relative;flex:none}.pf-hat{position:absolute;top:-22px;left:50%;transform:translateX(-50%) rotate(-12deg);font-size:40px;line-height:1;filter:drop-shadow(0 3px 4px rgba(0,0,0,.45));pointer-events:none}
.pf-avatar{width:112px;height:112px;border-radius:50%;object-fit:cover;border:4px solid var(--bg-secondary);box-shadow:0 0 0 2px var(--pf-color);background:var(--bg-card);flex:none}
.pf-who{flex:1;min-width:200px;padding-top:56px}
.pf-who h1{margin:0;font-size:clamp(1.5rem,4vw,2.1rem);line-height:1.15;overflow-wrap:anywhere}
@@@ -296,7 +305,7 @@ a{color:var(--accent)}
.r-common{--rc:var(--r-common)}.r-uncommon{--rc:var(--r-uncommon)}.r-rare{--rc:var(--r-rare)}.r-epic{--rc:var(--r-epic)}.r-legendary{--rc:var(--r-legendary)}
.pf-empty{color:var(--text-secondary);padding:18px;border:1px dashed var(--border);border-radius:12px;margin:0}
.pf-note{color:var(--text-secondary);font-size:.85rem;margin:12px 0 0}
--@media (max-width:560px){.pf-head{padding:0 16px 16px;gap:14px}.pf-avatar{width:92px;height:92px}.pf-who{padding-top:0;min-width:100%}.pf-bio,.pf-stats,.pf-private{padding-left:16px;padding-right:16px}.pf-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}}
++@media (max-width:560px){.pf-head{padding:0 16px 16px;gap:14px}.pf-avatar{width:92px;height:92px}.pf-hat{font-size:32px;top:-18px}.pf-who{padding-top:0;min-width:100%}.pf-bio,.pf-stats,.pf-private{padding-left:16px;padding-right:16px}.pf-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}}
</style>
</head>
<body>
@@@ -306,8 +315,8 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
<section class="pf-hero" aria-label="Profile">
<div class="pf-banner" aria-hidden="true"></div>
<div class="pf-head">
--<img class="pf-avatar" src="${esc(avatarUrl(u.username))}" alt="" width="112" height="112">
--<div class="pf-who"><h1>${esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
++<div class="pf-pic"><img class="pf-avatar" src="${esc(avatarUrl(u.username))}" alt="" width="112" height="112">${hatGlyph ? `<span class="pf-hat" title="${esc(hat.name)}" aria-hidden="true">${esc(hatGlyph)}</span>` : ''}</div>
++<div class="pf-who"><h1>${fxClass ? `<span class="ov-fx ${fxClass}" title="${esc(fx.name)}">${esc(name)}</span>` : esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
</div>
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex 4e7912c4..ae63c94d 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -20,7 +20,7 @@ const KINDS = [
];
const DEFS = {
itd_01JZ00000000000000000001R1: { id: 'itd_01JZ00000000000000000001R1', kind: 'live.hat', name: 'Royal Crown', rarity: 'legendary', art: { emoji: '👑' } },
-- itd_01JZ00000000000000000001R2: { id: 'itd_01JZ00000000000000000001R2', kind: 'live.name_effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈' } },
++ itd_01JZ00000000000000000001R2: { id: 'itd_01JZ00000000000000000001R2', kind: 'live.name_effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈', token: 'name-fx-rainbow' } },
itd_01JZ00000000000000000001R3: { id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', name: 'Sparkles <b>', rarity: 'common', art: { emoji: '✨' } },
};
const INSTANCES = [
@@@ -59,7 +59,7 @@ const INSTANCES = [
const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
if (down) return json(503, { code: 'down' });
if (u.pathname === '/api/v1/kinds') return json(200, { kinds: KINDS });
-- if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id } } });
++ if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
return json(404, { code: 'inventory.unknown_subject' });
});
@@@ -80,7 +80,9 @@ const INSTANCES = [
// A public profile.
let r = await get('/@ana');
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('<h1>Ana</h1>') && r.text.includes('@Ana'));
++ assert.ok(r.text.includes('<h1><span class="ov-fx name-fx-rainbow" title="Rainbow">Ana</span></h1>') && r.text.includes('@Ana'), 'her name wears its effect');
++ assert.ok(r.text.includes('<link rel="stylesheet" href="/shared/items.css">'), 'with the shared effects stylesheet');
++ assert.ok(/<span class="pf-hat" title="Royal Crown" aria-hidden="true">👑<\/span>/.test(r.text), 'her hat sits on her picture');
assert.ok(!r.text.includes('<script>alert(1)</script>') && r.text.includes('I stream &lt;script&gt;alert(1)&lt;/script&gt; &amp; paint'), 'the bio is escaped');
assert.ok(r.text.includes('Sparkles &lt;b&gt;') && !r.text.includes('Sparkles <b>'), 'item names are escaped');
assert.ok(r.text.includes('id="pf-wearing"') && /pf-worn r-legendary[\s\S]*Royal Crown/.test(r.text), 'the crown is in the showcase');
@@@ -102,7 +104,7 @@ const INSTANCES = [
r = await get('/api/v1/profiles/Ana');
assert.strictEqual(r.status, 200);
const p = JSON.parse(r.text).profile;
-- assert.deepStrictEqual([p.username, p.private, p.color, p.member_since, p.items.count, p.showcase.map((s) => s.name)], ['Ana', false, '#ff0066', '2026-10-01', 3, ['Royal Crown']]);
++ assert.deepStrictEqual([p.username, p.private, p.color, p.member_since, p.items.count, p.showcase.map((s) => s.name)], ['Ana', false, '#ff0066', '2026-10-01', 3, ['Rainbow', 'Royal Crown']]);
assert.strictEqual(p.avatar_url, 'https://openvibe.network/avatar/Ana?s=160');
assert.strictEqual(p.inventory_url, `https://inventory.openvibe.network/u/${A}`);
assert.deepStrictEqual(p.items.by_kind.map((k) => k.kind).sort(), ['live.hat', 'live.name_effect', 'live.particle']);
@@@ -112,6 +114,7 @@ const INSTANCES = [
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('This profile is private.') && r.text.includes('name="robots" content="noindex"'));
assert.ok(!r.text.includes('secret bio') && !r.text.includes('Member since') && !r.text.includes('class="pf-grid"'));
++ assert.ok(r.text.includes('<h1>Hidden</h1>') && !r.text.includes('class="pf-hat"') && !r.text.includes('/shared/items.css'), 'a hidden profile wears nothing');
r = await get('/api/v1/profiles/hidden');
assert.deepStrictEqual(JSON.parse(r.text).profile, { username: 'hidden', display_name: 'Hidden', avatar_url: 'https://openvibe.network/avatar/hidden?s=160', profile_url: 'https://openvibe.network/@hidden', private: true });