openvibers/openvibe.shared profile-panel.js

profile-panel.js

main 10.0 KB · 207 lines · javascript

1/**
2 * openvibe-shared/profile-panel.js — a person's stuff on any OpenVibe site, Steam-style: what they wear, their item
3 * count, their Quest badges and their achievements in every game, with links to the full profile and inventory.
4 * Browser global `OpenVibeProfilePanel`; draw with /shared/profile-panel.css.
5 *
6 * <div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div>
7 * <script src="/shared/profile-panel.js" defer></script>
8 * OpenVibeProfilePanel.render(el, 'ana') or every [data-ov-profile] on the page, by itself, once loaded
9 * <section data-ov-profile="me" hidden>…</section> whoever is signed in on this site (its own /auth/me, the navbar's
10 * session lookup), so a cached page can show "your stuff"; shown
11 * only once drawn, left as it was for someone signed out;
12 * a hidden [data-ov-profile-section] around it (a heading and
13 * a line) is shown with it
14 *
15 * It reads OpenVibe.Network's public profile (GET https://openvibe.network/api/v1/profiles/:username: profile@1, CORS
16 * open, no credentials, cached 60 s by the Network), which gathers Inventory, Quest and Play. The panel replaces the
17 * element's content (a plain link is the no-JavaScript fallback). Every value is set as text, never markup; pictures
18 * load only from openvibe.media and openvibe.network. A private profile shows its name and picture only; an answer
19 * that does not come leaves the fallback as it was.
20 */
21(function (root) {
22 'use strict';
23 const NAME = /^[A-Za-z0-9_]{1,32}$/;
24 const MEDIA_ID = /^[A-Za-z0-9_-]{1,80}$/;
25 const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
26 const SAFE_HOST = /^(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/;
27 const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, '');
28 const seen = new Map(); // username \u2192 Promise(profile | null), once a page
29
30 function load(username) {
31 if (!seen.has(username)) {
32 // At most eight seconds, and a failure is not kept: a request held while Network restarted used to stay in
33 // this map for the page's life, so that person's panel never opened again (11-03, the Live name card).
34 const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined;
35 seen.set(username, fetch(`${api()}/api/v1/profiles/${encodeURIComponent(username)}`, { credentials: 'omit', headers: { Accept: 'application/json' }, signal })
36 .then((r) => (r.ok ? r.json() : null)).then((b) => (b && b.profile) || null)
37 .catch(() => { seen.delete(username); return null; }));
38 }
39 return seen.get(username);
40 }
41
42 function el(tag, cls, text) {
43 const n = root.document.createElement(tag);
44 if (cls) n.className = cls;
45 if (text != null) n.textContent = String(text);
46 return n;
47 }
48 /** Only https links on the network's own hosts (openvibe.* and its subdomains, openre.stream) become hrefs. */
49 function safeHref(href) {
50 try {
51 const u = new URL(String(href || ''));
52 return u.protocol === 'https:' && SAFE_HOST.test(u.hostname) ? u.href : null;
53 } catch { return null; }
54 }
55 function link(href, cls, text) {
56 const a = el('a', cls, text);
57 const h = safeHref(href);
58 if (h) a.href = h;
59 return a;
60 }
61 function stat(n, label) {
62 const li = el('li');
63 li.appendChild(el('b', null, Number(n) || 0));
64 li.appendChild(el('span', null, label));
65 return li;
66 }
67 function section(title, cls) {
68 const s = el('div', `ovpp-sec ${cls}`);
69 s.appendChild(el('h3', 'ovpp-h', title));
70 return s;
71 }
72 function itemArt(art) {
73 const a = art || {};
74 const box = el('span', 'ovpp-art');
75 if (a.media_id && MEDIA_ID.test(String(a.media_id))) {
76 const img = el('img');
77 img.src = `https://openvibe.media/o/${a.media_id}`;
78 img.alt = '';
79 img.loading = 'lazy';
80 img.width = 28; img.height = 28;
81 box.appendChild(img);
82 } else box.textContent = String(a.emoji || '\u25c6').slice(0, 8);
83 box.setAttribute('aria-hidden', 'true');
84 return box;
85 }
86
87 /** Show the drawn panel, and the section around it that waits for it ([data-ov-profile-section], hidden until then). */
88 function reveal(target) {
89 target.hidden = false;
90 const section = target.closest && target.closest('[data-ov-profile-section]');
91 if (section) section.hidden = false;
92 }
93
94 function draw(target, p) {
95 const card = el('section', 'ovpp');
96 card.setAttribute('aria-label', `${p.display_name || p.username} on OpenVibe`);
97 const head = link(p.profile_url, 'ovpp-head');
98 const pic = el('img', 'ovpp-pic');
99 pic.src = `${api()}/avatar/${encodeURIComponent(p.username)}?s=96`;
100 pic.alt = ''; pic.width = 48; pic.height = 48;
101 head.appendChild(pic);
102 const who = el('span', 'ovpp-who');
103 who.appendChild(el('b', null, p.display_name || p.username));
104 who.appendChild(el('small', null, `@${p.username}`));
105 head.appendChild(who);
106 card.appendChild(head);
107 if (p.private) {
108 card.appendChild(el('p', 'ovpp-note', 'This profile is private.'));
109 target.replaceChildren(card);
110 reveal(target);
111 return card;
112 }
113 const ach = p.achievements || { total: 0, games: [] };
114 const stats = el('ul', 'ovpp-stats');
115 stats.appendChild(stat((p.items && p.items.count) || 0, 'items'));
116 stats.appendChild(stat((p.badges || []).length, 'badges'));
117 stats.appendChild(stat(ach.total || 0, 'achievements'));
118 stats.appendChild(stat((ach.games || []).length, 'games'));
119 card.appendChild(stats);
120
121 if ((p.showcase || []).length) {
122 const s = section('Wearing', 'ovpp-wear');
123 const ul = el('ul', 'ovpp-chips');
124 p.showcase.slice(0, 6).forEach((i) => {
125 const li = el('li', `ovpp-chip r-${RARITY.includes(i.rarity) ? i.rarity : 'common'}`);
126 const a = link(i.url, null);
127 a.title = `${i.kind_name || ''} \u00b7 ${i.name}`;
128 a.appendChild(itemArt(i.art));
129 a.appendChild(el('span', null, i.name));
130 li.appendChild(a);
131 ul.appendChild(li);
132 });
133 s.appendChild(ul);
134 card.appendChild(s);
135 }
136 if ((ach.games || []).length) {
137 const s = section('Achievements', 'ovpp-ach');
138 const ul = el('ul', 'ovpp-list');
139 ach.games.slice(0, 3).forEach((g) => {
140 const li = el('li');
141 const a = link(g.url, 'ovpp-game', g.name);
142 li.appendChild(a);
143 li.appendChild(el('small', null, ` \u00b7 ${g.count} unlocked`));
144 const latest = (g.recent || [])[0];
145 if (latest) li.appendChild(el('span', 'ovpp-latest', `\u{1F3C6} ${latest.name}`));
146 ul.appendChild(li);
147 });
148 s.appendChild(ul);
149 card.appendChild(s);
150 }
151 if ((p.badges || []).length) {
152 const s = section('Badges', 'ovpp-badges');
153 const ul = el('ul', 'ovpp-chips');
154 p.badges.slice(0, 8).forEach((b) => ul.appendChild(el('li', 'ovpp-chip ovpp-badge', `\u{1F3C5} ${b.name}`)));
155 s.appendChild(ul);
156 card.appendChild(s);
157 }
158 const foot = el('p', 'ovpp-links');
159 foot.appendChild(link(p.profile_url, null, 'Full profile'));
160 if (p.inventory_url) { foot.appendChild(root.document.createTextNode(' \u00b7 ')); foot.appendChild(link(p.inventory_url, null, 'Inventory')); }
161 card.appendChild(foot);
162 target.replaceChildren(card);
163 reveal(target);
164 return card;
165 }
166
167 /** Draw `username`'s panel into `target`. → the panel, or null (the fallback stays) when there is no answer. */
168 /** The signed-in person's username from this site's own /auth/me (the navbar's session lookup), once a page. */
169 let mePromise = null;
170 function me() {
171 if (!mePromise) {
172 mePromise = fetch('/auth/me', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
173 .then((r) => (r.ok ? r.json() : null)).then((b) => {
174 const u = b && b.user;
175 const name = u && (u.username || u.name);
176 return NAME.test(String(name || '')) ? String(name) : null;
177 }).catch(() => null);
178 }
179 return mePromise;
180 }
181
182 async function render(target, username) {
183 // "me": whoever is signed in on this site; nobody signed in leaves the element as it was (hidden stays hidden).
184 const name = String(username || '') === 'me' ? await me() : String(username || '');
185 if (!target || !NAME.test(String(name || ''))) return null;
186 const p = await load(name);
187 return p ? draw(target, p) : null;
188 }
189
190 function renderAll(node) {
191 const scope = node || root.document;
192 scope.querySelectorAll('[data-ov-profile]').forEach((n) => {
193 if (n.dataset.ovProfileDone) return;
194 n.dataset.ovProfileDone = '1';
195 render(n, n.getAttribute('data-ov-profile'));
196 });
197 }
198
199 const out = { render, renderAll, _draw: draw };
200 root.OpenVibeProfilePanel = out;
201 if (typeof module !== 'undefined' && module.exports) module.exports = out;
202 if (root.document && root.document.addEventListener) {
203 if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => renderAll());
204 else renderAll();
205 }
206})(typeof window !== 'undefined' ? window : globalThis);
207