openvibers/openvibe.shared ov-share.js
ov-share.js
| 1 | /** |
| 2 | * openvibe-shared/ov-share.js — one Share sheet for anything on OpenVibe, on every site (plan track Q; the owner, |
| 3 | * 2026-11-06: share with named accounts only, give a thing away with the recipient's say-so, "for everything else too … |
| 4 | * a more universal system", and "a unified share system … more platforms … easy copying of a link … the phone's own share |
| 5 | * thing … intuitive, user-friendly"). The look is ov-share.css, linked from beside this file; docs/share.md has both. |
| 6 | * |
| 7 | * One sheet, two parts, never tabs: |
| 8 | * Send it for everyone: the link and Copy; on a phone, the phone's own share sheet (contacts, messages, apps); |
| 9 | * X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages, |
| 10 | * LinkedIn, Pinterest, Tumblr, Hacker News, LINE (recent ones first, the rest behind "More (n)"); a QR |
| 11 | * code made on the device; "Start at 1:23"; embed code. |
| 12 | * Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people |
| 13 | * (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer. |
| 14 | * |
| 15 | * A locked item (Locker, ov-locker.js) gets one more part between them: what its lock means in plain words, each |
| 16 | * person's eight-word fingerprint (and a warning when their key changed), and, for a people lock, adding someone (their |
| 17 | * content key is wrapped for them on this device) and removing someone (the content is sealed again under a fresh key, |
| 18 | * and the sheet says they keep what they already opened). A link lock's key is the part of the link after #, which |
| 19 | * browsers never send; the sheet never invents it, it copies the whole link. ov-locker.js is loaded on the first press |
| 20 | * of that part, never before. |
| 21 | * |
| 22 | * <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address |
| 23 | * data-url="https://…" the link to share (else the page's address) |
| 24 | * data-text="…" a line to send with it (email, the phone's sheet) |
| 25 | * data-image="https://…" a picture of it (Pinterest) |
| 26 | * data-embed="https://…" an embeddable player address (the Embed tile) |
| 27 | * data-time-from="#player" a <video> or <audio> whose current time offers "Start at …" (data-time-param, else t) |
| 28 | * data-kind="paste" data-id="abc" also "Who can open it", for its owner (data-base, data-path as below) |
| 29 | * data-base="https://…" the service's origin, when it is not this page's |
| 30 | * data-path="/api/v1/…" the share URL, when it is not /api/v1/<kind>/<id>/share |
| 31 | * |
| 32 | * OVShare.open({ url?, title?, text?, image?, embedUrl?, time?, timeParam?, kind?, id?, base?, path?, token?, headers?, onChange? }) |
| 33 | * opens the sheet; resolves with the saved sharing state, or null when nothing was saved |
| 34 | * OVShare.offer(el, { kind, id, transfer_id, from, expires_at, keep_access?, title?, base?, token?, onDone? }) |
| 35 | * the recipient's banner for a pending transfer: Accept, Decline |
| 36 | * OVShare.gate(el, { kind, id, signedInAs?, signInUrl?, base?, token?, onDone? }) |
| 37 | * the page a person who is not on the list sees: "Ask for access", or "Sign in" |
| 38 | * <div data-ov-share-gate data-kind="post" data-id="12" data-signed-in-as="usr_…" data-sign-in-url="/auth/login?next=…"> |
| 39 | * the same gate from markup, started when this file loads (and by OVShare.startGates() for markup added later): |
| 40 | * no inline script, so a page under a strict CSP needs nothing else, and every value goes through the page's |
| 41 | * own attribute escaping (data-base and data-path as for the sheet). The element's text is what shows before. |
| 42 | * |
| 43 | * The sheet only asks; the service decides. Who may see a thing is enforced by the service holding it (canView), never |
| 44 | * by this file. Every name and title is set as text, never as markup. No tracking is added to a shared link. |
| 45 | * |
| 46 | * Calls (openvibe-sdk/share's router); a 401, 403 or 404 on the first GET means "not the owner": the sheet sends only. |
| 47 | * GET <path> { audience, audiences, roles, you, owner, members, requests, transfer, title, link } + ETag |
| 48 | * PUT <path> If-Match { audience, members: [{ subject | name, role, expires_at? }], notify } → the same |
| 49 | * POST <path>/transfer { to, keep_access } POST <path>/transfer/cancel { transfer_id } |
| 50 | * POST <path>/transfer/accept POST <path>/transfer/decline { transfer_id } |
| 51 | * POST <path>/requests (ask for access) POST <path>/requests/<id>/dismiss |
| 52 | * GET <base>/api/v1/share/people?name=x { person: { subject, username, display_name, avatar_url } | null } |
| 53 | * Events on document: 'ov-share:change' { kind, id, state } after a save, a transfer or an answer; |
| 54 | * 'ov-share:sent' { target, url, kind, id } when the link goes somewhere (copy, x, device, qr …). |
| 55 | */ |
| 56 | (function (root) { |
| 57 | ; |
| 58 | if (!root || !root.document || root.OVShare) return; |
| 59 | const doc = root.document; |
| 60 | const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/; // Network's username rule |
| 61 | const AUDIENCES = { |
| 62 | public: { label: 'Everyone', title: 'Everyone', desc: 'Listed on the site and in search. Anyone can open it.' }, |
| 63 | link: { label: 'Link', title: 'Anyone with the link', desc: 'Not listed and not in search. Anyone with the link can open it.' }, |
| 64 | people: { label: 'People', title: 'Specific people', desc: 'Only you and the people below, signed in to OpenVibe.' }, |
| 65 | private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' }, |
| 66 | }; |
| 67 | const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' }; |
| 68 | // What each lock means, in the words the person choosing it reads (Locker design 2026-11-06: "OpenVibe can't read |
| 69 | // what's inside"). The same three sentences are on the page that makes a locked item. |
| 70 | const LOCK_WORDS = { |
| 71 | people: { |
| 72 | label: 'For the people on the list', |
| 73 | means: 'Only you and the people below can open it, each with their own keys. OpenVibe can’t read what’s inside.', |
| 74 | }, |
| 75 | password: { |
| 76 | label: 'With a password', |
| 77 | means: 'Anyone you give the password to can open it. OpenVibe can’t read what’s inside, and if the password is lost, nobody can open it, including us.', |
| 78 | }, |
| 79 | link: { |
| 80 | label: 'With the link', |
| 81 | means: 'The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.', |
| 82 | }, |
| 83 | }; |
| 84 | const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`; |
| 85 | const ICON = { |
| 86 | public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'), |
| 87 | link: svg('<path d="M10 14a4.2 4.2 0 0 0 6 0l3-3a4.2 4.2 0 0 0-6-6l-1 1"/><path d="M14 10a4.2 4.2 0 0 0-6 0l-3 3a4.2 4.2 0 0 0 6 6l1-1"/>'), |
| 88 | people: svg('<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19c.6-3.2 2.7-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="16.8" cy="9.5" r="2.5"/><path d="M15.8 14.2c2.4-.3 4.2 1.2 4.7 4"/>'), |
| 89 | private: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'), |
| 90 | close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'), |
| 91 | back: svg('<path d="M14.5 6 8.5 12l6 6"/>'), |
| 92 | copy: svg('<rect x="8.5" y="8.5" width="11" height="11" rx="2.2"/><path d="M15.5 8.5V6.7a2.2 2.2 0 0 0-2.2-2.2H6.7a2.2 2.2 0 0 0-2.2 2.2v6.6a2.2 2.2 0 0 0 2.2 2.2h1.8"/>'), |
| 93 | give: svg('<path d="M4 12h12M12 6l6 6-6 6"/><path d="M20 5v14"/>'), |
| 94 | tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'), |
| 95 | }; |
| 96 | |
| 97 | // ── Small helpers ───────────────────────────────────────────────────────────────────────────────────────── |
| 98 | const el = (tag, cls, attrs, text) => { |
| 99 | const n = doc.createElement(tag); |
| 100 | if (cls) n.className = cls; |
| 101 | if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]); |
| 102 | if (text != null) n.textContent = text; |
| 103 | return n; |
| 104 | }; |
| 105 | const iconEl = (name, cls) => { const s = el('span', cls || 'ov-share-ico'); s.innerHTML = ICON[name]; return s; }; // constants only |
| 106 | // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. |
| 107 | const reduce = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; |
| 108 | const cleanName = (s) => String(s || '').trim().replace(/^@+/, ''); |
| 109 | const sid = (s) => (!s ? '' : typeof s === 'string' ? s : `${s.type}:${s.id}`); |
| 110 | const same = (a, b) => !!a && !!b && sid(a) === sid(b); |
| 111 | const handle = (p) => (p && p.username ? `@${p.username}` : ''); |
| 112 | const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone'; |
| 113 | function when(iso, withYear) { |
| 114 | const d = new Date(iso); |
| 115 | if (!iso || isNaN(d)) return ''; |
| 116 | const o = { month: 'short', day: 'numeric' }; |
| 117 | if (withYear || d.getFullYear() !== new Date().getFullYear()) o.year = 'numeric'; |
| 118 | return d.toLocaleDateString(undefined, o); |
| 119 | } |
| 120 | function hue(s) { let h = 0; for (const c of String(s || '?')) h = (h * 31 + c.codePointAt(0)) % 360; return h; } |
| 121 | function avatar(p) { |
| 122 | const s = el('span', 'ov-share-av', { 'aria-hidden': 'true' }); |
| 123 | const initial = (nameOf(p).trim()[0] || '?').toUpperCase(); |
| 124 | s.style.setProperty('--ov-share-h', String(hue(p && (p.username || sid(p.subject))))); |
| 125 | const url = p && p.avatar_url; |
| 126 | if (url && /^(https:\/\/|\/(?!\/))/.test(url)) { |
| 127 | const img = el('img', null, { src: url, alt: '', loading: 'lazy', decoding: 'async', referrerpolicy: 'no-referrer' }); |
| 128 | img.addEventListener('error', () => { img.remove(); s.textContent = initial; }, { once: true }); |
| 129 | s.appendChild(img); |
| 130 | } else s.textContent = initial; |
| 131 | return s; |
| 132 | } |
| 133 | |
| 134 | // The look, once, from beside this script (a page or the navbar may have linked it already). |
| 135 | function stylesheet() { |
| 136 | if (doc.querySelector('link[href*="ov-share.css"]')) return; |
| 137 | const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]'); |
| 138 | const src = me && me.src ? me.src : '/shared/ov-share.js'; |
| 139 | doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: src.replace(/ov-share\.js(\?[^#]*)?.*$/, 'ov-share.css$1') })); |
| 140 | } |
| 141 | stylesheet(); |
| 142 | |
| 143 | // ── Talking to the service ──────────────────────────────────────────────────────────────────────────────── |
| 144 | function endpoint(o) { |
| 145 | const base = String(o.base || '').replace(/\/+$/, ''); |
| 146 | const path = o.path || `/api/v1/${encodeURIComponent(o.kind)}/${encodeURIComponent(o.id)}/share`; |
| 147 | return { base, path: base + path }; |
| 148 | } |
| 149 | async function call(o, method, url, body, extra) { |
| 150 | const headers = Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-share' }, o.headers || {}, extra || {}); |
| 151 | if (body !== undefined) headers['content-type'] = 'application/json'; |
| 152 | if (o.token) headers.authorization = `Bearer ${o.token}`; |
| 153 | const r = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: o.base ? 'include' : 'same-origin' }); |
| 154 | let j = null; |
| 155 | try { j = await r.json(); } catch (_) { /* an empty answer */ } |
| 156 | if (!r.ok) { |
| 157 | const e = new Error((j && (j.message || j.error_description)) || `The service answered ${r.status}`); |
| 158 | e.status = r.status; e.body = j; |
| 159 | throw e; |
| 160 | } |
| 161 | const out = j || {}; |
| 162 | const tag = r.headers && r.headers.get ? r.headers.get('etag') : null; |
| 163 | if (tag) Object.defineProperty(out, '__etag', { value: tag, enumerable: false }); |
| 164 | return out; |
| 165 | } |
| 166 | // One @name → a person, or null. undefined means the check itself could not run (the name is checked on save). |
| 167 | async function lookup(o, name) { |
| 168 | try { |
| 169 | const j = await call(o, 'GET', `${endpoint(o).base}/api/v1/share/people?name=${encodeURIComponent(name)}`); |
| 170 | return j.person && j.person.subject ? j.person : null; |
| 171 | } catch (e) { |
| 172 | if (e.status === 429) { const x = new Error('Too many name checks. Wait a minute, then try again.'); x.status = 429; throw x; } |
| 173 | return undefined; |
| 174 | } |
| 175 | } |
| 176 | function announce(o, state) { |
| 177 | doc.dispatchEvent(new CustomEvent('ov-share:change', { detail: { kind: o.kind, id: o.id, state } })); |
| 178 | if (typeof o.onChange === 'function') { try { o.onChange(state); } catch (_) { /* the page's problem */ } } |
| 179 | } |
| 180 | |
| 181 | // ── Send: the link, the device's own share sheet, the places people post it ────────────────────────────── |
| 182 | // No tracking is added to a link: the address shared is the address of the thing (with ?t= when asked). |
| 183 | const E = encodeURIComponent; |
| 184 | const phoneLike = () => !!(root.matchMedia && root.matchMedia('(pointer: coarse)').matches); |
| 185 | // [id, label, brand slug in brands.js (null: a glyph of our own), the link it opens (null: an action here)] |
| 186 | const TARGETS = [ |
| 187 | ['x', 'X', 'x', (u, t) => `https://x.com/intent/post?text=${E(t)}&url=${E(u)}`], |
| 188 | ['bluesky', 'Bluesky', 'bluesky', (u, t) => `https://bsky.app/intent/compose?text=${E(t ? `${t} ${u}` : u)}`], |
| 189 | ['whatsapp', 'WhatsApp', 'whatsapp', (u, t) => `https://wa.me/?text=${E(t ? `${t} ${u}` : u)}`], |
| 190 | ['reddit', 'Reddit', 'reddit', (u, t) => `https://www.reddit.com/submit?url=${E(u)}&title=${E(t)}`], |
| 191 | ['discord', 'Discord', 'discord', null], |
| 192 | ['telegram', 'Telegram', 'telegram', (u, t) => `https://t.me/share/url?url=${E(u)}&text=${E(t)}`], |
| 193 | ['threads', 'Threads', 'threads', (u, t) => `https://www.threads.net/intent/post?text=${E(t ? `${t} ${u}` : u)}`], |
| 194 | ['mastodon', 'Mastodon', 'mastodon', null], |
| 195 | ['facebook', 'Facebook', 'facebook', (u) => `https://www.facebook.com/sharer/sharer.php?u=${E(u)}`], |
| 196 | ['email', 'Email', null, (u, t, o) => `mailto:?subject=${E(t)}&body=${E(`${o.text ? `${o.text}\n\n` : ''}${u}`)}`], |
| 197 | ['sms', 'Messages', null, (u, t) => `sms:?&body=${E(t ? `${t} ${u}` : u)}`], |
| 198 | ['linkedin', 'LinkedIn', null, (u) => `https://www.linkedin.com/sharing/share-offsite/?url=${E(u)}`], |
| 199 | ['pinterest', 'Pinterest', 'pinterest', (u, t, o) => `https://www.pinterest.com/pin/create/button/?url=${E(u)}&media=${E(o.image)}&description=${E(t)}`], |
| 200 | ['tumblr', 'Tumblr', 'tumblr', (u, t) => `https://www.tumblr.com/widgets/share/tool?canonicalUrl=${E(u)}&title=${E(t)}`], |
| 201 | ['hn', 'Hacker News', 'ycombinator', (u, t) => `https://news.ycombinator.com/submitlink?u=${E(u)}&t=${E(t)}`], |
| 202 | ['line', 'LINE', 'line', (u) => `https://social-plugins.line.me/lineit/share?url=${E(u)}`], |
| 203 | ]; |
| 204 | const SHOWN = 8; // tiles before the counted fold ("More places (8)") |
| 205 | const remembered = (k, fallback) => { try { const v = JSON.parse(root.localStorage.getItem(k)); return v == null ? fallback : v; } catch (_) { return fallback; } }; |
| 206 | const remember = (k, v) => { try { root.localStorage.setItem(k, JSON.stringify(v)); } catch (_) { /* private mode */ } }; |
| 207 | function targetsFor(st) { |
| 208 | const o = st.o; |
| 209 | const list = TARGETS.filter(([id]) => (id !== 'pinterest' || o.image) && (id !== 'sms' || phoneLike())); |
| 210 | const recent = remembered('ov-share-recent', []).filter((id) => list.some((t) => t[0] === id)).slice(0, 3); |
| 211 | return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))]; |
| 212 | } |
| 213 | const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; }; |
| 214 | /** The key a link carries after #k= (a link lock): base64url, 32 bytes. The same rule as ov-locker.js. */ |
| 215 | function linkKey(href) { |
| 216 | const h = String(href == null ? root.location.hash : href).replace(/^#/, ''); |
| 217 | const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/) || String(root.location.hash || '').match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/); |
| 218 | return m ? m[1] : null; |
| 219 | } |
| 220 | function theLink(st) { |
| 221 | const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href; |
| 222 | let u; |
| 223 | try { u = new URL(raw, root.location.href); } catch (_) { return raw; } |
| 224 | if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time))); |
| 225 | // A link-locked item is opened by the part after #; the service's own link never has it, the page's address does. |
| 226 | const key = st.server && st.server.lock === 'link' ? linkKey() : null; |
| 227 | if (key && !linkKey(u.href)) u.hash = `k=${key}`; |
| 228 | return u.href; |
| 229 | } |
| 230 | const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, ''); |
| 231 | function sent(st, target) { |
| 232 | if (target !== 'copy') remember('ov-share-recent', [target, ...remembered('ov-share-recent', []).filter((x) => x !== target)].slice(0, 6)); |
| 233 | doc.dispatchEvent(new CustomEvent('ov-share:sent', { detail: { target, url: theLink(st), kind: st.o.kind || null, id: st.o.id || null } })); |
| 234 | } |
| 235 | // A short word after an action; quiet ones are only said to a screen reader (the button already shows "Copied"). |
| 236 | function toast(st, words, quiet) { |
| 237 | st.toastWords = words; st.toastQuiet = !!quiet; |
| 238 | const t = st.dlg.querySelector('.ov-share-toast'); |
| 239 | if (t) { t.textContent = words; t.classList.toggle('is-quiet', !!quiet); t.classList.remove('is-on'); void t.offsetWidth; t.classList.add('is-on'); } |
| 240 | clearTimeout(st.toastTimer); |
| 241 | st.toastTimer = setTimeout(() => { st.toastWords = ''; const n = st.dlg.querySelector('.ov-share-toast'); if (n) n.classList.remove('is-on'); }, 2200); |
| 242 | } |
| 243 | async function copyText(st, text) { |
| 244 | try { await root.navigator.clipboard.writeText(text); return true; } catch (_) { /* fall back below */ } |
| 245 | const t = el('textarea', 'ov-share-copybuf', { readonly: true, 'aria-hidden': 'true' }); |
| 246 | t.value = text; st.dlg.appendChild(t); t.select(); |
| 247 | let ok = false; |
| 248 | try { ok = doc.execCommand('copy'); } catch (_) { ok = false; } |
| 249 | t.remove(); |
| 250 | return ok; |
| 251 | } |
| 252 | // A file beside this one (brands.js, ov-qr.js), once; resolves when it has loaded, false when it could not. |
| 253 | const loading = {}; |
| 254 | function loadSibling(name, global) { |
| 255 | if (root[global]) return Promise.resolve(true); |
| 256 | if (loading[name]) return loading[name]; |
| 257 | const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]'); |
| 258 | const src = (me && me.src ? me.src : '/shared/ov-share.js').replace(/ov-share\.js(\?[^#]*)?.*$/, `${name}$1`); |
| 259 | loading[name] = new Promise((resolve) => { |
| 260 | const sc = el('script', null, { src, async: true }); |
| 261 | sc.onload = () => resolve(!!root[global]); |
| 262 | sc.onerror = () => { loading[name] = null; resolve(false); }; |
| 263 | doc.head.appendChild(sc); |
| 264 | }); |
| 265 | return loading[name]; |
| 266 | } |
| 267 | const GLYPH = { |
| 268 | email: '<rect x="3.5" y="5.5" width="17" height="13" rx="2.5"/><path d="m4.5 7.5 7.5 6 7.5-6"/>', |
| 269 | sms: '<path d="M5 5.5h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-7l-4.5 3.5V17.5H5a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"/>', |
| 270 | linkedin: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 10.5v5M8.5 8v.01M12 15.5v-5M12 13c0-1.5 1-2.5 2.2-2.5s1.8.8 1.8 2.3v2.7"/>', |
| 271 | device: '<path d="M12 3.5v11M8 7.5l4-4 4 4"/><path d="M6.5 11H6a2 2 0 0 0-2 2v5.5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V13a2 2 0 0 0-2-2h-.5"/>', |
| 272 | qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>', |
| 273 | embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>', |
| 274 | more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>', |
| 275 | ovchat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>', |
| 276 | ovcommunity: '<path d="M4 5.5h9.5A1.5 1.5 0 0 1 15 7v5a1.5 1.5 0 0 1-1.5 1.5H9l-3 2.5v-2.5H4A1.5 1.5 0 0 1 2.5 12V7A1.5 1.5 0 0 1 4 5.5z"/><path d="M18 9.5h2a1.5 1.5 0 0 1 1.5 1.5v4.5A1.5 1.5 0 0 1 20 17h-1v2.5L16 17h-3.5"/>', |
| 277 | }; |
| 278 | function tileIcon(id, slug) { |
| 279 | const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' }); |
| 280 | const b = slug && root.OVBrands && root.OVBrands.get(slug); |
| 281 | if (b) { s.style.setProperty('--ov-brand', b.hex); s.innerHTML = root.OVBrands.svg(slug, { size: 22, color: 'currentColor' }); s.classList.add('is-brand'); } // generated, CC0 |
| 282 | else if (GLYPH[id]) s.innerHTML = svg(GLYPH[id]); // constants |
| 283 | else s.textContent = id.slice(0, 1).toUpperCase(); |
| 284 | return s; |
| 285 | } |
| 286 | function tile(st, id, label, slug, href, onClick, i) { |
| 287 | const t = href |
| 288 | ? el('a', 'ov-share-tile', { href, target: /^(mailto|sms):/.test(href) ? null : '_blank', rel: 'noopener noreferrer', 'data-k': `to-${id}`, style: `--i:${i}` }) |
| 289 | : el('button', 'ov-share-tile', { type: 'button', 'data-k': `to-${id}`, style: `--i:${i}`, 'aria-expanded': onClick && st.panel === id ? 'true' : null }); |
| 290 | t.appendChild(tileIcon(id, slug)); |
| 291 | t.appendChild(el('span', 'ov-share-tilelabel', null, label)); |
| 292 | t.addEventListener('click', (e) => { if (onClick) { e.preventDefault(); onClick(); } else sent(st, id); }); |
| 293 | return t; |
| 294 | } |
| 295 | |
| 296 | function sendSection(st, body) { |
| 297 | const o = st.o; |
| 298 | const sec = el('section', 'ov-share-send', { 'aria-label': 'Send it' }); |
| 299 | const href = theLink(st); |
| 300 | // The link, and Copy: the one thing everybody can do. |
| 301 | const pill = el('div', `ov-share-linkpill${st.copied ? ' is-copied' : ''}`); |
| 302 | pill.appendChild(iconEl('link')); |
| 303 | const inp = el('input', 'ov-share-linktext', { type: 'text', readonly: true, 'aria-label': 'The link', 'data-k': 'link', value: shownLink(href) }); |
| 304 | inp.value = shownLink(href); |
| 305 | inp.addEventListener('focus', () => { inp.value = href; inp.select(); }); |
| 306 | inp.addEventListener('blur', () => { inp.value = shownLink(href); }); |
| 307 | pill.appendChild(inp); |
| 308 | const copy = el('button', 'ov-share-btn is-primary ov-share-copy', { type: 'button', 'data-k': 'copy' }); |
| 309 | copy.appendChild(iconEl(st.copied ? 'tick' : 'copy')); |
| 310 | copy.appendChild(el('span', null, null, st.copied ? 'Copied' : 'Copy')); |
| 311 | copy.addEventListener('click', async () => { |
| 312 | const ok = await copyText(st, href); |
| 313 | if (!ok) { toast(st, 'Select the link and copy it.'); inp.focus(); return; } |
| 314 | st.copied = true; sent(st, 'copy'); draw(st); toast(st, 'Link copied', true); |
| 315 | clearTimeout(st.copyTimer); |
| 316 | st.copyTimer = setTimeout(() => { st.copied = false; if (st.dlg.isConnected) draw(st); }, 1800); |
| 317 | }); |
| 318 | pill.appendChild(copy); |
| 319 | sec.appendChild(pill); |
| 320 | // Who the link opens for, when the owner is looking and it is not open to everyone. |
| 321 | if (st.server && owner(st) && (st.audience === 'people' || st.audience === 'private')) { |
| 322 | sec.appendChild(el('p', 'ov-share-muted ov-share-linkfor', null, st.audience === 'private' ? 'Only you can open this link. Choose who else can, below.' : 'Only you and the people below can open this link.')); |
| 323 | } |
| 324 | if (o.time > 0) { |
| 325 | const l = el('label', 'ov-share-check'); |
| 326 | const sw = el('input', null, { type: 'checkbox', role: 'switch', 'data-k': 'at-time' }); |
| 327 | sw.checked = !!st.atTime; |
| 328 | sw.addEventListener('change', () => { st.atTime = sw.checked; draw(st); }); |
| 329 | l.appendChild(sw); |
| 330 | l.appendChild(el('span', null, null, `Start at ${hms(o.time)}`)); |
| 331 | sec.appendChild(l); |
| 332 | } |
| 333 | // The device's own share sheet: on a phone, its contacts and every app; first, because it reaches the most. |
| 334 | const canDevice = !!(root.navigator && typeof root.navigator.share === 'function'); |
| 335 | if (canDevice && phoneLike()) { |
| 336 | const d = el('button', 'ov-share-device', { type: 'button', 'data-k': 'device' }); |
| 337 | d.appendChild(tileIcon('device')); |
| 338 | const w = el('span', 'ov-share-devicewords'); |
| 339 | w.appendChild(el('span', 'ov-share-devicetitle', null, 'Send with your phone')); |
| 340 | w.appendChild(el('span', 'ov-share-muted', null, 'Your contacts, messages and apps')); |
| 341 | d.appendChild(w); |
| 342 | d.addEventListener('click', () => device(st)); |
| 343 | sec.appendChild(d); |
| 344 | } |
| 345 | // Where people post it: a grid, the person's recent ones first, the rest behind a counted fold. |
| 346 | const all = targetsFor(st); |
| 347 | const extra = []; |
| 348 | if (canDevice && !phoneLike()) extra.push(['device', 'More apps', null, null, () => device(st)]); |
| 349 | extra.push(['qr', 'QR code', null, null, () => { st.panel = st.panel === 'qr' ? '' : 'qr'; draw(st); }]); |
| 350 | if (o.embedUrl) extra.push(['embed', 'Embed', null, null, () => { st.panel = st.panel === 'embed' ? '' : 'embed'; draw(st); }]); |
| 351 | const tiles = all.map(([id, label, slug, fn]) => { |
| 352 | if (id === 'discord') return [id, label, slug, null, async () => { const ok = await copyText(st, href); sent(st, id); toast(st, ok ? 'Link copied: paste it in Discord' : 'Copy the link, then paste it in Discord'); }]; |
| 353 | if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }]; |
| 354 | return [id, label, slug, fn(href, st.o.title || '', st.o), null]; |
| 355 | }); |
| 356 | // OpenVibe Chat (4.144.0): to someone on OpenVibe, first, for a signed-in person (the Dock is theirs). The sheet |
| 357 | // closes and the Dock opens on "Send to…"; nothing is sent until they press Send in the conversation. |
| 358 | const dock = root.OpenVibeDock && typeof root.OpenVibeDock.share === 'function' |
| 359 | && (typeof root.OpenVibeDock.canSend !== 'function' || root.OpenVibeDock.canSend()) ? root.OpenVibeDock : null; |
| 360 | const first = dock ? [['ovchat', 'OpenVibe Chat', null, null, () => { |
| 361 | sent(st, 'ovchat'); |
| 362 | close(st, true); |
| 363 | dock.share({ text: o.title ? `${o.title} ${href}` : href, title: o.title || '' }); |
| 364 | }]] : []; |
| 365 | // OpenVibe Community (4.179.0): start a topic about it. The link opens Community's new-topic form with the title |
| 366 | // and the link filled in; the person picks the forum, reads it over and posts it themselves. Not on Community's |
| 367 | // own pages, which have their own Discuss. |
| 368 | const onCommunity = /(^|\.)openvibe\.community$/.test(String(root.location && root.location.hostname || '')); |
| 369 | if (!onCommunity) { |
| 370 | const body = `${o.text ? `${o.text}\n\n` : ''}${href}`; |
| 371 | first.push(['ovcommunity', 'Community', null, `https://openvibe.community/forum/discuss?title=${E(String(o.title || '').slice(0, 200))}&body=${E(body.slice(0, 4000))}`, null]); |
| 372 | } |
| 373 | const room = SHOWN - extra.length - first.length; |
| 374 | const ordered = [...first, ...tiles.slice(0, room), ...extra, ...tiles.slice(room)]; |
| 375 | const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' }); |
| 376 | const show = st.allTargets ? ordered : ordered.slice(0, SHOWN); |
| 377 | show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i))); |
| 378 | if (!st.allTargets && ordered.length > SHOWN) { |
| 379 | const more = el('button', 'ov-share-tile is-more', { type: 'button', 'data-k': 'more', style: `--i:${SHOWN}`, 'aria-expanded': 'false' }); |
| 380 | more.appendChild(tileIcon('more')); |
| 381 | more.appendChild(el('span', 'ov-share-tilelabel', null, `More (${ordered.length - SHOWN})`)); |
| 382 | more.addEventListener('click', () => { st.allTargets = true; draw(st); }); |
| 383 | grid.appendChild(more); |
| 384 | } |
| 385 | sec.appendChild(grid); |
| 386 | if (st.panel === 'mastodon') sec.appendChild(mastodonPanel(st, href)); |
| 387 | if (st.panel === 'qr') sec.appendChild(qrPanel(st, href)); |
| 388 | if (st.panel === 'embed') sec.appendChild(embedPanel(st)); |
| 389 | body.appendChild(sec); |
| 390 | } |
| 391 | |
| 392 | async function device(st) { |
| 393 | const o = st.o; |
| 394 | const data = { title: o.title || doc.title, url: theLink(st) }; |
| 395 | if (o.text) data.text = o.text; |
| 396 | try { |
| 397 | if (root.navigator.canShare && !root.navigator.canShare(data)) throw new Error('cannot'); |
| 398 | await root.navigator.share(data); |
| 399 | sent(st, 'device'); toast(st, 'Sent'); |
| 400 | } catch (e) { |
| 401 | if (e && e.name === 'AbortError') return; // the person closed their phone's sheet |
| 402 | toast(st, 'Your device could not share it. Copy the link instead.'); |
| 403 | } |
| 404 | } |
| 405 | function mastodonPanel(st, href) { |
| 406 | const f = el('form', 'ov-share-panel', { novalidate: true, 'aria-label': 'Share on Mastodon' }); |
| 407 | f.appendChild(el('label', 'ov-share-label', { for: 'ov-share-masto' }, 'Your Mastodon server')); |
| 408 | const row = el('div', 'ov-share-add'); |
| 409 | const field = el('div', 'ov-share-addfield'); |
| 410 | const inp = el('input', null, { id: 'ov-share-masto', type: 'text', 'data-k': 'masto', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: 'mastodon.social', inputmode: 'url' }); |
| 411 | inp.value = remembered('ov-share-mastodon', ''); |
| 412 | field.appendChild(inp); |
| 413 | row.appendChild(field); |
| 414 | row.appendChild(el('button', 'ov-share-btn', { type: 'submit' }, 'Open')); |
| 415 | f.appendChild(row); |
| 416 | const msg = el('p', 'ov-share-addmsg', { 'aria-live': 'polite' }); |
| 417 | f.appendChild(msg); |
| 418 | f.addEventListener('submit', (e) => { |
| 419 | e.preventDefault(); |
| 420 | const host = inp.value.trim().replace(/^https?:\/\//i, '').replace(/^@?[^@]*@/, '').replace(/\/.*$/, '').toLowerCase(); |
| 421 | if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) { msg.textContent = 'Type the address of your server, like mastodon.social.'; msg.classList.add('is-bad'); return; } |
| 422 | remember('ov-share-mastodon', host); |
| 423 | root.open(`https://${host}/share?text=${E(st.o.title ? `${st.o.title} ${href}` : href)}`, '_blank', 'noopener,noreferrer'); |
| 424 | sent(st, 'mastodon'); |
| 425 | }); |
| 426 | setTimeout(() => inp.focus(), 0); |
| 427 | return f; |
| 428 | } |
| 429 | function qrPanel(st, href) { |
| 430 | const p = el('div', 'ov-share-panel ov-share-qr', { 'aria-label': 'QR code' }); |
| 431 | const art = el('div', 'ov-share-qrart', { role: 'img', 'aria-label': `A QR code for ${shownLink(href)}` }); |
| 432 | p.appendChild(art); |
| 433 | const side = el('div', 'ov-share-qrside'); |
| 434 | side.appendChild(el('p', 'ov-share-muted', null, 'Point a phone camera at it to open the link. It is made on this device: the link is not sent anywhere.')); |
| 435 | const dl = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'qr-save' }, 'Save picture'); |
| 436 | side.appendChild(dl); |
| 437 | p.appendChild(side); |
| 438 | loadSibling('ov-qr.js', 'OVQR').then((ok) => { |
| 439 | if (!ok) { art.textContent = 'The QR code could not load.'; dl.disabled = true; return; } |
| 440 | let markup; |
| 441 | try { markup = root.OVQR.svg(href, { ecc: 'M', margin: 2, dark: '#0b0f17', light: '#ffffff' }); } catch (_) { art.textContent = 'This link is too long for a QR code.'; dl.disabled = true; return; } |
| 442 | art.innerHTML = markup; // made by ov-qr.js from the link, no markup of the link's own |
| 443 | dl.addEventListener('click', () => { |
| 444 | const img = new Image(); |
| 445 | img.onload = () => { |
| 446 | const c = el('canvas'); c.width = c.height = 1024; |
| 447 | const g = c.getContext('2d'); g.imageSmoothingEnabled = false; g.drawImage(img, 0, 0, 1024, 1024); |
| 448 | c.toBlob((b) => { if (!b) return; const a = el('a', null, { href: URL.createObjectURL(b), download: `${(st.o.title || 'openvibe').replace(/[^\w.-]+/g, '-').slice(0, 60)}-qr.png` }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); sent(st, 'qr'); }, 'image/png'); |
| 449 | }; |
| 450 | img.src = `data:image/svg+xml;charset=utf-8,${E(markup)}`; |
| 451 | }); |
| 452 | }); |
| 453 | return p; |
| 454 | } |
| 455 | function embedPanel(st) { |
| 456 | const o = st.o; |
| 457 | const p = el('div', 'ov-share-panel', { 'aria-label': 'Embed' }); |
| 458 | // The code is pasted into somebody else's page: every attribute value is escaped as HTML. |
| 459 | const attr = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 460 | const t = attr(o.title || 'OpenVibe'); |
| 461 | let src; |
| 462 | try { src = new URL(o.embedUrl, root.location.href).href; } catch (_) { return p; } |
| 463 | if (st.atTime && o.time > 0) { const u = new URL(src); u.searchParams.set(o.timeParam || 't', String(Math.floor(o.time))); src = u.href; } |
| 464 | const code = `<iframe src="${attr(src)}" width="640" height="360" title="${t}" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy" style="border:0;max-width:100%;aspect-ratio:16/9;height:auto"></iframe>`; |
| 465 | p.appendChild(el('label', 'ov-share-label', { for: 'ov-share-embed' }, 'Embed on your site')); |
| 466 | const ta = el('textarea', 'ov-share-code', { id: 'ov-share-embed', readonly: true, rows: '3', 'data-k': 'embed-code', spellcheck: 'false' }); |
| 467 | ta.value = code; |
| 468 | ta.addEventListener('focus', () => ta.select()); |
| 469 | p.appendChild(ta); |
| 470 | const c = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'embed-copy' }, 'Copy code'); |
| 471 | c.addEventListener('click', async () => { const ok = await copyText(st, code); toast(st, ok ? 'Embed code copied' : 'Select the code and copy it.'); if (ok) sent(st, 'embed'); }); |
| 472 | p.appendChild(c); |
| 473 | return p; |
| 474 | } |
| 475 | |
| 476 | // ── The sheet ───────────────────────────────────────────────────────────────────────────────────────────── |
| 477 | let current = null; // the open sheet's state |
| 478 | |
| 479 | function open(opts) { |
| 480 | const o = Object.assign({}, opts || {}); |
| 481 | if (current) current.close(true); |
| 482 | stylesheet(); |
| 483 | return new Promise((resolve) => { |
| 484 | const st = { |
| 485 | o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false, |
| 486 | note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false, |
| 487 | access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false, |
| 488 | lockOpen: false, reseal: false, |
| 489 | }; |
| 490 | st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' }); |
| 491 | st.close = (force) => close(st, force); |
| 492 | st.dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(st); }); |
| 493 | st.dlg.addEventListener('click', (e) => { if (e.target === st.dlg) close(st); }); // the backdrop |
| 494 | doc.body.appendChild(st.dlg); |
| 495 | current = st; |
| 496 | draw(st); |
| 497 | st.dlg.showModal(); |
| 498 | doc.documentElement.classList.add('ov-share-locked'); |
| 499 | if (reduce()) st.dlg.classList.add('is-open'); |
| 500 | else root.requestAnimationFrame(() => root.requestAnimationFrame(() => st.dlg.classList.add('is-open'))); |
| 501 | if (st.access) load(st); |
| 502 | if (!root.OVBrands) loadSibling('brands.js', 'OVBrands').then((ok) => { if (ok && st.dlg.isConnected) draw(st); }); |
| 503 | }); |
| 504 | } |
| 505 | |
| 506 | async function load(st) { |
| 507 | try { |
| 508 | take(st, await call(st.o, 'GET', endpoint(st.o).path)); |
| 509 | st.error = ''; |
| 510 | } catch (e) { |
| 511 | // Not the owner (or signed out): this person can still send it; who can open it is not theirs to see. |
| 512 | if ([401, 403, 404].includes(e.status)) st.access = false; |
| 513 | else st.error = `Could not load who can open this. ${e.message}`; |
| 514 | } |
| 515 | draw(st); |
| 516 | } |
| 517 | function take(st, s) { |
| 518 | st.server = s; |
| 519 | if (s.__etag) st.etag = s.__etag; |
| 520 | st.audience = s.audience || 'private'; |
| 521 | st.members = (s.members || []).map((m) => Object.assign({}, m)); |
| 522 | st.confirmLeave = false; |
| 523 | } |
| 524 | const owner = (st) => !!(st.server && st.server.you && st.server.you.role === 'owner'); |
| 525 | const roles = (st) => (st.server && Array.isArray(st.server.roles) && st.server.roles.length ? st.server.roles : ['viewer']); |
| 526 | function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; } |
| 527 | function dirty(st) { |
| 528 | if (!st.server) return false; |
| 529 | if (st.reseal) return true; |
| 530 | if (st.audience !== (st.server.audience || 'private')) return true; |
| 531 | const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(','); |
| 532 | const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(','); |
| 533 | return a !== b; |
| 534 | } |
| 535 | |
| 536 | function close(st, force) { |
| 537 | if (!st.dlg.isConnected) return; |
| 538 | if (!force && dirty(st) && !st.confirmLeave) { st.confirmLeave = true; draw(st); return; } |
| 539 | if (current === st) current = null; |
| 540 | doc.documentElement.classList.remove('ov-share-locked'); |
| 541 | const done = () => { try { st.dlg.close(); } catch (_) { /* closed */ } st.dlg.remove(); st.resolve(st.saved); }; |
| 542 | if (reduce()) return done(); |
| 543 | st.dlg.classList.remove('is-open'); |
| 544 | let ended = false; |
| 545 | const end = () => { if (!ended) { ended = true; done(); } }; |
| 546 | st.dlg.addEventListener('transitionend', end, { once: true }); |
| 547 | setTimeout(end, 260); |
| 548 | } |
| 549 | |
| 550 | // Draw the whole sheet again from its state: small enough that keeping it simple beats patching it. |
| 551 | function draw(st, focus) { |
| 552 | const keep = doc.activeElement && st.dlg.contains(doc.activeElement) ? doc.activeElement.getAttribute('data-k') : null; |
| 553 | const box = el('div', `ov-share-box${st.first ? ' is-first' : ''}`); |
| 554 | st.first = false; |
| 555 | box.appendChild(head(st)); |
| 556 | const body = el('div', 'ov-share-body'); |
| 557 | if (st.view === 'give') giveView(st, body); else mainView(st, body); |
| 558 | box.appendChild(body); |
| 559 | box.appendChild(foot(st)); |
| 560 | box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || '')); |
| 561 | st.dlg.replaceChildren(box); |
| 562 | // A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll. |
| 563 | if (st.panel && st.panel !== st.shownPanel) { |
| 564 | const pnl = st.dlg.querySelector('.ov-share-panel'); |
| 565 | if (pnl && pnl.scrollIntoView) pnl.scrollIntoView({ block: 'nearest', behavior: reduce() ? 'auto' : 'smooth' }); |
| 566 | } |
| 567 | st.shownPanel = st.panel; |
| 568 | const want = keep ? st.dlg.querySelector(`[data-k="${keep}"]`) : null; |
| 569 | if (want) want.focus(); |
| 570 | else if (focus || !keep) { |
| 571 | const first = st.dlg.querySelector('[data-k="give-name"]') || st.dlg.querySelector('[data-k="copy"]') || st.dlg.querySelector('[data-k="close"]'); |
| 572 | if (first) first.focus(); |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | function head(st) { |
| 577 | const h = el('div', 'ov-share-head'); |
| 578 | if (st.view === 'give') { |
| 579 | const b = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Back', 'data-k': 'back' }); |
| 580 | b.appendChild(iconEl('back')); |
| 581 | b.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); }); |
| 582 | h.appendChild(b); |
| 583 | } |
| 584 | const title = (st.server && st.server.title) || st.o.title || ''; |
| 585 | const t = el('h2', 'ov-share-title', { id: 'ov-share-title' }); |
| 586 | t.textContent = st.view === 'give' ? 'Transfer ownership' : (title ? `Share “${title}”` : 'Share'); |
| 587 | h.appendChild(t); |
| 588 | const x = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Close', 'data-k': 'close' }); |
| 589 | x.appendChild(iconEl('close')); |
| 590 | x.addEventListener('click', () => close(st)); |
| 591 | h.appendChild(x); |
| 592 | return h; |
| 593 | } |
| 594 | |
| 595 | function mainView(st, body) { |
| 596 | sendSection(st, body); |
| 597 | if (!st.access) return; |
| 598 | if (!st.server) { |
| 599 | body.appendChild(el('p', st.error ? 'ov-share-error' : 'ov-share-muted', { role: st.error ? 'alert' : null }, st.error || 'Loading who can open this\u2026')); |
| 600 | return; |
| 601 | } |
| 602 | const s = st.server; |
| 603 | const mine = owner(st); |
| 604 | body.appendChild(el('hr', 'ov-share-rule')); |
| 605 | if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st)); |
| 606 | |
| 607 | // Who can open this: four choices, one line saying what the chosen one means. |
| 608 | // A locked item is never public (the service refuses it too): the choice is not offered. |
| 609 | const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)) |
| 610 | .filter((a) => AUDIENCES[a] && !(isLocked(st) && a === 'public')); |
| 611 | const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy }); |
| 612 | fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this')); |
| 613 | const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` }); |
| 614 | for (const a of offered) { |
| 615 | const lab = el('label', 'ov-share-segopt', { title: AUDIENCES[a].title }); |
| 616 | const r = el('input', null, { type: 'radio', name: 'ov-share-aud', value: a, 'data-k': `aud-${a}` }); |
| 617 | r.checked = st.audience === a; |
| 618 | r.addEventListener('change', () => { st.audience = a; st.note = ''; draw(st); }); |
| 619 | lab.appendChild(r); |
| 620 | lab.appendChild(iconEl(a)); |
| 621 | lab.appendChild(el('span', 'ov-share-seglabel', null, AUDIENCES[a].label)); |
| 622 | seg.appendChild(lab); |
| 623 | } |
| 624 | fs.appendChild(seg); |
| 625 | const desc = el('p', 'ov-share-desc', { 'aria-live': 'polite' }); |
| 626 | desc.appendChild(el('strong', null, null, `${AUDIENCES[st.audience] ? AUDIENCES[st.audience].title : st.audience}. `)); |
| 627 | desc.appendChild(doc.createTextNode(AUDIENCES[st.audience] ? AUDIENCES[st.audience].desc : '')); |
| 628 | fs.appendChild(desc); |
| 629 | body.appendChild(fs); |
| 630 | |
| 631 | // Locked items: what the lock means, the fingerprints, and the people they are wrapped for. |
| 632 | if (isLocked(st)) body.appendChild(lockSection(st)); |
| 633 | |
| 634 | // People: who has access (or, on an open item, who may edit it). |
| 635 | const open = st.audience === 'public' || st.audience === 'link'; |
| 636 | const canEditRole = roles(st).includes('editor'); |
| 637 | const listMeans = open ? (canEditRole ? 'edit' : 'nothing') : 'open'; |
| 638 | const sec = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-people-h' }); |
| 639 | const hd = el('div', 'ov-share-sechead'); |
| 640 | hd.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-people-h' }, open ? 'People who can edit' : 'People with access')); |
| 641 | sec.appendChild(hd); |
| 642 | if (mine && listMeans !== 'nothing') sec.appendChild(adder(st)); |
| 643 | if (st.note) sec.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.note)); |
| 644 | if (st.audience === 'private' && st.members.length) { |
| 645 | sec.appendChild(el('p', 'ov-share-warn', null, st.members.length === 1 |
| 646 | ? 'Only me: saving removes the person below.' : `Only me: saving removes the ${st.members.length} people below.`)); |
| 647 | } |
| 648 | if (open && listMeans === 'nothing' && st.members.length) { |
| 649 | sec.appendChild(el('p', 'ov-share-muted', null, 'Anyone can open it now, so this list does nothing. Choose People to limit it to them.')); |
| 650 | } |
| 651 | const ul = el('ul', 'ov-share-list'); |
| 652 | if (s.owner) ul.appendChild(personRow(st, s.owner, { tag: 'Owner', you: s.you && same(s.owner.subject, s.you.subject) })); |
| 653 | st.members.forEach((m, i) => ul.appendChild(personRow(st, m, { member: m, index: i, dim: st.audience === 'private' }))); |
| 654 | sec.appendChild(ul); |
| 655 | if (!st.members.length && listMeans === 'open' && mine) { |
| 656 | sec.appendChild(el('p', 'ov-share-muted', null, st.audience === 'private' ? 'Add someone by name to share it with them.' : 'Nobody else yet. Add someone by their OpenVibe name.')); |
| 657 | } |
| 658 | if (st.members.some((m) => m.isNew)) { |
| 659 | const nl = el('label', 'ov-share-check'); |
| 660 | const cb = el('input', null, { type: 'checkbox', 'data-k': 'notify' }); |
| 661 | cb.checked = st.notify; |
| 662 | cb.addEventListener('change', () => { st.notify = cb.checked; }); |
| 663 | nl.appendChild(cb); |
| 664 | nl.appendChild(el('span', null, null, 'Tell the people I add (a notification on OpenVibe)')); |
| 665 | sec.appendChild(nl); |
| 666 | } |
| 667 | body.appendChild(sec); |
| 668 | |
| 669 | // People who asked. |
| 670 | const asks = (s.requests || []).filter((r) => !st.members.some((m) => same(m.subject, r.subject))); |
| 671 | if (mine && asks.length) { |
| 672 | const rs = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-asks-h' }); |
| 673 | rs.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-asks-h' }, `Asking for access (${asks.length})`)); |
| 674 | const rl = el('ul', 'ov-share-list'); |
| 675 | for (const r of asks) rl.appendChild(requestRow(st, r)); |
| 676 | rs.appendChild(rl); |
| 677 | body.appendChild(rs); |
| 678 | } |
| 679 | if (!mine) { |
| 680 | body.appendChild(el('p', 'ov-share-muted', null, s.owner && s.owner.username |
| 681 | ? `Only ${handle(s.owner)} can change who can open this.` : 'Only the owner can change who can open this.')); |
| 682 | } |
| 683 | if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error)); |
| 684 | } |
| 685 | |
| 686 | // ── Locked items (Locker) ──────────────────────────────────────────────────────────────────────────────── |
| 687 | // ov-locker.js loads on the first press of the Locked part, never before: the lock's words and the fingerprints |
| 688 | // come from the service's own answer; only the wrapping and the re-seal need the keys, and they live in that file. |
| 689 | const isLocked = (st) => !!(st.server && st.server.lock && st.server.lock !== 'none'); |
| 690 | async function locker() { |
| 691 | if (root.OVLocker) return root.OVLocker; |
| 692 | const ok = await loadSibling('ov-locker.js', 'OVLocker'); |
| 693 | return ok && root.OVLocker ? root.OVLocker : null; |
| 694 | } |
| 695 | const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); }; |
| 696 | /** The people whose slot was in the stored envelope and who are no longer on the list. */ |
| 697 | function removedMembers(st) { |
| 698 | if (!st.server) return []; |
| 699 | return (st.server.members || []).filter((m) => m.key_slot && !st.members.some((x) => same(x.subject, m.subject))); |
| 700 | } |
| 701 | function lockSection(st) { |
| 702 | const kind = st.server.lock; |
| 703 | const words = LOCK_WORDS[kind] || { label: kind, means: '' }; |
| 704 | const sec = el('section', 'ov-share-lock', { 'aria-labelledby': 'ov-share-lock-h' }); |
| 705 | const head = el('button', 'ov-share-lockhead', { type: 'button', 'data-k': 'lock-open', 'aria-expanded': st.lockOpen ? 'true' : 'false', 'aria-controls': 'ov-share-lock-body' }); |
| 706 | head.appendChild(iconEl('private')); |
| 707 | const w = el('span', 'ov-share-locklabel'); |
| 708 | w.appendChild(el('span', 'ov-share-locktitle', { id: 'ov-share-lock-h' }, 'Locked')); |
| 709 | w.appendChild(el('span', 'ov-share-muted', null, words.label)); |
| 710 | head.appendChild(w); |
| 711 | head.appendChild(el('span', 'ov-share-lockcaret', { 'aria-hidden': 'true' }, st.lockOpen ? '−' : '+')); |
| 712 | head.addEventListener('click', () => { |
| 713 | st.lockOpen = !st.lockOpen; |
| 714 | if (st.lockOpen && !root.OVLocker) locker(); // the words need nothing; the sealing later does |
| 715 | draw(st); |
| 716 | }); |
| 717 | sec.appendChild(head); |
| 718 | if (!st.lockOpen) { |
| 719 | sec.appendChild(el('p', 'ov-share-muted ov-share-lockshort', null, 'OpenVibe can’t read what’s inside.')); |
| 720 | return sec; |
| 721 | } |
| 722 | const inner = el('div', 'ov-share-lockbody', { id: 'ov-share-lock-body' }); |
| 723 | inner.appendChild(el('p', 'ov-share-lockmeans', null, words.means)); |
| 724 | if (kind === 'link') { |
| 725 | const key = linkKey(theLink(st)); |
| 726 | inner.appendChild(el('p', key ? 'ov-share-muted' : 'ov-share-warn', { role: key ? null : 'status' }, key |
| 727 | ? 'The whole link, including the part after #, is the key. Copy sends the whole link.' |
| 728 | : 'This link is missing its key: a locked link ends with #k=… Ask whoever shared it for the whole link. OpenVibe never had the key, so it cannot send it.')); |
| 729 | } |
| 730 | if (kind === 'password') { |
| 731 | inner.appendChild(el('p', 'ov-share-muted', null, 'The password is not stored anywhere. If you lose it, nobody can open this: there is no reset.')); |
| 732 | } |
| 733 | if (kind === 'people') { |
| 734 | inner.appendChild(el('p', 'ov-share-muted', null, 'Each person’s content key is wrapped for their locker on this device. OpenVibe never sees a key.')); |
| 735 | const known = st.members.filter((m) => m.key_fingerprint); |
| 736 | if (known.length) { |
| 737 | const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' }); |
| 738 | for (const m of known) { |
| 739 | const li = el('li', 'ov-share-fprow'); |
| 740 | li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m))); |
| 741 | li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint)); |
| 742 | if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed')); |
| 743 | ul.appendChild(li); |
| 744 | } |
| 745 | inner.appendChild(ul); |
| 746 | } |
| 747 | const changed = st.members.filter((m) => m.fingerprint_changed); |
| 748 | if (changed.length) { |
| 749 | const who = changed.map((m) => handle(m) || nameOf(m)).join(', '); |
| 750 | inner.appendChild(el('p', 'ov-share-warn', { role: 'status' }, `${who} changed locker keys since this was shared for them. Compare the new fingerprint with them, in person or on a call, before you use it.`)); |
| 751 | const use = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'use-new-key' }, 'Use their new key'); |
| 752 | use.addEventListener('click', async () => { |
| 753 | use.disabled = true; |
| 754 | try { |
| 755 | const L = await locker(); |
| 756 | if (!L) throw new Error('The Locker could not load. Check your connection and try again.'); |
| 757 | for (const m of changed) { |
| 758 | const keys = await L.keys(m.subject); |
| 759 | if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`); |
| 760 | m._publicKey = keys.ecdh_public || keys.ecdh; |
| 761 | m._keyFingerprint = keys.fingerprint || null; |
| 762 | m.fingerprint_changed = false; |
| 763 | m.useNewKey = true; |
| 764 | } |
| 765 | st.reseal = true; st.lockNote = 'Their new key is used when you save.'; |
| 766 | st.lockError = ''; |
| 767 | } catch (e) { st.lockError = e.message; } |
| 768 | draw(st); |
| 769 | }); |
| 770 | inner.appendChild(use); |
| 771 | } |
| 772 | const gone = removedMembers(st); |
| 773 | if (gone.length) { |
| 774 | const who = gone.map((m) => handle(m) || nameOf(m)).join(', '); |
| 775 | inner.appendChild(el('p', 'ov-share-warn', { role: 'status' }, |
| 776 | `They had the key before you removed them (${who}). They may still have a copy of what they already opened.`)); |
| 777 | } |
| 778 | if (st.lockNote) inner.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.lockNote)); |
| 779 | if (st.lockError) inner.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.lockError)); |
| 780 | } |
| 781 | sec.appendChild(inner); |
| 782 | return sec; |
| 783 | } |
| 784 | |
| 785 | /** The person's public key, asked of Network through the Locker: a people lock cannot be made without it. */ |
| 786 | async function attachKey(st, p) { |
| 787 | if (!p || !p.subject) { st.addMsg = `We could not confirm ${handle(p) || nameOf(p)}’s account, so no key is known.`; st.addMsgBad = true; return false; } |
| 788 | try { |
| 789 | const L = await locker(); |
| 790 | if (!L) throw new Error('The Locker could not load. Check your connection and try again.'); |
| 791 | const keys = await L.keys(p.subject); |
| 792 | if (!keys) { |
| 793 | st.addMsg = `${handle(p) || nameOf(p)} has no locker yet: they make one the first time they open a locked item.`; |
| 794 | st.addMsgBad = true; |
| 795 | return false; |
| 796 | } |
| 797 | p._publicKey = keys.ecdh_public || keys.ecdh; |
| 798 | p._keyFingerprint = keys.fingerprint || null; |
| 799 | p.needsKey = true; |
| 800 | return true; |
| 801 | } catch (e) { |
| 802 | st.addMsg = `Could not get ${handle(p) || nameOf(p)}’s key: ${e.message}`; |
| 803 | st.addMsgBad = true; |
| 804 | return false; |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | /** The bytes a locked item serves: its envelope. */ |
| 809 | async function envelopeBytes(st) { |
| 810 | const url = (st.server && st.server.envelope_url) || st.o.envelope_url; |
| 811 | if (!url) throw new Error('This service did not say where the locked item is.'); |
| 812 | const r = await fetch(url, { credentials: st.o.base ? 'include' : 'same-origin', headers: { accept: 'application/octet-stream' } }); |
| 813 | if (!r.ok) throw new Error(`The locked item answered ${r.status}.`); |
| 814 | return new Uint8Array(await r.arrayBuffer()); |
| 815 | } |
| 816 | |
| 817 | /** |
| 818 | * For a people lock whose list changed: the envelope re-wrapped here. Someone added gets their slot (the content key |
| 819 | * never leaves this device); someone removed means the content is sealed again under a fresh key for those who stay, |
| 820 | * so the key the person leaving holds opens nothing. The service stores the bytes and never reads them. |
| 821 | */ |
| 822 | async function resealIfNeeded(st) { |
| 823 | const before = st.server.members || []; |
| 824 | const after = st.audience === 'private' ? [] : st.members; |
| 825 | const gone = before.filter((m) => !after.some((x) => same(x.subject, m.subject))); |
| 826 | const needing = after.filter((m) => m.needsKey || m.fingerprint_changed || m.useNewKey); |
| 827 | // A slot that is already there has to go when it is wrapped again for the same person: two slots for one |
| 828 | // person would leave the old key in the envelope. |
| 829 | const stale = needing.filter((m) => m.key_slot); |
| 830 | const remove = [...gone, ...stale].map((m) => m.subject); |
| 831 | if (!remove.length && !needing.length && !st.reseal) return null; |
| 832 | const L = await locker(); |
| 833 | if (!L) throw new Error('The Locker could not load. Check your connection and try again.'); |
| 834 | await L.ensure(); |
| 835 | const publicKey = async (m) => { |
| 836 | if (m._publicKey) return m._publicKey; |
| 837 | const keys = await L.keys(m.subject); |
| 838 | if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`); |
| 839 | m._publicKey = keys.ecdh_public || keys.ecdh; |
| 840 | m._keyFingerprint = keys.fingerprint || null; |
| 841 | return m._publicKey; |
| 842 | }; |
| 843 | const add = []; |
| 844 | for (const m of needing) add.push({ subject: m.subject, publicKey: await publicKey(m) }); |
| 845 | const keys = []; |
| 846 | const me = await L.publicKeys(); |
| 847 | const my = st.server.you && st.server.you.subject; |
| 848 | if ((remove.length || st.o.prepareEnvelope) && my && me) keys.push({ subject: my, publicKey: me.ecdh }); |
| 849 | if (remove.length || st.o.prepareEnvelope) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) }); |
| 850 | // File services may stream a new envelope to their ciphertext store and return a short receipt. |
| 851 | // The sheet still supplies the recipients and saves their slots and access list together. |
| 852 | const out = typeof st.o.prepareEnvelope === 'function' |
| 853 | ? await st.o.prepareEnvelope({ keys, remove, add }) |
| 854 | : await L.rewrap(await envelopeBytes(st), { add, remove, keys }); |
| 855 | const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot])); |
| 856 | const list = after.map((m) => { |
| 857 | const slot = slots.get(sid(m.subject)); |
| 858 | if (!slot) throw new Error(`The sealed envelope has no key for ${handle(m) || nameOf(m)}.`); |
| 859 | const r = { subject: m.subject, role: m.role, key_slot: slot }; |
| 860 | if (m.expires_at) r.expires_at = m.expires_at; |
| 861 | return r; |
| 862 | }); |
| 863 | return { members: list, ...(out.envelope_ref ? { envelope_ref: out.envelope_ref } : { envelope: b64(out.bytes) }) }; |
| 864 | } |
| 865 | |
| 866 | // The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row. |
| 867 | function adder(st) { |
| 868 | const wrap = el('form', 'ov-share-add', { novalidate: true }); |
| 869 | const field = el('div', 'ov-share-addfield'); |
| 870 | field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@')); |
| 871 | const inp = el('input', null, { |
| 872 | type: 'text', name: 'name', 'data-k': 'add', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', |
| 873 | enterkeyhint: 'done', maxlength: '41', placeholder: 'Add people by their OpenVibe name', 'aria-label': 'Add people by their OpenVibe name', |
| 874 | 'aria-describedby': 'ov-share-add-msg', |
| 875 | }); |
| 876 | if (st.draftName) inp.value = st.draftName; |
| 877 | inp.addEventListener('input', () => { st.draftName = inp.value; if (st.addMsg) { st.addMsg = ''; msg.textContent = ''; msg.className = 'ov-share-addmsg'; } }); |
| 878 | field.appendChild(inp); |
| 879 | wrap.appendChild(field); |
| 880 | const btn = el('button', 'ov-share-btn', { type: 'submit', 'data-k': 'add-btn' }, st.checking ? 'Checking…' : 'Add'); |
| 881 | btn.disabled = !!st.checking; |
| 882 | wrap.appendChild(btn); |
| 883 | const msg = el('p', `ov-share-addmsg${st.addMsgBad ? ' is-bad' : ''}`, { id: 'ov-share-add-msg', 'aria-live': 'polite' }, st.addMsg || ''); |
| 884 | wrap.appendChild(msg); |
| 885 | wrap.addEventListener('submit', (e) => { e.preventDefault(); addNames(st, inp.value); }); |
| 886 | const sugg = suggestionsFor(st); |
| 887 | if (sugg.length) { |
| 888 | const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People you have shared with' }); |
| 889 | for (const p of sugg) { |
| 890 | const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `sugg-${p.username}` }); |
| 891 | c.appendChild(avatar(p)); |
| 892 | c.appendChild(el('span', null, null, handle(p))); |
| 893 | c.addEventListener('click', () => addPerson(st, p)); |
| 894 | row.appendChild(c); |
| 895 | } |
| 896 | wrap.appendChild(row); |
| 897 | } |
| 898 | return wrap; |
| 899 | } |
| 900 | function suggestionsFor(st) { |
| 901 | const have = new Set(st.members.map((m) => sid(m.subject))); |
| 902 | if (st.server && st.server.owner) have.add(sid(st.server.owner.subject)); |
| 903 | return (st.suggested || []).filter((p) => p && p.subject && !have.has(sid(p.subject))).slice(0, 6); |
| 904 | } |
| 905 | |
| 906 | async function addNames(st, raw) { |
| 907 | const names = String(raw || '').split(/[\s,;]+/).map(cleanName).filter(Boolean); |
| 908 | if (!names.length) return; |
| 909 | const bad = names.filter((n) => !NAME_RE.test(n)); |
| 910 | if (bad.length) { st.addMsg = `“${bad[0]}” is not an OpenVibe name: letters, numbers, dots, dashes and underscores only.`; st.addMsgBad = true; draw(st); return; } |
| 911 | st.checking = true; st.addMsg = ''; draw(st); |
| 912 | const missing = []; |
| 913 | try { |
| 914 | const peopleLock = !!(st.server && st.server.lock === 'people'); |
| 915 | for (const n of names) { |
| 916 | const p = await lookup(st.o, n); |
| 917 | if (p === null) missing.push(n); |
| 918 | else if (p === undefined && peopleLock) { st.addMsg = `We could not check @${n}, so no key is known for them. Try again.`; st.addMsgBad = true; } |
| 919 | else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true); |
| 920 | else if (peopleLock) { if (await attachKey(st, p)) addPerson(st, p, true); } |
| 921 | else addPerson(st, p, true); |
| 922 | } |
| 923 | st.draftName = missing.join(' '); |
| 924 | if (missing.length) { |
| 925 | st.addMsg = `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.`; |
| 926 | st.addMsgBad = true; |
| 927 | } |
| 928 | } catch (e) { |
| 929 | st.addMsg = e.message; st.addMsgBad = true; |
| 930 | } |
| 931 | st.checking = false; |
| 932 | draw(st); |
| 933 | } |
| 934 | function addPerson(st, p, quiet) { |
| 935 | const s = st.server; |
| 936 | if (s.owner && p.subject && same(p.subject, s.owner.subject)) { st.addMsg = `${handle(p)} owns this already.`; st.addMsgBad = false; if (!quiet) draw(st); return; } |
| 937 | if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; } |
| 938 | const open = st.audience === 'public' || st.audience === 'link'; |
| 939 | const role = open && roles(st).includes('editor') ? 'editor' : 'viewer'; |
| 940 | st.members.push({ |
| 941 | subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, |
| 942 | avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked, |
| 943 | ...(p.key_slot ? { key_slot: p.key_slot } : {}), |
| 944 | ...(p.needsKey ? { needsKey: true } : {}), |
| 945 | ...(p._publicKey ? { _publicKey: p._publicKey } : {}), |
| 946 | ...(p._keyFingerprint || p.key_fingerprint ? { key_fingerprint: p._keyFingerprint || p.key_fingerprint } : {}), |
| 947 | }); |
| 948 | if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; } |
| 949 | if (!quiet) { st.draftName = ''; draw(st); } |
| 950 | } |
| 951 | |
| 952 | function personRow(st, p, x) { |
| 953 | const li = el('li', `ov-share-row${x.dim ? ' is-dim' : ''}${x.member && x.member.isNew ? ' is-new' : ''}`); |
| 954 | li.appendChild(avatar(p)); |
| 955 | const words = el('div', 'ov-share-words'); |
| 956 | const top = el('div', 'ov-share-name'); |
| 957 | top.appendChild(el('span', 'ov-share-display', null, nameOf(p))); |
| 958 | if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You')); |
| 959 | if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New')); |
| 960 | words.appendChild(top); |
| 961 | const sub = el('div', 'ov-share-sub'); |
| 962 | if (p.username) sub.appendChild(el('span', null, null, handle(p))); |
| 963 | if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save')); |
| 964 | if (x.member) sub.appendChild(expiry(st, x.member)); |
| 965 | // A people lock shows which key the slot is for: the eight words a person can compare out of band. |
| 966 | if (x.member && isLocked(st) && st.server.lock === 'people' && x.member.key_fingerprint) { |
| 967 | sub.appendChild(el('span', `ov-share-fp${x.member.fingerprint_changed ? ' is-changed' : ''}`, { title: 'Their locker’s fingerprint' }, x.member.key_fingerprint)); |
| 968 | if (x.member.fingerprint_changed) sub.appendChild(el('span', 'ov-share-soft', null, 'key changed')); |
| 969 | } |
| 970 | words.appendChild(sub); |
| 971 | li.appendChild(words); |
| 972 | if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; } |
| 973 | const m = x.member; |
| 974 | if (!owner(st)) { li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role)); return li; } |
| 975 | const rs = roles(st); |
| 976 | if (rs.length > 1) { |
| 977 | const sel = el('select', 'ov-share-select', { 'aria-label': `What ${handle(m) || nameOf(m)} can do`, 'data-k': `role-${memberKey(m)}`, 'data-ov-select': 'bare' }); |
| 978 | for (const r of rs) { const op = el('option', null, { value: r }, ROLE_WORDS[r] || r); op.selected = m.role === r; sel.appendChild(op); } |
| 979 | sel.addEventListener('change', () => { m.role = sel.value; draw(st); }); |
| 980 | const pill = el('span', 'ov-share-rolepill'); |
| 981 | pill.appendChild(sel); |
| 982 | li.appendChild(pill); |
| 983 | } else li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role)); |
| 984 | const rm = el('button', 'ov-share-iconbtn is-small', { type: 'button', 'aria-label': `Remove ${handle(m) || nameOf(m)}`, 'data-k': `rm-${memberKey(m)}` }); |
| 985 | rm.appendChild(iconEl('close')); |
| 986 | rm.addEventListener('click', () => { |
| 987 | st.members.splice(st.members.indexOf(m), 1); |
| 988 | st.note = m.isNew ? '' : isLocked(st) && st.server.lock === 'people' |
| 989 | ? `${handle(m) || nameOf(m)} loses access when you save. They had the key before you removed them: they may still have a copy of what they already opened.` |
| 990 | : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`; |
| 991 | draw(st); |
| 992 | }); |
| 993 | li.appendChild(rm); |
| 994 | return li; |
| 995 | } |
| 996 | |
| 997 | // "until Nov 30": an end date for one person's access, set with the browser's date picker. |
| 998 | function expiry(st, m) { |
| 999 | const wrap = el('span', 'ov-share-exp'); |
| 1000 | if (!owner(st)) { if (m.expires_at) wrap.textContent = `until ${when(m.expires_at)}`; return wrap; } |
| 1001 | if (st.editingExp === memberKey(m)) { |
| 1002 | const d = el('input', 'ov-share-date', { type: 'date', 'aria-label': `Last day for ${handle(m) || nameOf(m)}`, 'data-k': `date-${memberKey(m)}` }); |
| 1003 | const t = new Date(Date.now() + 86400000); |
| 1004 | d.min = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`; |
| 1005 | if (m.expires_at) { const e = new Date(m.expires_at); d.value = `${e.getFullYear()}-${String(e.getMonth() + 1).padStart(2, '0')}-${String(e.getDate()).padStart(2, '0')}`; } |
| 1006 | const commit = () => { |
| 1007 | if (d.value) { const [y, mo, da] = d.value.split('-').map(Number); m.expires_at = new Date(y, mo - 1, da, 23, 59, 59).toISOString(); } |
| 1008 | st.editingExp = null; draw(st); |
| 1009 | }; |
| 1010 | d.addEventListener('change', commit); |
| 1011 | d.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); st.editingExp = null; draw(st); } }); |
| 1012 | wrap.appendChild(d); |
| 1013 | if (m.expires_at) { |
| 1014 | const none = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': `noexp-${memberKey(m)}` }, 'No end date'); |
| 1015 | none.addEventListener('click', () => { m.expires_at = null; st.editingExp = null; draw(st); }); |
| 1016 | wrap.appendChild(none); |
| 1017 | } |
| 1018 | setTimeout(() => { try { d.focus(); if (d.showPicker) d.showPicker(); } catch (_) { /* a picker needs a gesture in some browsers */ } }, 0); |
| 1019 | return wrap; |
| 1020 | } |
| 1021 | const b = el('button', 'ov-share-link', { type: 'button', 'data-k': `exp-${memberKey(m)}` }, m.expires_at ? `until ${when(m.expires_at)}` : 'Set an end date'); |
| 1022 | b.addEventListener('click', () => { st.editingExp = memberKey(m); draw(st); }); |
| 1023 | if (m.expires_at) b.setAttribute('aria-label', `Access for ${handle(m) || nameOf(m)} ends ${when(m.expires_at, true)}: change`); |
| 1024 | wrap.appendChild(b); |
| 1025 | return wrap; |
| 1026 | } |
| 1027 | |
| 1028 | function requestRow(st, r) { |
| 1029 | const li = el('li', 'ov-share-row'); |
| 1030 | li.appendChild(avatar(r)); |
| 1031 | const words = el('div', 'ov-share-words'); |
| 1032 | words.appendChild(el('div', 'ov-share-name', null, nameOf(r))); |
| 1033 | const sub = el('div', 'ov-share-sub'); |
| 1034 | if (r.username) sub.appendChild(el('span', null, null, handle(r))); |
| 1035 | if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`)); |
| 1036 | words.appendChild(sub); |
| 1037 | li.appendChild(words); |
| 1038 | const add = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': `req-add-${sid(r.subject)}` }, 'Add'); |
| 1039 | add.addEventListener('click', () => addPerson(st, r)); |
| 1040 | li.appendChild(add); |
| 1041 | const no = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': `req-no-${sid(r.subject)}` }, 'Dismiss'); |
| 1042 | no.addEventListener('click', async () => { |
| 1043 | no.disabled = true; |
| 1044 | try { |
| 1045 | await call(st.o, 'POST', `${endpoint(st.o).path}/requests/${encodeURIComponent(r.id)}/dismiss`, {}); |
| 1046 | st.server.requests = (st.server.requests || []).filter((q) => q !== r); |
| 1047 | } catch (e) { st.error = `Could not dismiss: ${e.message}`; } |
| 1048 | draw(st); |
| 1049 | }); |
| 1050 | li.appendChild(no); |
| 1051 | return li; |
| 1052 | } |
| 1053 | |
| 1054 | function pendingBanner(st) { |
| 1055 | const t = st.server.transfer; |
| 1056 | const b = el('div', 'ov-share-banner', { role: 'status' }); |
| 1057 | b.appendChild(iconEl('give')); |
| 1058 | const p = el('p'); |
| 1059 | p.appendChild(doc.createTextNode('Waiting for ')); |
| 1060 | p.appendChild(el('strong', null, null, handle(t.to) || nameOf(t.to))); |
| 1061 | p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`)); |
| 1062 | b.appendChild(p); |
| 1063 | const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer'); |
| 1064 | c.addEventListener('click', async () => { |
| 1065 | st.busy = true; draw(st); |
| 1066 | try { take(st, await call(st.o, 'POST', `${endpoint(st.o).path}/transfer/cancel`, { transfer_id: t.id })); st.note = 'Offer cancelled. It is still yours.'; announce(st.o, st.server); } |
| 1067 | catch (e) { st.error = `Could not cancel: ${e.message}`; } |
| 1068 | st.busy = false; draw(st); |
| 1069 | }); |
| 1070 | b.appendChild(c); |
| 1071 | return b; |
| 1072 | } |
| 1073 | |
| 1074 | // ── Transfer ownership: pick one person, say what happens to your own access, send ───────────────────────── |
| 1075 | function giveView(st, body) { |
| 1076 | const title = (st.server && st.server.title) || st.o.title; |
| 1077 | body.appendChild(el('p', 'ov-share-lead', null, `Give ${title ? `“${title}”` : 'this'} to someone else. They become the owner when they accept, and can then decide who else may open it.`)); |
| 1078 | const f = el('form', 'ov-share-give', { novalidate: true }); |
| 1079 | const lab = el('label', 'ov-share-label', { for: 'ov-share-give-name' }, 'New owner'); |
| 1080 | f.appendChild(lab); |
| 1081 | const field = el('div', 'ov-share-addfield'); |
| 1082 | field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@')); |
| 1083 | const inp = el('input', null, { id: 'ov-share-give-name', type: 'text', 'data-k': 'give-name', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', maxlength: '41', placeholder: 'their OpenVibe name', 'aria-describedby': 'ov-share-give-msg' }); |
| 1084 | if (st.giveTo) inp.value = st.giveTo.username || ''; |
| 1085 | else if (st.giveDraft) inp.value = st.giveDraft; |
| 1086 | inp.addEventListener('input', () => { st.giveDraft = inp.value; st.giveTo = null; }); |
| 1087 | field.appendChild(inp); |
| 1088 | f.appendChild(field); |
| 1089 | f.appendChild(el('p', `ov-share-addmsg${st.giveBad ? ' is-bad' : ''}`, { id: 'ov-share-give-msg', 'aria-live': 'polite' }, st.giveMsg || '')); |
| 1090 | const people = st.members.filter((m) => m.subject && !m.isNew); |
| 1091 | if (people.length && !st.giveTo) { |
| 1092 | const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People with access' }); |
| 1093 | for (const p of people.slice(0, 6)) { |
| 1094 | const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `give-${p.username}` }); |
| 1095 | c.appendChild(avatar(p)); |
| 1096 | c.appendChild(el('span', null, null, handle(p))); |
| 1097 | c.addEventListener('click', () => { st.giveTo = p; st.giveMsg = `${nameOf(p)} (${handle(p)}) will be asked.`; st.giveBad = false; draw(st); }); |
| 1098 | row.appendChild(c); |
| 1099 | } |
| 1100 | f.appendChild(row); |
| 1101 | } |
| 1102 | const keep = el('fieldset', 'ov-share-keep'); |
| 1103 | keep.appendChild(el('legend', 'ov-share-label', null, 'When they accept')); |
| 1104 | for (const [v, t, d] of [[false, 'Remove my access', 'Recommended. It is theirs alone; they can share it back with you.'], [true, 'Keep me as a viewer', 'You can still open it, but not change it.']]) { |
| 1105 | const l = el('label', 'ov-share-radio'); |
| 1106 | const r = el('input', null, { type: 'radio', name: 'ov-share-keep', 'data-k': `keep-${v}` }); |
| 1107 | r.checked = st.keepAccess === v; |
| 1108 | r.addEventListener('change', () => { st.keepAccess = v; }); |
| 1109 | l.appendChild(r); |
| 1110 | const w = el('span', 'ov-share-radiowords'); |
| 1111 | w.appendChild(el('span', 'ov-share-radiotitle', null, t)); |
| 1112 | w.appendChild(el('span', 'ov-share-muted', null, d)); |
| 1113 | l.appendChild(w); |
| 1114 | keep.appendChild(l); |
| 1115 | } |
| 1116 | f.appendChild(keep); |
| 1117 | f.appendChild(el('p', 'ov-share-muted', null, 'They have 7 days to accept, and can open it meanwhile to look. Until they accept it stays yours, and you can cancel the offer.')); |
| 1118 | if (st.error) f.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error)); |
| 1119 | f.addEventListener('submit', (e) => { e.preventDefault(); sendOffer(st, inp.value); }); |
| 1120 | body.appendChild(f); |
| 1121 | } |
| 1122 | async function sendOffer(st, raw) { |
| 1123 | st.error = ''; |
| 1124 | let to = st.giveTo; |
| 1125 | if (!to) { |
| 1126 | const n = cleanName(raw); |
| 1127 | if (!NAME_RE.test(n)) { st.giveMsg = 'Type their OpenVibe name.'; st.giveBad = true; draw(st); return; } |
| 1128 | st.busy = true; st.giveMsg = 'Checking…'; st.giveBad = false; draw(st); |
| 1129 | try { |
| 1130 | const p = await lookup(st.o, n); |
| 1131 | if (p === null) { st.busy = false; st.giveMsg = `No OpenVibe account is called @${n}.`; st.giveBad = true; draw(st); return; } |
| 1132 | to = p || { name: n, username: n }; |
| 1133 | } catch (e) { st.busy = false; st.giveMsg = e.message; st.giveBad = true; draw(st); return; } |
| 1134 | } |
| 1135 | if (st.server.owner && to.subject && same(to.subject, st.server.owner.subject)) { st.busy = false; st.giveMsg = 'That is you.'; st.giveBad = true; draw(st); return; } |
| 1136 | st.busy = true; draw(st); |
| 1137 | try { |
| 1138 | // A people-locked item is opened by its slots: the recipient's comes with the offer, and the envelope that |
| 1139 | // goes up carries it too, so they can look before they accept. Their key is asked of Network, and the |
| 1140 | // wrapping happens here. |
| 1141 | let slot = null; |
| 1142 | if (isLocked(st) && st.server.lock === 'people') { |
| 1143 | if (!to.subject) throw new Error('That is not an OpenVibe account, so there is no key to lock it for.'); |
| 1144 | const L = await locker(); |
| 1145 | if (!L) throw new Error('The Locker could not load. Check your connection and try again.'); |
| 1146 | const keys = await L.keys(to.subject); |
| 1147 | if (!keys) throw new Error(`${handle(to) || nameOf(to)} has no locker yet: they make one the first time they open a locked item.`); |
| 1148 | await L.ensure(); |
| 1149 | const bytes = await envelopeBytes(st); |
| 1150 | const wrapped = await L.rewrap(bytes, { add: [{ subject: to.subject, publicKey: keys.ecdh_public || keys.ecdh }] }); |
| 1151 | slot = (wrapped.header.slots.people || []).find((x) => same(x.subject, to.subject)) || null; |
| 1152 | if (!slot) throw new Error('The sealed envelope has no key for them; try again.'); |
| 1153 | const members = st.audience === 'private' ? [] : st.members.map((m) => ({ |
| 1154 | subject: m.subject, role: m.role, |
| 1155 | ...(m.expires_at ? { expires_at: m.expires_at } : {}), |
| 1156 | ...(m.key_slot ? { key_slot: m.key_slot } : {}), |
| 1157 | })); |
| 1158 | take(st, await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, envelope: b64(wrapped.bytes) }, st.etag ? { 'if-match': st.etag } : null)); |
| 1159 | } |
| 1160 | const out = await call(st.o, 'POST', `${endpoint(st.o).path}/transfer`, { |
| 1161 | to: to.subject ? { subject: to.subject } : { name: to.name }, keep_access: !!st.keepAccess, ...(slot ? { key_slot: slot } : {}), |
| 1162 | }); |
| 1163 | if (out && out.audience) take(st, out); else take(st, await call(st.o, 'GET', endpoint(st.o).path)); |
| 1164 | st.view = 'main'; st.giveTo = null; st.giveDraft = ''; st.giveMsg = ''; |
| 1165 | st.note = `Offer sent to ${handle(to) || nameOf(to)}. It stays yours until they accept.`; |
| 1166 | announce(st.o, st.server); |
| 1167 | } catch (e) { |
| 1168 | st.error = e.status === 404 && e.body && /name|person|subject/i.test(JSON.stringify(e.body)) ? `No OpenVibe account is called ${handle(to)}.` : `Could not send the offer: ${e.message}`; |
| 1169 | } |
| 1170 | st.busy = false; draw(st, true); |
| 1171 | } |
| 1172 | |
| 1173 | function foot(st) { |
| 1174 | const f = el('div', 'ov-share-foot'); |
| 1175 | if (st.view === 'give') { |
| 1176 | const c = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give-cancel' }, 'Back'); |
| 1177 | c.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); }); |
| 1178 | const s = el('button', 'ov-share-btn is-primary', { type: 'button', 'data-k': 'give-send', disabled: st.busy }, st.busy ? 'Sending…' : 'Send offer'); |
| 1179 | s.addEventListener('click', () => { const i = st.dlg.querySelector('[data-k="give-name"]'); sendOffer(st, i ? i.value : ''); }); |
| 1180 | f.appendChild(el('span', 'ov-share-grow')); |
| 1181 | f.appendChild(c); f.appendChild(s); |
| 1182 | return f; |
| 1183 | } |
| 1184 | if (st.confirmLeave) { |
| 1185 | f.classList.add('is-confirm'); |
| 1186 | f.appendChild(el('p', 'ov-share-grow', { role: 'alert' }, 'Leave without saving your changes?')); |
| 1187 | const k = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'keep-editing' }, 'Keep editing'); |
| 1188 | k.addEventListener('click', () => { st.confirmLeave = false; draw(st); }); |
| 1189 | const d = el('button', 'ov-share-btn is-danger', { type: 'button', 'data-k': 'discard' }, 'Discard'); |
| 1190 | d.addEventListener('click', () => close(st, true)); |
| 1191 | f.appendChild(k); f.appendChild(d); |
| 1192 | return f; |
| 1193 | } |
| 1194 | const s = st.server; |
| 1195 | if (s && owner(st) && !(s.transfer && s.transfer.state === 'pending') && s.transferable !== false) { |
| 1196 | const g = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give' }); |
| 1197 | g.appendChild(iconEl('give')); |
| 1198 | g.appendChild(el('span', null, null, 'Transfer')); |
| 1199 | g.setAttribute('aria-label', 'Transfer ownership'); |
| 1200 | g.addEventListener('click', () => { st.view = 'give'; st.error = ''; st.giveMsg = ''; draw(st, true); }); |
| 1201 | f.appendChild(g); |
| 1202 | } |
| 1203 | f.appendChild(el('span', 'ov-share-grow')); |
| 1204 | const isDirty = dirty(st); |
| 1205 | const done = el('button', `ov-share-btn ${isDirty ? 'is-primary' : ''}`, { type: 'button', 'data-k': 'done', disabled: st.busy || (isDirty && st.checking) }, st.busy ? 'Saving…' : isDirty ? 'Save' : 'Done'); |
| 1206 | done.addEventListener('click', () => (isDirty ? save(st) : close(st))); |
| 1207 | f.appendChild(done); |
| 1208 | return f; |
| 1209 | } |
| 1210 | |
| 1211 | async function save(st) { |
| 1212 | st.busy = true; st.error = ''; st.lockError = ''; draw(st); |
| 1213 | const members = st.audience === 'private' ? [] : st.members.map((m) => { |
| 1214 | const r = { role: m.role }; |
| 1215 | if (m.expires_at) r.expires_at = m.expires_at; |
| 1216 | if (m.subject) r.subject = m.subject; else r.name = m.name || m.username; |
| 1217 | return r; |
| 1218 | }); |
| 1219 | try { |
| 1220 | const body = { audience: st.audience, members, notify: !!st.notify }; |
| 1221 | // A people lock follows its people: someone added is wrapped for now, someone removed means the content is |
| 1222 | // sealed again. The bytes go up with the list, and this browser is the only place they were opened. |
| 1223 | if (isLocked(st) && st.server.lock === 'people') { |
| 1224 | const sealed = await resealIfNeeded(st); |
| 1225 | if (sealed) { body.members = sealed.members; if (sealed.envelope_ref) body.envelope_ref = sealed.envelope_ref; else body.envelope = sealed.envelope; } |
| 1226 | } |
| 1227 | const out = await call(st.o, 'PUT', endpoint(st.o).path, body, st.etag ? { 'if-match': st.etag } : null); |
| 1228 | take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path)); |
| 1229 | st.saved = st.server; st.note = 'Saved.'; st.reseal = false; st.lockNote = ''; |
| 1230 | announce(st.o, st.server); |
| 1231 | } catch (e) { |
| 1232 | if (e.status === 412) { |
| 1233 | try { take(st, await call(st.o, 'GET', endpoint(st.o).path)); } catch (_) { /* keep what is drawn */ } |
| 1234 | st.busy = false; st.error = 'Someone changed who can open this meanwhile. This is how it is now; make your change again.'; |
| 1235 | draw(st); return; |
| 1236 | } |
| 1237 | const names = e.body && Array.isArray(e.body.names) ? e.body.names : null; |
| 1238 | const code = (e.body && e.body.code) || e.code; |
| 1239 | if (code === 'share.key_changed') st.error = 'Someone’s locker key changed. Open the Locked part and use their new key, then save again.'; |
| 1240 | else if (code === 'share.no_key') st.error = 'Someone on the list has no locker yet. They make one the first time they open a locked item.'; |
| 1241 | else if (code === 'share.reseal_required') st.error = 'Removing someone from a locked item seals it again here. Unlock your locker and save again.'; |
| 1242 | else if (code === 'share.slot_required') st.error = 'Every person on a locked item needs their key. Add them again, then save.'; |
| 1243 | else st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`; |
| 1244 | } |
| 1245 | st.busy = false; draw(st); |
| 1246 | } |
| 1247 | |
| 1248 | // ── For the recipient of a transfer: a banner on the item ───────────────────────────────────────────────── |
| 1249 | function offer(container, opts) { |
| 1250 | const o = Object.assign({}, opts || {}); |
| 1251 | stylesheet(); |
| 1252 | const b = el('div', 'ov-share-banner is-offer', { role: 'region', 'aria-label': 'Ownership offer' }); |
| 1253 | const drawOffer = (state, err) => { |
| 1254 | b.replaceChildren(); |
| 1255 | b.appendChild(iconEl(state === 'accepted' ? 'tick' : 'give')); |
| 1256 | const p = el('p'); |
| 1257 | if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.'; |
| 1258 | else if (state === 'declined') p.textContent = 'You declined. Nothing changed.'; |
| 1259 | else { |
| 1260 | p.appendChild(el('strong', null, null, handle(o.from) || nameOf(o.from))); |
| 1261 | p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `)); |
| 1262 | p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. ')); |
| 1263 | if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`)); |
| 1264 | } |
| 1265 | b.appendChild(p); |
| 1266 | if (err) b.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err)); |
| 1267 | if (!state) { |
| 1268 | const acts = el('div', 'ov-share-acts'); |
| 1269 | const yes = el('button', 'ov-share-btn is-primary is-small', { type: 'button' }, 'Accept'); |
| 1270 | const no = el('button', 'ov-share-btn is-quiet is-small', { type: 'button' }, 'Decline'); |
| 1271 | const answer = async (verb) => { |
| 1272 | yes.disabled = no.disabled = true; |
| 1273 | try { |
| 1274 | const out = await call(o, 'POST', `${endpoint(o).path}/transfer/${verb}`, { transfer_id: o.transfer_id }); |
| 1275 | announce(o, out); |
| 1276 | drawOffer(verb === 'accept' ? 'accepted' : 'declined'); |
| 1277 | if (typeof o.onDone === 'function') o.onDone(verb === 'accept' ? 'accepted' : 'declined', out); |
| 1278 | } catch (e) { drawOffer(null, e.status === 410 || e.status === 404 ? 'This offer is no longer open.' : `Could not answer: ${e.message}`); } |
| 1279 | }; |
| 1280 | yes.addEventListener('click', () => answer('accept')); |
| 1281 | no.addEventListener('click', () => answer('decline')); |
| 1282 | acts.appendChild(yes); acts.appendChild(no); |
| 1283 | b.appendChild(acts); |
| 1284 | } |
| 1285 | }; |
| 1286 | drawOffer(null); |
| 1287 | container.replaceChildren(b); |
| 1288 | return b; |
| 1289 | } |
| 1290 | |
| 1291 | // ── For a person who is not on the list: ask, or sign in. Never the title or the content. ───────────────── |
| 1292 | function gate(container, opts) { |
| 1293 | const o = Object.assign({}, opts || {}); |
| 1294 | stylesheet(); |
| 1295 | const card = el('div', 'ov-share-gate', { role: 'region', 'aria-label': 'Access' }); |
| 1296 | const drawGate = (state, err) => { |
| 1297 | card.replaceChildren(); |
| 1298 | card.appendChild(iconEl('people', 'ov-share-ico is-big')); |
| 1299 | card.appendChild(el('h2', 'ov-share-gatetitle', null, state === 'asked' ? 'Asked' : 'This is shared with specific people')); |
| 1300 | if (!o.signedInAs) { |
| 1301 | card.appendChild(el('p', 'ov-share-muted', null, 'Sign in with the OpenVibe account it was shared with.')); |
| 1302 | const a = el('a', 'ov-share-btn is-primary', { href: o.signInUrl || '/login' }, 'Sign in'); |
| 1303 | card.appendChild(a); |
| 1304 | } else if (state === 'asked') { |
| 1305 | card.appendChild(el('p', 'ov-share-muted', null, 'The owner has a notification. You will get one when they answer.')); |
| 1306 | } else { |
| 1307 | const p = el('p', 'ov-share-muted'); |
| 1308 | p.appendChild(doc.createTextNode('You are signed in as ')); |
| 1309 | p.appendChild(el('strong', null, null, `@${cleanName(o.signedInAs)}`)); |
| 1310 | p.appendChild(doc.createTextNode(', who is not on the list.')); |
| 1311 | card.appendChild(p); |
| 1312 | if (err) card.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err)); |
| 1313 | const acts = el('div', 'ov-share-acts'); |
| 1314 | const ask = el('button', 'ov-share-btn is-primary', { type: 'button' }, 'Ask for access'); |
| 1315 | ask.addEventListener('click', async () => { |
| 1316 | ask.disabled = true; |
| 1317 | try { await call(o, 'POST', `${endpoint(o).path}/requests`, {}); drawGate('asked'); if (typeof o.onDone === 'function') o.onDone('asked'); } |
| 1318 | catch (e) { drawGate(null, e.status === 429 ? 'You asked recently. The owner has it.' : `Could not ask: ${e.message}`); } |
| 1319 | }); |
| 1320 | acts.appendChild(ask); |
| 1321 | if (o.signInUrl) acts.appendChild(el('a', 'ov-share-btn is-quiet', { href: o.signInUrl }, 'Switch account')); |
| 1322 | card.appendChild(acts); |
| 1323 | } |
| 1324 | }; |
| 1325 | drawGate(null); |
| 1326 | container.replaceChildren(card); |
| 1327 | return card; |
| 1328 | } |
| 1329 | |
| 1330 | // ── Buttons in markup: [data-ov-share-sheet] ────────────────────────────────────────────────────────────── |
| 1331 | doc.addEventListener('click', (e) => { |
| 1332 | const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null; |
| 1333 | if (!b || b.disabled) return; |
| 1334 | e.preventDefault(); |
| 1335 | open(fromButton(b)); |
| 1336 | }); |
| 1337 | |
| 1338 | // ── Gates in markup: [data-ov-share-gate] ───────────────────────────────────────────────────────────────────────── |
| 1339 | // A page's "not on the list" answer is markup only: the page escapes the values as it escapes any attribute, and no |
| 1340 | // inline script is needed (an inline script needs a CSP nonce on most of the network's sites, and a value written |
| 1341 | // into one has to be escaped for JavaScript, not HTML). Each gate starts once. |
| 1342 | function startGates(scope) { |
| 1343 | const within = scope && scope.querySelectorAll ? scope : doc; |
| 1344 | const nodes = within.querySelectorAll('[data-ov-share-gate]:not([data-ov-share-gate-on])'); |
| 1345 | let n = 0; |
| 1346 | nodes.forEach((node) => { |
| 1347 | const d = node.dataset || {}; |
| 1348 | if (!d.kind || !d.id) return; |
| 1349 | node.setAttribute('data-ov-share-gate-on', ''); |
| 1350 | gate(node, { kind: d.kind, id: d.id, signedInAs: d.signedInAs || null, signInUrl: d.signInUrl || undefined, base: d.base, path: d.path }); |
| 1351 | n += 1; |
| 1352 | }); |
| 1353 | return n; |
| 1354 | } |
| 1355 | if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', () => startGates()); |
| 1356 | else startGates(); |
| 1357 | |
| 1358 | // A button's data-* as open()'s options (navbar.js and kit-web.js use it too, once this file has loaded). |
| 1359 | function fromButton(b) { |
| 1360 | const d = b.dataset || {}; |
| 1361 | let time = 0; |
| 1362 | if (d.timeFrom) { const m = doc.querySelector(d.timeFrom); if (m && typeof m.currentTime === 'number' && isFinite(m.currentTime)) time = m.currentTime; } |
| 1363 | else if (d.time) time = Number(d.time) || 0; |
| 1364 | return { kind: d.kind, id: d.id, title: d.title || doc.title, text: d.text, url: d.url || d.link, image: d.image, embedUrl: d.embed, |
| 1365 | time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path }; |
| 1366 | } |
| 1367 | |
| 1368 | root.OVShare = { open, offer, gate, startGates, fromButton, version: 4 }; |
| 1369 | })(typeof window !== 'undefined' ? window : null); |
| 1370 |