openvibers/openvibe.sites Commits
openvibe-shared v2.20.0 (was v2.17.0) (#32)
Changes
@@@ -50,5 +50,5 @@ Report security issues through [SECURITY.md](SECURITY.md).<!-- versions:start -->- openvibe-contracts: v0.122.1--- openvibe-shared: v2.17.0++- openvibe-shared: v2.20.0<!-- versions:end -->@@@ -23,7 +23,7 @@"status.openvibe.network (status notice)"],"contracts": "openvibe-contracts v0.122.1: sites.json mirrors products.catalog() (scripts/sync-catalog.js)",-- "shared": "openvibe-shared v2.17.0",++ "shared": "openvibe-shared v2.20.0","deployed": true,"deployment": "Deployed with `sudo ovhost deploy sites --restart` (OpenVibe.Host strategy static-build since the 2026-09-27 cutover): /opt/openvibe.sites, nginx serves dist/<domain>/ through deploy/nginx/<domain>.conf; no unit, no port, no env file","features": [@@@ -143,7 +143,7 @@const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }-- if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';++ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';const found = NETWORK.find(n => n.id === service);if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;@@@ -7,26 +7,39 @@* OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)** It reads OpenVibe.Inventory's public equipped sets (GET /api/v1/equipped?subjects=…, 100 people per request, no-- * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets the-- * classes its items' renderers ask for:-- * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …): items.css draws it-- * Anything else is left alone. Inventory unreachable: names stay as they are. Only tokens of the known shape are ever-- * used as class names.++ * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets what its++ * items' renderers ask for:++ * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …) on the name: items.css draws it++ * live.hat.glyph@1 the hat's emoji just before the name (`ov-hat`, with its float/pulse/warp animation)++ * live.particle.css@1 a short burst of the item's characters when you point at or focus the name (`ov-px`)++ * network.badge.image@1 a community badge (the Workshop): its reviewed image just before the name (`ov-badge`),++ * straight from the equipped read's media_id, served by OpenVibe.Media++ * Hats and particles need their definitions (emoji, animation, characters): one public read of Live's definitions per++ * page, only when someone on it wears one. Anything else is left alone. Inventory unreachable: names stay as they++ * are. Only tokens of the known shape become class names; emoji and characters are text, never markup. A burst never++ * runs by itself, and never under reduced motion or the network's Calm setting.*/(function (root) {'use strict';const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const NAME_FX = /^name-fx-[a-z]{2,24}$/;++ const PX = /^px-[a-z]{2,24}$/;++ const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++ const HAT_MOTION = ['float', 'pulse', 'warp'];const BATCH = 100;const TTL_MS = 60 * 1000;const cache = new Map(); // subject → { at, slots }const waiting = new Map(); // subject → Promise++ let defsPromise = null; // Map(definition id → { art, attributes })const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');++ const getJson = async (url) => {++ const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });++ if (!r.ok) throw new Error(`inventory answered ${r.status}`);++ return r.json();++ };async function fetchSets(subjects) {-- const r = await fetch(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`, { credentials: 'omit', headers: { Accept: 'application/json' } });-- if (!r.ok) throw new Error(`inventory answered ${r.status}`);-- const body = await r.json();++ const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);const now = Date.now();const got = new Map();for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});@@@ -48,11 +61,89 @@return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));}-- /** Give one element the classes of a set of slots (removing an earlier name effect). */-- function apply(el, slots) {-- for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c)) el.classList.remove(c);-- for (const [key, v] of Object.entries(slots || {})) {-- if (key.split(':')[0] === 'live.name_effect' && v && NAME_FX.test(String(v.token || ''))) el.classList.add('ov-fx', v.token);++ /** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */++ function definitions() {++ if (!defsPromise) {++ defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)++ .then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))++ .catch(() => { defsPromise = null; return new Map(); });++ }++ return defsPromise;++ }++++ const slotOf = (slots, kind) => { for (const [key, v] of Object.entries(slots || {})) if (key.split(':')[0] === kind && v) return v; return null; };++ const calm = () => {++ const d = root.document && root.document.documentElement;++ if (d && d.getAttribute && d.getAttribute('data-ov-motion') === 'reduced') return true;++ return !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);++ };++++ /** A short burst of characters out of the name (pointer or keyboard focus); never under reduced motion. */++ function burst(el) {++ if (calm() || !el.isConnected || el.dataset.ovPxBusy) return;++ const chars = Array.from(el.dataset.ovPxChars || '').slice(0, 6);++ if (!chars.length) return;++ el.dataset.ovPxBusy = '1';++ for (let i = 0; i < 6; i++) {++ const p = el.ownerDocument.createElement('span');++ p.className = 'ov-px-p';++ p.setAttribute('aria-hidden', 'true');++ p.textContent = chars[i % chars.length];++ const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.6;++ p.style.setProperty('--px-dx', `${Math.round(Math.cos(angle) * 22)}px`);++ p.style.setProperty('--px-dy', `${Math.round(Math.sin(angle) * 16 - 10)}px`);++ p.style.left = `${Math.round(30 + Math.random() * 40)}%`;++ el.appendChild(p);++ setTimeout(() => p.remove(), 1900);++ }++ setTimeout(() => { delete el.dataset.ovPxBusy; }, 1200);++ }++ function onBurst(e) { burst(e.currentTarget); }++++ /** Give one element what a set of slots draws (removing what an earlier set drew). defs: Map from definitions(). */++ function apply(el, slots, defs) {++ for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c) || c === 'ov-px' || PX.test(c)) el.classList.remove(c);++ for (let prev = el.previousElementSibling; prev && prev.classList && (prev.classList.contains('ov-hat') || prev.classList.contains('ov-badge')); prev = el.previousElementSibling) prev.remove();++ el.removeEventListener('mouseenter', onBurst);++ el.removeEventListener('focusin', onBurst);++ delete el.dataset.ovPxChars;++++ const fx = slotOf(slots, 'live.name_effect');++ if (fx && NAME_FX.test(String(fx.token || ''))) el.classList.add('ov-fx', fx.token);++++ const badge = slotOf(slots, 'network.badge');++ if (badge && MEDIA_ID.test(String(badge.media_id || '')) && el.parentNode) {++ const b = el.ownerDocument.createElement('img');++ b.className = 'ov-badge';++ b.src = `${String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.media) || 'https://openvibe.media').replace(/\/+$/, '')}/o/${badge.media_id}`;++ b.alt = '';++ b.width = 16; b.height = 16;++ b.setAttribute('loading', 'lazy');++ b.setAttribute('aria-hidden', 'true');++ el.parentNode.insertBefore(b, el);++ }++++ const hat = slotOf(slots, 'live.hat');++ const hatDef = hat && defs && defs.get(hat.definition_id);++ const glyph = hatDef && String(hatDef.art.emoji || hatDef.attributes.hat_char || '').slice(0, 8);++ if (glyph && el.parentNode) {++ const h = el.ownerDocument.createElement('span');++ const motion = String(hatDef.attributes.animated || '');++ h.className = `ov-hat${HAT_MOTION.includes(motion) ? ` ov-hat-${motion}` : ''}`;++ h.setAttribute('aria-hidden', 'true');++ h.textContent = glyph;++ el.parentNode.insertBefore(h, el);++ }++++ const px = slotOf(slots, 'live.particle');++ const pxDef = px && defs && defs.get(px.definition_id);++ const token = String((px && px.token) || (pxDef && pxDef.art.token) || '');++ const chars = pxDef && String(pxDef.attributes.chars || '').slice(0, 24);++ if (PX.test(token) && chars) {++ el.classList.add('ov-px', token);++ el.dataset.ovPxChars = chars;++ el.addEventListener('mouseenter', onBurst);++ el.addEventListener('focusin', onBurst);}}@@@ -64,9 +155,11 @@for (const el of els) el.dataset.ovItems = '1';let sets;try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }-- for (const el of els) apply(el, sets.get(el.dataset.ovSubject));++ const needsDefs = [...sets.values()].some((slots) => slotOf(slots, 'live.hat') || slotOf(slots, 'live.particle'));++ const defs = needsDefs ? await definitions() : new Map();++ for (const el of els) apply(el, sets.get(el.dataset.ovSubject), defs);return els.length;}-- root.OpenVibeItems = { decorate, equipped, apply, _cache: cache };++ root.OpenVibeItems = { decorate, equipped, definitions, apply, burst, _cache: cache };})(typeof window !== 'undefined' ? window : globalThis);@@@ -478,10 +478,12 @@function resolveBrand() {const b = Object.assign({}, _config.brand || {});const host = currentHost().toLowerCase();-- let sub = null, tld = null, core = 'OpenVibe';++ // OpenRestream lives at openre.stream: its brand is one word, "OpenRe" + "stream" with no dot between, so it++ // reads as OpenRestream while the two colours still show the domain.++ let sub = null, tld = null, core = 'OpenVibe', joined = false;let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }-- else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; joined = true; }// Off-network hosts (localhost, previews): fall back to the service id.if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }// Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.@@@ -494,27 +496,29 @@if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;if (b.tld) tld = String(b.tld).toLowerCase();const subText = b.subLabel || (sub ? subLabel(sub) : null);-- const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);-- const name = b.name || [subText, core, tldText].filter(Boolean).join('.');++ const tldText = b.tldLabel || (joined ? tld : TLD_LABELS[tld] || titleCase(tld));++ const site = joined ? `${core}${tldText}` : `${core}.${tldText}`;++ const name = b.name || [subText, site].filter(Boolean).join('.');const icon = b.icon || _config.brandIcon || null;-- const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);-- return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };++ const variant = b.variant || (joined ? 'stream' : tld);++ return { sub, subText, core, tld, tldText, joined, site, name, short: subText || site, icon, variant, tag: b.tag || null, href: b.href || '/' };}/** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */function brandLinks(brand) {-- const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;++ const apex = brand.joined ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };-- return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };++ return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex };}function brandHTML(brand) {const to = brandLinks(brand);const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;const dot = '<span class="b-dot">.</span>';++ const join = brand.joined ? '' : dot;const text = brand.subText-- ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)-- : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);++ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, brand.joined ? brand.site : 'OpenVibe Network') + join + seg('b-tld', brand.tldText, to.tld, `All of ${brand.site}`)++ : seg('b-core', brand.core, to.core, brand.name) + join + seg('b-tld', brand.tldText, to.tld, brand.name);const mark = brand.icon? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;@@@ -40,7 +40,7 @@live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade',codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing',-- tips: 'Tips', vip: 'VIP', openre: 'OpenRe', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',++ tips: 'Tips', vip: 'VIP', openre: 'OpenRestream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', node: 'Node', zone: 'Zone', download: 'Download',video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest',website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',@@@ -7,7 +7,7 @@"name": "openvibe-sites","dependencies": {"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0"},"engines": {"node": ">=20"@@@ -197,9 +197,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.17.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",-- "integrity": "sha512-z0dDgGH9QqOoinQybWDEYfs4t8ewwFOgsiKxmNbNzWYlGXf5ZYAkfK+zExtFqO2EUBpRzC4MhZkTvF6ydvhltA==",++ "version": "2.20.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0",++ "integrity": "sha512-rE43C5ik80zixTb+cOGI8nNj2uoKGzIt4J25pPJ8CDFUAIqE5ZRYFfdXa9+dX2GlFQEjclruNbpAF+p8wli+Dw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -8,7 +8,7 @@},"dependencies": {"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0"},"engines": {"node": ">=20"
@@ -50,5 +50,5 @@ Report security issues through [SECURITY.md](SECURITY.md).
@@ -50,5 +50,5 @@ Report security issues through [SECURITY.md](SECURITY.md).
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.122.1
- openvibe-contracts: v0.122.1
-- openvibe-shared: v2.17.0
+- openvibe-shared: v2.20.0
<!-- versions:end -->
<!-- versions:end -->
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"status.openvibe.network (status notice)"
"status.openvibe.network (status notice)"
],
],
"contracts": "openvibe-contracts v0.122.1: sites.json mirrors products.catalog() (scripts/sync-catalog.js)",
"contracts": "openvibe-contracts v0.122.1: sites.json mirrors products.catalog() (scripts/sync-catalog.js)",
- "shared": "openvibe-shared v2.17.0",
+ "shared": "openvibe-shared v2.20.0",
"deployed": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy sites --restart` (OpenVibe.Host strategy static-build since the 2026-09-27 cutover): /opt/openvibe.sites, nginx serves dist/<domain>/ through deploy/nginx/<domain>.conf; no unit, no port, no env file",
"deployment": "Deployed with `sudo ovhost deploy sites --restart` (OpenVibe.Host strategy static-build since the 2026-09-27 cutover): /opt/openvibe.sites, nginx serves dist/<domain>/ through deploy/nginx/<domain>.conf; no unit, no port, no env file",
"features": [
"features": [
@@ -143,7 +143,7 @@
@@ -143,7 +143,7 @@
const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
- if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
+ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';
const found = NETWORK.find(n => n.id === service);
const found = NETWORK.find(n => n.id === service);
if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
@@ -7,26 +7,39 @@
@@ -7,26 +7,39 @@
* OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)
* OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)
*
*
* It reads OpenVibe.Inventory's public equipped sets (GET /api/v1/equipped?subjects=…, 100 people per request, no
* It reads OpenVibe.Inventory's public equipped sets (GET /api/v1/equipped?subjects=…, 100 people per request, no
- * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets the
- * classes its items' renderers ask for:
- * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …): items.css draws it
- * Anything else is left alone. Inventory unreachable: names stay as they are. Only tokens of the known shape are ever
- * used as class names.
+ * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets what its
+ * items' renderers ask for:
+ * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …) on the name: items.css draws it
+ * live.hat.glyph@1 the hat's emoji just before the name (`ov-hat`, with its float/pulse/warp animation)
+ * live.particle.css@1 a short burst of the item's characters when you point at or focus the name (`ov-px`)
+ * network.badge.image@1 a community badge (the Workshop): its reviewed image just before the name (`ov-badge`),
+ * straight from the equipped read's media_id, served by OpenVibe.Media
+ * Hats and particles need their definitions (emoji, animation, characters): one public read of Live's definitions per
+ * page, only when someone on it wears one. Anything else is left alone. Inventory unreachable: names stay as they
+ * are. Only tokens of the known shape become class names; emoji and characters are text, never markup. A burst never
+ * runs by itself, and never under reduced motion or the network's Calm setting.
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const NAME_FX = /^name-fx-[a-z]{2,24}$/;
const NAME_FX = /^name-fx-[a-z]{2,24}$/;
+ const PX = /^px-[a-z]{2,24}$/;
+ const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const HAT_MOTION = ['float', 'pulse', 'warp'];
const BATCH = 100;
const BATCH = 100;
const TTL_MS = 60 * 1000;
const TTL_MS = 60 * 1000;
const cache = new Map(); // subject → { at, slots }
const cache = new Map(); // subject → { at, slots }
const waiting = new Map(); // subject → Promise
const waiting = new Map(); // subject → Promise
+ let defsPromise = null; // Map(definition id → { art, attributes })
const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
+ const getJson = async (url) => {
+ const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });
+ if (!r.ok) throw new Error(`inventory answered ${r.status}`);
+ return r.json();
+ };
async function fetchSets(subjects) {
async function fetchSets(subjects) {
- const r = await fetch(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`, { credentials: 'omit', headers: { Accept: 'application/json' } });
- if (!r.ok) throw new Error(`inventory answered ${r.status}`);
- const body = await r.json();
+ const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);
const now = Date.now();
const now = Date.now();
const got = new Map();
const got = new Map();
for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
@@ -48,11 +61,89 @@
@@ -48,11 +61,89 @@
return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));
return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));
}
}
- /** Give one element the classes of a set of slots (removing an earlier name effect). */
- function apply(el, slots) {
- for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c)) el.classList.remove(c);
- for (const [key, v] of Object.entries(slots || {})) {
- if (key.split(':')[0] === 'live.name_effect' && v && NAME_FX.test(String(v.token || ''))) el.classList.add('ov-fx', v.token);
+ /** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */
+ function definitions() {
+ if (!defsPromise) {
+ defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)
+ .then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))
+ .catch(() => { defsPromise = null; return new Map(); });
+ }
+ return defsPromise;
+ }
+
+ const slotOf = (slots, kind) => { for (const [key, v] of Object.entries(slots || {})) if (key.split(':')[0] === kind && v) return v; return null; };
+ const calm = () => {
+ const d = root.document && root.document.documentElement;
+ if (d && d.getAttribute && d.getAttribute('data-ov-motion') === 'reduced') return true;
+ return !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ };
+
+ /** A short burst of characters out of the name (pointer or keyboard focus); never under reduced motion. */
+ function burst(el) {
+ if (calm() || !el.isConnected || el.dataset.ovPxBusy) return;
+ const chars = Array.from(el.dataset.ovPxChars || '').slice(0, 6);
+ if (!chars.length) return;
+ el.dataset.ovPxBusy = '1';
+ for (let i = 0; i < 6; i++) {
+ const p = el.ownerDocument.createElement('span');
+ p.className = 'ov-px-p';
+ p.setAttribute('aria-hidden', 'true');
+ p.textContent = chars[i % chars.length];
+ const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.6;
+ p.style.setProperty('--px-dx', `${Math.round(Math.cos(angle) * 22)}px`);
+ p.style.setProperty('--px-dy', `${Math.round(Math.sin(angle) * 16 - 10)}px`);
+ p.style.left = `${Math.round(30 + Math.random() * 40)}%`;
+ el.appendChild(p);
+ setTimeout(() => p.remove(), 1900);
+ }
+ setTimeout(() => { delete el.dataset.ovPxBusy; }, 1200);
+ }
+ function onBurst(e) { burst(e.currentTarget); }
+
+ /** Give one element what a set of slots draws (removing what an earlier set drew). defs: Map from definitions(). */
+ function apply(el, slots, defs) {
+ for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c) || c === 'ov-px' || PX.test(c)) el.classList.remove(c);
+ for (let prev = el.previousElementSibling; prev && prev.classList && (prev.classList.contains('ov-hat') || prev.classList.contains('ov-badge')); prev = el.previousElementSibling) prev.remove();
+ el.removeEventListener('mouseenter', onBurst);
+ el.removeEventListener('focusin', onBurst);
+ delete el.dataset.ovPxChars;
+
+ const fx = slotOf(slots, 'live.name_effect');
+ if (fx && NAME_FX.test(String(fx.token || ''))) el.classList.add('ov-fx', fx.token);
+
+ const badge = slotOf(slots, 'network.badge');
+ if (badge && MEDIA_ID.test(String(badge.media_id || '')) && el.parentNode) {
+ const b = el.ownerDocument.createElement('img');
+ b.className = 'ov-badge';
+ b.src = `${String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.media) || 'https://openvibe.media').replace(/\/+$/, '')}/o/${badge.media_id}`;
+ b.alt = '';
+ b.width = 16; b.height = 16;
+ b.setAttribute('loading', 'lazy');
+ b.setAttribute('aria-hidden', 'true');
+ el.parentNode.insertBefore(b, el);
+ }
+
+ const hat = slotOf(slots, 'live.hat');
+ const hatDef = hat && defs && defs.get(hat.definition_id);
+ const glyph = hatDef && String(hatDef.art.emoji || hatDef.attributes.hat_char || '').slice(0, 8);
+ if (glyph && el.parentNode) {
+ const h = el.ownerDocument.createElement('span');
+ const motion = String(hatDef.attributes.animated || '');
+ h.className = `ov-hat${HAT_MOTION.includes(motion) ? ` ov-hat-${motion}` : ''}`;
+ h.setAttribute('aria-hidden', 'true');
+ h.textContent = glyph;
+ el.parentNode.insertBefore(h, el);
+ }
+
+ const px = slotOf(slots, 'live.particle');
+ const pxDef = px && defs && defs.get(px.definition_id);
+ const token = String((px && px.token) || (pxDef && pxDef.art.token) || '');
+ const chars = pxDef && String(pxDef.attributes.chars || '').slice(0, 24);
+ if (PX.test(token) && chars) {
+ el.classList.add('ov-px', token);
+ el.dataset.ovPxChars = chars;
+ el.addEventListener('mouseenter', onBurst);
+ el.addEventListener('focusin', onBurst);
}
}
}
}
@@ -64,9 +155,11 @@
@@ -64,9 +155,11 @@
for (const el of els) el.dataset.ovItems = '1';
for (const el of els) el.dataset.ovItems = '1';
let sets;
let sets;
try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }
try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }
- for (const el of els) apply(el, sets.get(el.dataset.ovSubject));
+ const needsDefs = [...sets.values()].some((slots) => slotOf(slots, 'live.hat') || slotOf(slots, 'live.particle'));
+ const defs = needsDefs ? await definitions() : new Map();
+ for (const el of els) apply(el, sets.get(el.dataset.ovSubject), defs);
return els.length;
return els.length;
}
}
- root.OpenVibeItems = { decorate, equipped, apply, _cache: cache };
+ root.OpenVibeItems = { decorate, equipped, definitions, apply, burst, _cache: cache };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -478,10 +478,12 @@
@@ -478,10 +478,12 @@
function resolveBrand() {
function resolveBrand() {
const b = Object.assign({}, _config.brand || {});
const b = Object.assign({}, _config.brand || {});
const host = currentHost().toLowerCase();
const host = currentHost().toLowerCase();
- let sub = null, tld = null, core = 'OpenVibe';
+ // OpenRestream lives at openre.stream: its brand is one word, "OpenRe" + "stream" with no dot between, so it
+ // reads as OpenRestream while the two colours still show the domain.
+ let sub = null, tld = null, core = 'OpenVibe', joined = false;
let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
- else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
+ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; joined = true; }
// Off-network hosts (localhost, previews): fall back to the service id.
// Off-network hosts (localhost, previews): fall back to the service id.
if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
// Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
// Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
@@ -494,27 +496,29 @@
@@ -494,27 +496,29 @@
if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
if (b.tld) tld = String(b.tld).toLowerCase();
if (b.tld) tld = String(b.tld).toLowerCase();
const subText = b.subLabel || (sub ? subLabel(sub) : null);
const subText = b.subLabel || (sub ? subLabel(sub) : null);
- const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
- const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
+ const tldText = b.tldLabel || (joined ? tld : TLD_LABELS[tld] || titleCase(tld));
+ const site = joined ? `${core}${tldText}` : `${core}.${tldText}`;
+ const name = b.name || [subText, site].filter(Boolean).join('.');
const icon = b.icon || _config.brandIcon || null;
const icon = b.icon || _config.brandIcon || null;
- const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
- return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
+ const variant = b.variant || (joined ? 'stream' : tld);
+ return { sub, subText, core, tld, tldText, joined, site, name, short: subText || site, icon, variant, tag: b.tag || null, href: b.href || '/' };
}
}
/** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */
/** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */
function brandLinks(brand) {
function brandLinks(brand) {
- const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;
+ const apex = brand.joined ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;
if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };
if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };
- return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };
+ return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex };
}
}
function brandHTML(brand) {
function brandHTML(brand) {
const to = brandLinks(brand);
const to = brandLinks(brand);
const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
const dot = '<span class="b-dot">.</span>';
const dot = '<span class="b-dot">.</span>';
+ const join = brand.joined ? '' : dot;
const text = brand.subText
const text = brand.subText
- ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)
- : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);
+ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, brand.joined ? brand.site : 'OpenVibe Network') + join + seg('b-tld', brand.tldText, to.tld, `All of ${brand.site}`)
+ : seg('b-core', brand.core, to.core, brand.name) + join + seg('b-tld', brand.tldText, to.tld, brand.name);
const mark = brand.icon
const mark = brand.icon
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
@@ -40,7 +40,7 @@
@@ -40,7 +40,7 @@
live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',
live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',
blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade',
blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade',
codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing',
codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing',
- tips: 'Tips', vip: 'VIP', openre: 'OpenRe', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',
+ tips: 'Tips', vip: 'VIP', openre: 'OpenRestream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',
services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', node: 'Node', zone: 'Zone', download: 'Download',
services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', node: 'Node', zone: 'Zone', download: 'Download',
video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest',
video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest',
website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',
website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"name": "openvibe-sites",
"name": "openvibe-sites",
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0"
},
},
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -197,9 +197,9 @@
@@ -197,9 +197,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.17.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
- "integrity": "sha512-z0dDgGH9QqOoinQybWDEYfs4t8ewwFOgsiKxmNbNzWYlGXf5ZYAkfK+zExtFqO2EUBpRzC4MhZkTvF6ydvhltA==",
+ "version": "2.20.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0",
+ "integrity": "sha512-rE43C5ik80zixTb+cOGI8nNj2uoKGzIt4J25pPJ8CDFUAIqE5ZRYFfdXa9+dX2GlFQEjclruNbpAF+p8wli+Dw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
},
},
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.20.0"
},
},
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"