openvibers/openvibe.shared ov-labels.js

ov-labels.js

main 9.0 KB · 168 lines · javascript

1/* openvibe-shared/ov-labels.js: content labels (plan L1, 2026-10-31), each viewer's own choice applied in the page.
2 *
3 * A page marks what carries labels: data-ov-labels="nudity,18+" on a card, a player or a picture. Optionally:
4 * data-ov-labels-media on the child to cover (the thumbnail, the <video>), else the marked element itself;
5 * data-ov-labels-owner the poster, as their subject (usr_…) or as @username for a page that knows only the name:
6 * their own content always shows to them;
7 * data-ov-labels-opened on the thing's own page (a watch page, a picture's page): the person opened it themselves, so
8 * hide covers it as blur does instead of leaving the page empty.
9 * The navbar loads this file the first time such an element appears (navbar.js, like ov-glyphs.js). It reads the
10 * person's choices once from Network (GET /api/v1/me/label-prefs, network.label-prefs@1) with the navbar's token:
11 * show as it is (the web default, and what a signed-out visitor gets);
12 * blur the media is covered by a calm "Show" button naming the labels, until tapped (a playing video pauses);
13 * hide the element is not shown.
14 * Labels are choices, never bans: nothing is removed from the page for anyone else, and nothing here asks for
15 * anything. window.OVLabels: { apply(root?), prefs(), NAMES, LABELS }.
16 */
17(function (root) {
18 'use strict';
19 if (root.OVLabels) return;
20 const LABELS = ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers'];
21 const NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };
22 const SEL = '[data-ov-labels]';
23 const CACHE = 'ov_label_prefs';
24 const CACHE_MS = 5 * 60 * 1000;
25 let prefs = null;
26 let me = null;
27 let meName = null; // '@name', lower-cased: a page may name the poster by username
28 let loading = null;
29
30 const CSS = `
31[data-ov-labels-state="hide"]{display:none!important}
32.ov-labels-cover{position:relative;overflow:hidden}
33.ov-labels-cover>:not(.ov-labels-gate){filter:blur(26px) saturate(.6);transform:scale(1.04)}
34.ov-labels-gate{all:unset;box-sizing:border-box;position:absolute;inset:0;z-index:3;display:grid;place-content:center;justify-items:center;gap:6px;padding:12px;cursor:pointer;text-align:center;
35 font:600 .86rem/1.3 system-ui,sans-serif;color:#fff;background:color-mix(in srgb,#05070c 46%,transparent)}
36.ov-labels-gate b{font-weight:700;font-size:.78rem;letter-spacing:.02em;opacity:.86}
37.ov-labels-gate span{display:inline-flex;align-items:center;min-height:36px;padding:0 16px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.28)}
38.ov-labels-gate:hover span,.ov-labels-gate:focus-visible span{background:rgba(255,255,255,.26)}
39.ov-labels-gate:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:-4px}
40html[data-ov-motion=off] .ov-labels-cover>:not(.ov-labels-gate){transform:none}`;
41
42 function injectCSS() {
43 if (document.getElementById('ov-labels-css')) return;
44 const st = document.createElement('style');
45 st.id = 'ov-labels-css';
46 st.textContent = CSS;
47 (document.head || document.documentElement).appendChild(st);
48 }
49
50 const parse = (el) => String(el.getAttribute('data-ov-labels') || '').split(',').map((s) => s.trim()).filter((l) => LABELS.includes(l));
51
52 /** What this viewer sees for these labels: the strictest of their choices. */
53 function decide(labels, el) {
54 if (!prefs || !labels.length) return 'show';
55 const owner = el.getAttribute('data-ov-labels-owner');
56 if (owner && ((me && owner === me) || (meName && owner.charAt(0) === '@' && owner.toLowerCase() === meName))) return 'show';
57 let out = 'show';
58 for (const l of labels) {
59 if (prefs[l] === 'hide') return el.hasAttribute('data-ov-labels-opened') ? 'blur' : 'hide';
60 if (prefs[l] === 'blur') out = 'blur';
61 }
62 return out;
63 }
64
65 function cover(el, labels) {
66 const media = el.querySelector('[data-ov-labels-media]') || el;
67 if (media.querySelector(':scope > .ov-labels-gate')) return;
68 media.classList.add('ov-labels-cover');
69 if (getComputedStyle(media).position === 'static') media.style.position = 'relative';
70 const gate = document.createElement('button');
71 gate.type = 'button';
72 gate.className = 'ov-labels-gate';
73 const what = document.createElement('b');
74 what.textContent = labels.map((l) => NAMES[l]).join(' · ');
75 const show = document.createElement('span');
76 show.textContent = 'Show';
77 gate.append(what, show);
78 gate.setAttribute('aria-label', `Labelled ${what.textContent}. Show it`);
79 gate.addEventListener('click', (e) => {
80 e.preventDefault();
81 e.stopPropagation();
82 el.setAttribute('data-ov-labels-state', 'shown');
83 media.classList.remove('ov-labels-cover');
84 gate.remove();
85 });
86 media.prepend(gate);
87 const v = media.tagName === 'VIDEO' ? media : media.querySelector('video');
88 if (v && !v.paused) { try { v.pause(); } catch { /* */ } }
89 }
90
91 function uncover(el) {
92 const media = el.querySelector('[data-ov-labels-media]') || el;
93 media.classList.remove('ov-labels-cover');
94 const gate = media.querySelector(':scope > .ov-labels-gate');
95 if (gate) gate.remove();
96 }
97
98 /** Apply the choices to every marked element under root (the page by default). A revealed one stays revealed. */
99 function apply(scope) {
100 const base = scope && scope.querySelectorAll ? scope : document;
101 const list = base.matches && base.matches(SEL) ? [base] : [...base.querySelectorAll(SEL)];
102 for (const el of list) {
103 if (el.getAttribute('data-ov-labels-state') === 'shown') continue;
104 const labels = parse(el);
105 const state = decide(labels, el);
106 el.setAttribute('data-ov-labels-state', state);
107 if (state === 'blur') cover(el, labels); else uncover(el);
108 }
109 }
110
111 function readCache(subject) {
112 try {
113 const c = JSON.parse(sessionStorage.getItem(CACHE) || 'null');
114 return c && c.subject === subject && Date.now() - c.at < CACHE_MS ? c.prefs : null;
115 } catch { return null; }
116 }
117
118 async function load(session) {
119 const token = session && session.token;
120 const user = session && session.user;
121 me = user && (user.subject_id || user.subject || null);
122 meName = user && user.username ? `@${String(user.username).toLowerCase()}` : null;
123 if (!token) { prefs = null; apply(); return; }
124 const cached = readCache(me);
125 if (cached) { prefs = cached; apply(); return; }
126 if (loading) return loading;
127 const base = String((session && session.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
128 loading = fetch(`${base}/api/v1/me/label-prefs`, { headers: { Authorization: `Bearer ${token}` }, credentials: 'omit' })
129 .then((r) => (r.ok ? r.json() : null))
130 .then((body) => {
131 const p = body && body.prefs;
132 prefs = p && typeof p === 'object' ? p : null;
133 try { if (prefs) sessionStorage.setItem(CACHE, JSON.stringify({ subject: me, at: Date.now(), prefs })); } catch { /* */ }
134 })
135 .catch(() => { prefs = null; })
136 .finally(() => { loading = null; apply(); });
137 return loading;
138 }
139
140 function start() {
141 injectCSS();
142 // The navbar may have signed in before this file arrived (its session getter), or will say so (its event).
143 const N = root.OpenVibeNavbar;
144 const s = N && N.session;
145 if (s && s.token) load(s); else apply();
146 document.addEventListener('openvibe-navbar-auth', (e) => {
147 const d = (e && e.detail) || {};
148 try { sessionStorage.removeItem(CACHE); } catch { /* */ }
149 load({ token: d.token, user: d.user, apiBase: N && N.session && N.session.apiBase });
150 });
151 // Labelled things that arrive later (a feed's next page, a rail that loads as it scrolls).
152 if (typeof MutationObserver === 'function' && document.body) {
153 let soon = false;
154 const own = (n) => n.nodeType !== 1 || (n.classList && n.classList.contains('ov-labels-gate'));
155 new MutationObserver((records) => {
156 // Its own covers going in or out are not news.
157 if (records.every((r) => [...r.addedNodes, ...r.removedNodes].every(own))) return;
158 if (soon) return;
159 soon = true;
160 setTimeout(() => { soon = false; apply(); }, 120);
161 }).observe(document.body, { childList: true, subtree: true });
162 }
163 }
164
165 root.OVLabels = { apply, prefs: () => (prefs ? { ...prefs } : null), NAMES, LABELS };
166 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
167})(typeof window !== 'undefined' ? window : globalThis);
168