openvibers/openvibe.shared ov-labels.js
ov-labels.js
| 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 | ; |
| 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} |
| 40 | html[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 |