openvibers/openvibe.shared items.js
items.js
| 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 | ; |
| 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 |