openvibers/openvibe.shared items.js

items.js

main 9.7 KB · 170 lines · javascript

1/**
2 * openvibe-shared/items.js — what people wear, on any OpenVibe site (plan T21 "equip everywhere"). Browser global
3 * `OpenVibeItems`; draw with /shared/items.css.
4 *
5 * <a href="…" data-ov-subject="usr_01…">Ana</a>
6 * OpenVibeItems.decorate(document) every [data-ov-subject] under the node, once per element
7 * OpenVibeItems.decorate(listEl) again after new names are added (only the new ones are read)
8 *
9 * It reads OpenVibe.Inventory's public equipped sets (GET /api/v1/equipped?subjects=…, 100 people per request, no
10 * credentials) and keeps each answer 60 s, so a page full of names costs one or two requests. Each name gets what its
11 * items' renderers ask for:
12 * live.name_effect.css@1 `ov-fx` + the token (name-fx-rainbow, …) on the name: items.css draws it
13 * live.hat.glyph@1 the hat's emoji just before the name (`ov-hat`, with its float/pulse/warp animation)
14 * live.particle.css@1 a short burst of the item's characters when you point at or focus the name (`ov-px`)
15 * network.badge.image@1 a community badge (the Workshop): its reviewed image just before the name (`ov-badge`),
16 * straight from the equipped read's media_id, served by OpenVibe.Media
17 * Hats and particles need their definitions (emoji, animation, characters): one public read of Live's definitions per
18 * page, only when someone on it wears one. Anything else is left alone. Inventory unreachable: names stay as they
19 * are. Only tokens of the known shape become class names; emoji and characters are text, never markup. A burst never
20 * runs by itself, and never under reduced motion or the network's Calm setting.
21 */
22(function (root) {
23 'use strict';
24 const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
25 const NAME_FX = /^name-fx-[a-z]{2,24}$/;
26 const PX = /^px-[a-z]{2,24}$/;
27 const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
28 const HAT_MOTION = ['float', 'pulse', 'warp'];
29 const BATCH = 100;
30 const TTL_MS = 60 * 1000;
31 const cache = new Map(); // subject → { at, slots }
32 const waiting = new Map(); // subject → Promise
33 let defsPromise = null; // Map(definition id → { art, attributes })
34 const api = () => String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.api) || 'https://inventory.openvibe.network').replace(/\/+$/, '');
35 const getJson = async (url) => {
36 const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json' } });
37 if (!r.ok) throw new Error(`inventory answered ${r.status}`);
38 return r.json();
39 };
40
41 async function fetchSets(subjects) {
42 const body = await getJson(`${api()}/api/v1/equipped?subjects=${subjects.map(encodeURIComponent).join(',')}`);
43 const now = Date.now();
44 const got = new Map();
45 for (const set of (body && body.equipped) || []) if (set && SUBJECT.test(set.subject)) got.set(set.subject, set.slots || {});
46 for (const s of subjects) cache.set(s, { at: now, slots: got.get(s) || {} });
47 }
48
49 /** Equipped slots for each subject: Map(subject → { 'kind:slot': { token, definition_id, … } }). */
50 async function equipped(subjects) {
51 const want = [...new Set(subjects)].filter((s) => SUBJECT.test(s));
52 const now = Date.now();
53 const missing = want.filter((s) => { const h = cache.get(s); return (!h || now - h.at > TTL_MS) && !waiting.has(s); });
54 for (let i = 0; i < missing.length; i += BATCH) {
55 const chunk = missing.slice(i, i + BATCH);
56 const p = fetchSets(chunk).catch(() => { for (const s of chunk) if (!cache.has(s)) cache.set(s, { at: Date.now(), slots: {} }); });
57 for (const s of chunk) waiting.set(s, p);
58 p.then(() => { for (const s of chunk) waiting.delete(s); });
59 }
60 await Promise.all(want.map((s) => waiting.get(s)).filter(Boolean));
61 return new Map(want.map((s) => [s, (cache.get(s) || { slots: {} }).slots]));
62 }
63
64 /** Live's item definitions (hats and particles carry their emoji, animation and characters there), once a page. */
65 function definitions() {
66 if (!defsPromise) {
67 defsPromise = getJson(`${api()}/api/v1/definitions?issuer=${encodeURIComponent('service:live')}&limit=500`)
68 .then((b) => new Map(((b && b.definitions) || []).map((d) => [d.id, { art: d.art || {}, attributes: d.attributes || {} }])))
69 .catch(() => { defsPromise = null; return new Map(); });
70 }
71 return defsPromise;
72 }
73
74 const slotOf = (slots, kind) => { for (const [key, v] of Object.entries(slots || {})) if (key.split(':')[0] === kind && v) return v; return null; };
75 // How far things move: 1 in full, .35 under Calm, 0 with motion off (<html data-ov-motion>, theme-loader.js).
76 const move = () => {
77 const d = root.document && root.document.documentElement, m = d && d.getAttribute ? d.getAttribute('data-ov-motion') : '';
78 if (m === 'off') return 0;
79 return m === 'reduced' || (!m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) ? 0.35 : 1;
80 };
81
82 /** A short burst of characters out of the name (pointer or keyboard focus): six in full, three a shorter way under
83 * Calm, none with motion off. */
84 function burst(el) {
85 const k = move();
86 if (!k || !el.isConnected || el.dataset.ovPxBusy) return;
87 const chars = Array.from(el.dataset.ovPxChars || '').slice(0, 6);
88 if (!chars.length) return;
89 el.dataset.ovPxBusy = '1';
90 const far = k < 1 ? 0.5 : 1;
91 for (let i = 0; i < (k < 1 ? 3 : 6); i++) {
92 const p = el.ownerDocument.createElement('span');
93 p.className = 'ov-px-p';
94 p.setAttribute('aria-hidden', 'true');
95 p.textContent = chars[i % chars.length];
96 const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.6;
97 p.style.setProperty('--px-dx', `${Math.round(Math.cos(angle) * 22 * far)}px`);
98 p.style.setProperty('--px-dy', `${Math.round((Math.sin(angle) * 16 - 10) * far)}px`);
99 p.style.left = `${Math.round(30 + Math.random() * 40)}%`;
100 el.appendChild(p);
101 setTimeout(() => p.remove(), 1900);
102 }
103 setTimeout(() => { delete el.dataset.ovPxBusy; }, 1200);
104 }
105 function onBurst(e) { burst(e.currentTarget); }
106
107 /** Give one element what a set of slots draws (removing what an earlier set drew). defs: Map from definitions(). */
108 function apply(el, slots, defs) {
109 for (const c of [...el.classList]) if (c === 'ov-fx' || NAME_FX.test(c) || c === 'ov-px' || PX.test(c)) el.classList.remove(c);
110 for (let prev = el.previousElementSibling; prev && prev.classList && (prev.classList.contains('ov-hat') || prev.classList.contains('ov-badge')); prev = el.previousElementSibling) prev.remove();
111 el.removeEventListener('mouseenter', onBurst);
112 el.removeEventListener('focusin', onBurst);
113 delete el.dataset.ovPxChars;
114
115 const fx = slotOf(slots, 'live.name_effect');
116 if (fx && NAME_FX.test(String(fx.token || ''))) el.classList.add('ov-fx', fx.token);
117
118 const badge = slotOf(slots, 'network.badge');
119 if (badge && MEDIA_ID.test(String(badge.media_id || '')) && el.parentNode) {
120 const b = el.ownerDocument.createElement('img');
121 b.className = 'ov-badge';
122 b.src = `${String((root.OpenVibeItemsConfig && root.OpenVibeItemsConfig.media) || 'https://openvibe.media').replace(/\/+$/, '')}/o/${badge.media_id}`;
123 b.alt = '';
124 b.width = 16; b.height = 16;
125 b.setAttribute('loading', 'lazy');
126 b.setAttribute('aria-hidden', 'true');
127 el.parentNode.insertBefore(b, el);
128 }
129
130 const hat = slotOf(slots, 'live.hat');
131 const hatDef = hat && defs && defs.get(hat.definition_id);
132 const glyph = hatDef && String(hatDef.art.emoji || hatDef.attributes.hat_char || '').slice(0, 8);
133 if (glyph && el.parentNode) {
134 const h = el.ownerDocument.createElement('span');
135 const motion = String(hatDef.attributes.animated || '');
136 h.className = `ov-hat${HAT_MOTION.includes(motion) ? ` ov-hat-${motion}` : ''}`;
137 h.setAttribute('aria-hidden', 'true');
138 h.textContent = glyph;
139 el.parentNode.insertBefore(h, el);
140 }
141
142 const px = slotOf(slots, 'live.particle');
143 const pxDef = px && defs && defs.get(px.definition_id);
144 const token = String((px && px.token) || (pxDef && pxDef.art.token) || '');
145 const chars = pxDef && String(pxDef.attributes.chars || '').slice(0, 24);
146 if (PX.test(token) && chars) {
147 el.classList.add('ov-px', token);
148 el.dataset.ovPxChars = chars;
149 el.addEventListener('mouseenter', onBurst);
150 el.addEventListener('focusin', onBurst);
151 }
152 }
153
154 async function decorate(node) {
155 const scope = node || (root.document && root.document);
156 if (!scope || !scope.querySelectorAll) return 0;
157 const els = [...scope.querySelectorAll('[data-ov-subject]')].filter((el) => !el.dataset.ovItems && SUBJECT.test(el.dataset.ovSubject || ''));
158 if (!els.length) return 0;
159 for (const el of els) el.dataset.ovItems = '1';
160 let sets;
161 try { sets = await equipped(els.map((el) => el.dataset.ovSubject)); } catch { return 0; }
162 const needsDefs = [...sets.values()].some((slots) => slotOf(slots, 'live.hat') || slotOf(slots, 'live.particle'));
163 const defs = needsDefs ? await definitions() : new Map();
164 for (const el of els) apply(el, sets.get(el.dataset.ovSubject), defs);
165 return els.length;
166 }
167
168 root.OpenVibeItems = { decorate, equipped, definitions, apply, burst, _cache: cache };
169})(typeof window !== 'undefined' ? window : globalThis);
170