openvibers/openvibe.shared ov-share.js

ov-share.js

main 89.8 KB · 1370 lines · javascript

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 'use strict';
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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