openvibers/openvibe.shared ov-user.js

ov-user.js

main 18.8 KB · 274 lines · javascript

1/* The public user card (ask 287, the owner: "one user card … across all sites … level … live streams … like Discord, but
2 * way better"). Its look, 4.202.0: a banner from the person's own picture (blurred, tinted by their name's hue), the face
3 * with its ring (red while live), the name and @handle, Live now as the first action when they are, level / streak /
4 * badges / followers as chips, the sites they used this week, and the way to their profile and channel. Opened from a
5 * mention or a face (OVUser.bind), it sits in the one window (ov-window.js, a panel beside what was pressed; a sheet on
6 * a phone), loaded from beside this file.
7 * OVUser.card(host, { username }) -> { ready, refresh(), destroy() }
8 * Nothing mounts or intercepts clicks merely by loading this file. */
9(function (root, factory) {
10 'use strict';
11 const api = factory(root);
12 if (typeof module === 'object' && module.exports) module.exports = api;
13 else root.OVUser = api;
14})(typeof window !== 'undefined' ? window : globalThis, function (root) {
15 'use strict';
16 const NETWORK = 'https://openvibe.network';
17 const USER_REV = { 'ov-window.js': '1c4db9b0ce9c' };
18 const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
19 const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
20.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
21.ovu-banner::before { content: ""; position: absolute; inset: -30%; background: var(--ovu-bg, none) center / cover; filter: blur(26px) saturate(1.5); opacity: .75; }
22.ovu-banner::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, var(--ovw-bg, #111826) 55%, transparent)); }
23.ovu-head { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: end; gap: 12px; margin: -44px 16px 0; }
24.ovu-face { position: relative; display: grid; place-items: center; width: 76px; height: 76px; border-radius: 50%; overflow: hidden; background: hsl(var(--ovu-h, 220) 55% 40%); color: #fff; font-size: 30px; font-weight: 800; box-shadow: 0 0 0 4px var(--ovw-bg, var(--bg-secondary, #111826)); }
25.ovu-face img { width: 100%; height: 100%; object-fit: cover; }
26.ovu-face.is-live { box-shadow: 0 0 0 3px var(--live-red, #ef4444), 0 0 0 6px var(--ovw-bg, var(--bg-secondary, #111826)); }
27.ovu-names { min-width: 0; padding-bottom: 4px; }
28.ovu-name { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; font-weight: 800; letter-spacing: -.01em; }
29.ovu-handle { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 14px; }
30.ovu-state { margin: 0 16px; font-size: 13px; color: var(--text-muted, #8b98ad); }
31.ovu-live { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; margin: 0 16px; border-radius: 12px; background: var(--live-red, #ef4444); color: #fff; font-weight: 750; text-decoration: none; }
32.ovu-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: ovu-beat 1.4s ease-in-out infinite; }
33.ovu-live small { font-weight: 600; opacity: .85; }
34.ovu-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 16px; padding: 0; list-style: none; }
35.ovu-chips li { display: inline-flex; align-items: baseline; gap: 5px; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
36.ovu-chips b { font-size: 14px; color: var(--text-primary, #e6edf7); }
37.ovu-sec { margin: 0 16px; }
38.ovu-sec h3 { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #8b98ad); }
39.ovu-sites { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
40.ovu-sites a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13px; font-weight: 600; }
41.ovu-sites a:hover { border-color: var(--accent, #3b82f6); }
42.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }
43.ovu-acts a { flex: 1; display: grid; place-items: center; min-height: 42px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: inherit; font-weight: 650; text-decoration: none; }
44.ovu-acts a:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
45.ovu-acts a:hover { filter: brightness(1.1); }
46.ovu-wait { display: grid; gap: 10px; padding: 0 16px 18px; }
47.ovu-wait i { display: block; height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovu-shine 1.2s ease-in-out infinite; }
48.ovu-wait i:first-child { width: 55%; height: 18px; }
49.ovu-wait i:nth-child(2) { width: 35%; }
50.ovu-wait i:nth-child(3) { width: 80%; }
51.ovu-none { margin: 0 16px; color: var(--text-secondary, #a8b3c4); }
52@keyframes ovu-beat { 50% { opacity: .35; transform: scale(.75); } }
53@keyframes ovu-shine { 50% { opacity: .45; } }
54html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i { animation: none; }`;
55 function styles(doc) { if (doc.getElementById('ovu-styles')) return; const st = doc.createElement('style'); st.id = 'ovu-styles'; st.textContent = CSS; doc.head.appendChild(st); }
56 // The banner's hue: from the name, so a person's card always wears the same colour.
57 const hueOf = (name) => { let h = 0; for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; };
58 const compact = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
59 function needWindow() {
60 return new Promise((ok) => {
61 if (root.OVWindow) return ok(root.OVWindow);
62 const m = /^(https?:\/\/[^?#]*\/)ov-user(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
63 if (!m || typeof document === 'undefined') return ok(null);
64 const sc = document.createElement('script');
65 sc.src = m[1] + 'ov-window.js' + (USER_REV['ov-window.js'] ? '?v=' + USER_REV['ov-window.js'] : '');
66 sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
67 document.head.appendChild(sc);
68 });
69 }
70 const mounts = new WeakMap();
71 const bindings = new WeakMap();
72 const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;
73 function safeUrl(value) {
74 try {
75 const u = new URL(value);
76 return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;
77 } catch { return null; }
78 }
79 function element(doc, tag, text, className) {
80 const el = doc.createElement(tag);
81 if (text !== undefined) el.textContent = String(text);
82 if (className) el.className = className;
83 return el;
84 }
85 function link(doc, label, url) {
86 const safe = safeUrl(url);
87 if (!safe) return null;
88 const a = element(doc, 'a', label, 'ovk-btn ovk-btn--quiet');
89 a.href = safe;
90 return a;
91 }
92 function render(section, person) {
93 const doc = section.ownerDocument;
94 section.replaceChildren();
95 section.setAttribute('aria-label', `User @${person.username}`);
96 section.style.setProperty('--ovu-h', String(hueOf(person.username)));
97 const avatar = safeUrl(person.avatar_url);
98 const banner = element(doc, 'div', undefined, 'ovu-banner');
99 if (avatar && !/["'()\\\s]/.test(avatar)) banner.style.setProperty('--ovu-bg', `url("${avatar}")`);
100 section.appendChild(banner);
101 const head = element(doc, 'div', undefined, 'ovu-head');
102 const live = person.live && person.live.now === true;
103 const face = element(doc, 'span', undefined, `ovu-face${live ? ' is-live' : ''}`);
104 if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }
105 else face.textContent = person.username[0].toUpperCase();
106 const names = element(doc, 'div', undefined, 'ovu-names');
107 names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));
108 names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));
109 head.append(face, names);
110 section.appendChild(head);
111 if (live) {
112 const a = link(doc, 'Live now', person.live.url);
113 if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }
114 } else if (person.live && person.live.now === false) section.appendChild(element(doc, 'p', 'Not live right now', 'ovu-state'));
115 const chips = element(doc, 'ul', undefined, 'ovu-chips');
116 for (const [key, label] of [['level', 'Level'], ['streak', 'day streak'], ['badges_count', 'badges'], ['followers', 'followers']]) {
117 const value = person[key];
118 if (!Number.isSafeInteger(value) || value < 0) continue;
119 const li = element(doc, 'li');
120 if (key === 'level') { li.append(label + ' '); li.appendChild(element(doc, 'b', value)); }
121 else { li.appendChild(element(doc, 'b', compact(value))); li.append(` ${key === 'streak' && value === 1 ? 'day streak' : key === 'badges_count' && value === 1 ? 'badge' : key === 'followers' && value === 1 ? 'follower' : label}`); }
122 chips.appendChild(li);
123 }
124 if (chips.childNodes.length) section.appendChild(chips);
125 if (Array.isArray(person.active_sites)) {
126 const list = element(doc, 'ul', undefined, 'ovu-sites');
127 for (const site of person.active_sites) {
128 if (!site || typeof site.name !== 'string') continue;
129 const a = link(doc, site.name, site.url);
130 if (a) { a.className = ''; const li = element(doc, 'li'); li.appendChild(a); list.appendChild(li); }
131 }
132 if (list.childNodes.length) { const sec = element(doc, 'div', undefined, 'ovu-sec'); sec.append(element(doc, 'h3', 'This week'), list); section.appendChild(sec); }
133 }
134 const acts = element(doc, 'div', undefined, 'ovu-acts');
135 for (const [label, url] of [['Profile', person.profile_url], ['Channel', person.channel_url]]) {
136 const a = link(doc, label, url);
137 if (a) { a.className = ''; acts.appendChild(a); }
138 }
139 if (acts.childNodes.length) section.appendChild(acts);
140 }
141 function card(host, options = {}) {
142 const username = usernameOf(options.username);
143 if (!host?.ownerDocument || typeof host.replaceChildren !== 'function') throw new TypeError('OVUser.card: pass a host element');
144 if (!username) throw new TypeError('OVUser.card: use a username of 3–24 letters, digits or underscores');
145 mounts.get(host)?.destroy();
146 const doc = host.ownerDocument;
147 styles(doc);
148 const section = element(doc, 'section', undefined, 'ovu');
149 section.setAttribute('data-ov-user-card', '');
150 host.replaceChildren(section);
151 let generation = 0, controller, disposed = false;
152 const profile = `${NETWORK}/@${encodeURIComponent(username)}`;
153 const api = {
154 ready: null,
155 refresh() {
156 if (disposed) return Promise.resolve(null);
157 const ticket = ++generation;
158 controller?.abort();
159 const request = controller = new AbortController();
160 const timer = setTimeout(() => request.abort(), 8000);
161 const status = element(doc, 'div', undefined, 'ovu-wait'); status.setAttribute('role', 'status'); status.setAttribute('aria-label', 'Loading the profile');
162 status.append(element(doc, 'i'), element(doc, 'i'), element(doc, 'i'));
163 section.style.setProperty('--ovu-h', String(hueOf(username)));
164 section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
165 const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));
166 const url = `${String(options.networkUrl || NETWORK).replace(/\/+$/, '')}/api/v1/people/${encodeURIComponent(username)}/card`;
167 api.ready = Promise.resolve().then(() => fetcher(url, { credentials: 'omit', cache: 'no-store',
168 headers: { Accept: 'application/json' }, signal: request.signal }))
169 .then(async response => {
170 if (!response.ok) throw new Error('Profile unavailable');
171 const person = await response.json();
172 if (!person || !usernameOf(person.username)) throw new Error('Profile unavailable');
173 if (!disposed && ticket === generation) render(section, person);
174 return person;
175 }).catch(() => {
176 if (!disposed && ticket === generation) {
177 const message = element(doc, 'p', 'The profile did not load.', 'ovu-none'); message.setAttribute('role', 'status');
178 const acts = element(doc, 'div', undefined, 'ovu-acts'); const a = link(doc, 'Open the profile', profile); if (a) { a.className = ''; acts.appendChild(a); }
179 section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), message, acts);
180 }
181 return null;
182 }).finally(() => {
183 clearTimeout(timer);
184 if (!disposed && ticket === generation) section.removeAttribute('aria-busy');
185 });
186 return api.ready;
187 },
188 destroy() {
189 if (disposed) return;
190 disposed = true; generation++; controller?.abort();
191 section.remove();
192 if (mounts.get(host) === api) mounts.delete(host);
193 },
194 };
195 mounts.set(host, api);
196 api.refresh();
197 return api;
198 }
199 /** Delegated hook: marked mentions/avatars and profile links; call only when the site opts in. */
200 function bind(scope, options = {}) {
201 if (!scope?.addEventListener) throw new TypeError('OVUser.bind: pass a document or element');
202 bindings.get(scope)?.();
203 const doc = scope.ownerDocument || scope;
204 let close = null, disposed = false;
205 function open(username, trigger) {
206 close?.();
207 if (root.OVWindow || selfSrc) {
208 needWindow().then((W) => {
209 if (!W) { openDialog(username, trigger); return; }
210 const host = element(doc, 'div');
211 const mounted = card(host, { ...options, username });
212 let win = null;
213 const cleanup = () => { mounted.destroy(); if (win) { const w = win; win = null; w.close(); } if (close === cleanup) close = null; };
214 close = cleanup;
215 win = W.open({ id: 'ov-user-card', kind: 'panel', size: 'sm', bare: true, title: `@${username}`, body: host, from: trigger, onClose: () => { win = null; mounted.destroy(); if (close === cleanup) close = null; } });
216 });
217 return true;
218 }
219 return openDialog(username, trigger);
220 }
221 function openDialog(username, trigger) {
222 const dialog = element(doc, 'dialog');
223 if (typeof dialog.showModal !== 'function') return false;
224 dialog.setAttribute('aria-label', `User @${username}`);
225 const dismiss = element(doc, 'button', 'Close', 'ovk-btn ovk-btn--quiet'); dismiss.type = 'button';
226 const host = element(doc, 'div');
227 dialog.append(dismiss, host); doc.body.appendChild(dialog);
228 const mounted = card(host, { ...options, username });
229 const cleanup = () => {
230 mounted.destroy(); dialog.remove(); trigger.focus?.();
231 if (close === cleanup) close = null;
232 };
233 close = cleanup;
234 dismiss.addEventListener('click', cleanup);
235 dialog.addEventListener('cancel', cleanup);
236 dialog.addEventListener('close', cleanup);
237 dialog.addEventListener('click', event => { if (event.target === dialog) cleanup(); });
238 dialog.showModal();
239 return true;
240 }
241 function handle(event) {
242 if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
243 if (event.type === 'click' && event.button > 0) return;
244 const target = event.target?.closest?.('[data-ov-user], [data-username], a[href]');
245 if (!target || target.closest('[data-ov-user-card]')) return;
246 if (event.type === 'keydown' && (!['Enter', ' '].includes(event.key) || ['A', 'BUTTON'].includes(target.tagName) || event.repeat)) return;
247 let username = usernameOf(target.getAttribute('data-ov-user') || target.getAttribute('data-username'));
248 if (!username && target.tagName === 'A') {
249 try {
250 const base = doc.baseURI || NETWORK;
251 const url = new URL(target.getAttribute('href'), base);
252 if (!['https:', 'http:'].includes(url.protocol) || ![new URL(base).origin, NETWORK, 'https://openvibe.live'].includes(url.origin)) return;
253 const match = /^\/@([A-Za-z0-9_]{3,24})\/?$/.exec(url.pathname);
254 if (match) username = match[1];
255 } catch { return; }
256 }
257 if (!username) return;
258 if (typeof options.onOpen === 'function') {
259 event.preventDefault(); options.onOpen({ username, trigger: target, event });
260 } else if (open(username, target)) event.preventDefault();
261 }
262 for (const type of ['click', 'contextmenu', 'keydown']) scope.addEventListener(type, handle);
263 const cleanup = () => {
264 if (disposed) return;
265 disposed = true; close?.();
266 for (const type of ['click', 'contextmenu', 'keydown']) scope.removeEventListener(type, handle);
267 if (bindings.get(scope) === cleanup) bindings.delete(scope);
268 };
269 bindings.set(scope, cleanup);
270 return cleanup;
271 }
272 return { card, bind };
273});
274