openvibers/openvibe.shared ov-thread.js

ov-thread.js

main 31.3 KB · 502 lines · javascript

1/* openvibe-shared/ov-thread.js — the conversation UI's behaviour (plan L55 C4), for markup from openvibe-shared/thread
2 * drawn with thread.css. The page works without it; with it:
3 * - the chats open as a drawer on a phone (Esc or the scrim closes it), and the search box filters them;
4 * - the composer grows as you type, Enter sends on a keyboard (Shift+Enter is a new line), Send waits for words;
5 * - a starter fills the composer; Copy on each answer and on each code block;
6 * - the thread opens at its bottom and stays there while you are there;
7 * - the sticky head and the chats column sit under the navbar, and follow it when it steps away.
8 * A site that streams turns uses window.OVThread:
9 * OVThread.add(html) append a turn (string or element) to the page's thread
10 * OVThread.replace(id, html) swap the turn data-turn="id" (its answer fades in)
11 * OVThread.live(id, text) the working turn's live line ("Reading the page")
12 * OVThread.work(id, html) append a row to the turn's work list
13 * OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)
14 * OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)
15 * OVThread.tool(id, { name, input, icon, key }) a running tool row in the turn's work list (built here, text only)
16 * OVThread.toolDone(id, ok, key) that tool's row finished (the row with key, else the last running one); ok false: it
17 * failed, null: it ended with no result (no mark)
18 * Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
19 */
20(function (root) {
21 'use strict';
22 if (root.OVThread) return;
23 var fine = root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches;
24 // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
25 var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
26 var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
27 const THREAD_REV = { 'ov-composer.js': 'fbc3cc30fd2e', 'ov-combo.js': '8001a3b19c2f' };
28 var selfSrc = (document.currentScript && document.currentScript.src) || '';
29 // 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
30 function loadCombo() {
31 if (root.OVCombo) { root.OVCombo.scan(); return; }
32 if (loadCombo.on || !document.querySelector('input[data-ov-combo]')) return;
33 loadCombo.on = true;
34 var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
35 var s = document.createElement('script');
36 s.async = true;
37 s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
38 document.head.appendChild(s);
39 }
40 function loadComposer(after) {
41 if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }
42 if (after) loadComposer.waiting.push(after);
43 if (loadComposer.on) return;
44 loadComposer.on = true;
45 var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
46 var s = document.createElement('script');
47 s.async = true;
48 s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-composer.js' + (THREAD_REV['ov-composer.js'] ? '?v=' + THREAD_REV['ov-composer.js'] : '');
49 s.addEventListener('load', function () { var list = loadComposer.waiting; loadComposer.waiting = []; list.forEach(function (fn) { try { fn(); } catch (e) { /* one box's failure never holds the page */ } }); }, { once: true });
50 document.head.appendChild(s);
51 }
52 loadComposer.waiting = [];
53
54 function q(el, sel) { return el ? el.querySelector(sel) : null; }
55 function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }
56 function frag(html) {
57 if (typeof html !== 'string') return html;
58 var t = document.createElement('template');
59 t.innerHTML = html.trim();
60 return t.content.firstElementChild;
61 }
62 function nearBottom() { return (root.innerHeight || 0) + (root.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220; }
63 function toBottom(smooth) { if (typeof root.scrollTo === 'function') root.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }
64
65 function copyText(text, btn) {
66 if (!canCopy) return;
67 navigator.clipboard.writeText(text).then(function () {
68 var label = btn.querySelector('span') || btn;
69 var was = label.textContent;
70 label.textContent = 'Copied';
71 // An icon button shows a tick for a moment (its word is only for screen readers and the tooltip).
72 var path = btn.querySelector('svg path'); var d = path && path.getAttribute('d');
73 if (path) path.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5');
74 btn.classList.add('is-done'); if (btn.title) btn.title = 'Copied';
75 setTimeout(function () { label.textContent = was; if (path) path.setAttribute('d', d); btn.classList.remove('is-done'); if (btn.title) btn.title = was; }, 1400);
76 }).catch(function () { /* a refused clipboard changes nothing */ });
77 }
78
79 /**
80 * What a reader means by an answer's text: without the Copy buttons and anything marked data-ovt-nocopy (a citation's
81 * number: "visits 12" was "visits 1 2"); the space before a removed mark goes when punctuation follows it. innerText
82 * keeps line breaks only for laid-out nodes, so the copy is read while it sits, invisible, in the page (Actor's, 11-01).
83 */
84 function plainText(el) {
85 var c = el.cloneNode(true);
86 qa(c, '[data-ovt-nocopy], .ovt-code-copy').forEach(function (x) {
87 var before = x.previousSibling, after = x.nextSibling;
88 if (before && before.nodeType === 3 && (!after || (after.nodeType === 3 && /^[\s,.;:!?)]/.test(after.nodeValue)))) before.nodeValue = before.nodeValue.replace(/\s+$/, '');
89 x.remove();
90 });
91 if (c.style) c.style.cssText = 'position:fixed;left:-10000px;top:0;width:' + (el.clientWidth || 600) + 'px;opacity:0;pointer-events:none';
92 c.setAttribute('aria-hidden', 'true');
93 document.body.appendChild(c);
94 var t = String(c.innerText || c.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
95 c.remove();
96 return t;
97 }
98
99 /** Copy on answers and code blocks under el (once each). */
100 function enhance(el) {
101 if (!el) return el;
102 qa(el, '[data-ovt-copy]').forEach(function (b) {
103 if (b.dataset.ovtReady) return;
104 b.dataset.ovtReady = '1';
105 if (!canCopy) return;
106 b.hidden = false;
107 b.addEventListener('click', function () {
108 var turn = b.closest('.ovt-turn');
109 var ans = q(turn, '[data-ovt-answer]');
110 if (!ans) return;
111 // What a reader means to copy: not the Copy buttons, nor anything the site marked data-ovt-nocopy (a citation's number).
112 copyText(plainText(ans), b);
113 });
114 });
115 if (canCopy) qa(el, '.ovt-answer pre').forEach(function (pre) {
116 if (pre.querySelector('.ovt-code-copy')) return;
117 var b = document.createElement('button');
118 b.type = 'button';
119 b.className = 'ovt-code-copy';
120 b.textContent = 'Copy';
121 b.addEventListener('click', function () { var code = pre.querySelector('code') || pre; copyText(code.innerText.replace(/\nCopy$/, ''), b); });
122 pre.appendChild(b);
123 });
124 return el;
125 }
126
127 /**
128 * The answer types in (the owner, 2026-11-01: "text streams in, with nice effects"). An answer that arrives whole
129 * is revealed word by word, fast (about 45 frames, whatever its length); code, tables, pictures and anything marked
130 * data-ovt-whole come in whole as the words reach them. Only opacity changes, so a screen reader reads it all at once,
131 * and reduced motion shows it at once.
132 */
133 function reveal(ans) {
134 if (!ans || reduce || !document.createTreeWalker) return;
135 var WHOLE = 'pre, table, img, figure, [data-ovt-whole]';
136 var words = [];
137 var walker = document.createTreeWalker(ans, 4, { acceptNode: function (n) { return n.parentNode.closest(WHOLE) ? 2 : (/\S/.test(n.nodeValue) ? 1 : 2); } });
138 var nodes = [];
139 while (walker.nextNode()) nodes.push(walker.currentNode);
140 nodes.forEach(function (n) {
141 var f = document.createDocumentFragment();
142 n.nodeValue.split(/(\s+)/).forEach(function (part) {
143 if (!part) return;
144 if (/^\s+$/.test(part)) { f.appendChild(document.createTextNode(part)); return; }
145 var w = document.createElement('span'); w.className = 'ovt-sw'; w.textContent = part; f.appendChild(w); words.push(w);
146 });
147 n.parentNode.replaceChild(f, n);
148 });
149 var blocks = qa(ans, WHOLE).filter(function (b) { return !b.parentNode.closest(WHOLE); });
150 blocks.forEach(function (b) { b.classList.add('ovt-sw'); });
151 var all = words.concat(blocks);
152 if (!all.length) return;
153 ans.classList.add('is-revealing');
154 var per = Math.max(2, Math.ceil(words.length / 45)), i = 0;
155 var raf = root.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
156 (function step() {
157 for (var k = 0; k < per && i < words.length; k++, i++) words[i].classList.add('on');
158 var last = words[Math.max(0, i - 1)];
159 blocks.forEach(function (b) { if (!b.classList.contains('on') && last && (b.compareDocumentPosition(last) & 4)) b.classList.add('on'); });
160 if (i < words.length) raf(step);
161 else { all.forEach(function (w) { w.classList.add('on'); }); setTimeout(function () { ans.classList.remove('is-revealing'); }, 300); }
162 })();
163 }
164
165 // The icons a streamed tool row can carry (thread.js draws the same ones on the server).
166 var ICONS = {
167 tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',
168 read: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
169 find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5',
170 edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
171 run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4',
172 web: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',
173 sub: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18',
174 ok: 'M5 12l5 5 9-10',
175 bad: 'M6 6l12 12M18 6L6 18',
176 };
177 function svgIcon(name) {
178 var ns = 'http://www.w3.org/2000/svg';
179 var s = document.createElementNS(ns, 'svg');
180 s.setAttribute('class', 'ovt-ic'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
181 var p = document.createElementNS(ns, 'path'); p.setAttribute('d', ICONS[name] || ICONS.tool);
182 s.appendChild(p);
183 return s;
184 }
185
186 function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }
187 function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }
188
189 var api = {
190 enhance: enhance,
191 reveal: reveal,
192 add: function (html) {
193 var list = threadEl();
194 var el = frag(html);
195 if (!list || !el) return null;
196 var stay = nearBottom();
197 el.classList.add('is-new');
198 list.appendChild(el);
199 var empty = q(list.closest('[data-ovt]'), '.ovt-empty-state');
200 if (empty) empty.hidden = true;
201 enhance(el);
202 if (stay) toBottom(true);
203 return el;
204 },
205 replace: function (id, html) {
206 var old = turnEl(id);
207 var el = frag(html);
208 if (!old || !el) return null;
209 var stay = nearBottom();
210 var finished = !el.hasAttribute('data-open') && old.hasAttribute('data-open');
211 old.replaceWith(el);
212 enhance(el);
213 // A turn that just finished: its answer types in (or, where motion is reduced or words cannot be split, fades).
214 if (finished) { var ans = q(el, '[data-ovt-answer]'); if (ans && !reduce && document.createTreeWalker) reveal(ans); else el.classList.add('is-fresh'); }
215 if (stay) toBottom(false);
216 return el;
217 },
218 live: function (id, text) {
219 var line = q(turnEl(id), '[data-ovt-live]');
220 if (line) line.textContent = String(text) + '…';
221 },
222 tool: function (id, t) {
223 t = t || {};
224 var li = document.createElement('li');
225 li.className = 'ovt-w-tool is-run';
226 if (t.key != null) li.setAttribute('data-key', String(t.key));
227 var row = document.createElement('div');
228 row.className = 'ovt-w-row';
229 var b = document.createElement('b'); b.textContent = String(t.name || 'Tool');
230 row.appendChild(svgIcon(t.icon)); row.appendChild(b);
231 if (t.input) { var inp = document.createElement('span'); inp.className = 'ovt-w-in'; inp.textContent = String(t.input).slice(0, 200); row.appendChild(inp); }
232 var st = document.createElement('span'); st.className = 'ovt-w-st';
233 var dot = document.createElement('span'); dot.className = 'ovt-dot'; dot.setAttribute('aria-hidden', 'true');
234 st.appendChild(dot); row.appendChild(st); li.appendChild(row);
235 return api.work(id, li);
236 },
237 toolDone: function (id, ok, key) {
238 var rows = qa(turnEl(id), '[data-ovt-work] > .ovt-w-tool.is-run');
239 var li = key != null ? rows.filter(function (r) { return r.getAttribute('data-key') === String(key); })[0] : rows[rows.length - 1];
240 if (!li) return null;
241 li.classList.remove('is-run'); li.classList.add(ok === null ? 'is-done' : ok === false ? 'is-bad' : 'is-ok');
242 var st = q(li, '.ovt-w-st');
243 if (st) { st.textContent = ''; if (ok !== null) st.appendChild(svgIcon(ok === false ? 'bad' : 'ok')); }
244 return li;
245 },
246 work: function (id, html) {
247 var list = q(turnEl(id), '[data-ovt-work]');
248 var el = frag(html);
249 if (!list || !el) return null;
250 var stay = nearBottom();
251 list.appendChild(el);
252 if (stay) toBottom(false);
253 return el;
254 },
255 };
256 root.OVThread = api;
257
258 function start() {
259 loadCombo();
260 qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
261 if (box.dataset.ovtReady) return;
262 box.dataset.ovtReady = '1';
263 var head = q(box, '[data-ovt-head]');
264
265 // Under the navbar: the sticky head and the chats column follow its bottom edge. The bar is found on each
266 // measure (navbar.js may draw it after this runs), and measured again once its slide ends: on a phone it
267 // slides away while reading down (.32 s), and the head then moves up to the top instead of leaving a gap.
268 var topWas = -1, ticking = false, trail = 0;
269 function measure() {
270 ticking = false;
271 var nav = document.querySelector('.openvibe-navbar') || document.getElementById('navbar-mount');
272 var b = nav && nav.getBoundingClientRect ? Math.max(0, Math.round(nav.getBoundingClientRect().bottom || 0)) : 0;
273 if (b !== topWas) { topWas = b; box.style.setProperty('--ovt-top', b + 'px'); }
274 if (head) head.classList.toggle('is-stuck', root.scrollY > 8);
275 }
276 function onScroll() {
277 if (!ticking) { ticking = true; (root.requestAnimationFrame || setTimeout)(measure); }
278 clearTimeout(trail); trail = setTimeout(measure, 380);
279 }
280 measure();
281 root.addEventListener('scroll', onScroll, { passive: true });
282 root.addEventListener('resize', onScroll);
283 setTimeout(measure, 400);
284
285 // The panel (thread.panel): a drawer from the right below 1180 px, opened from the head's button; its scrim, its
286 // close button and Escape close it, and the focus goes in and comes back.
287 var pan = q(box, '[data-ovt-panel]');
288 var panOpen = q(box, '[data-ovt-panel-open]');
289 var panScrim = q(box, '[data-ovt-panel-scrim]');
290 function panel(on) {
291 if (!pan) return;
292 box.classList.toggle('is-panel', on);
293 if (panScrim) panScrim.hidden = !on;
294 if (panOpen) panOpen.setAttribute('aria-expanded', on ? 'true' : 'false');
295 if (on) { var c = q(pan, '[data-ovt-panel-close]'); if (c) c.focus(); }
296 else if (panOpen && document.activeElement && pan.contains(document.activeElement)) panOpen.focus();
297 }
298 if (pan) {
299 if (panOpen) panOpen.addEventListener('click', function () { panel(!box.classList.contains('is-panel')); });
300 qa(box, '[data-ovt-panel-close], [data-ovt-panel-scrim]').forEach(function (el) { el.addEventListener('click', function () { panel(false); }); });
301 document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-panel')) panel(false); });
302 }
303
304 // Find a chat, in the network's sidebar (kit.sidebar): it filters as you type. Enter keeps you here when the
305 // form is filter-only (thread.sidebar's default); otherwise it runs the site's own search (every message).
306 var kfind = q(box, '.ovk-sidebar-find input');
307 if (kfind) {
308 var kform = kfind.closest('form');
309 if (kform && kform.hasAttribute('data-ovt-filter-only')) kform.addEventListener('submit', function (e) { e.preventDefault(); });
310 kfind.addEventListener('input', function () {
311 var term = kfind.value.trim().toLowerCase();
312 qa(box, '.ovk-sidebar-secs .ovk-sidebar-item').forEach(function (a) { var tt = q(a, '.ovk-sidebar-t'); a.hidden = !!term && (!tt || tt.textContent.toLowerCase().indexOf(term) === -1); });
313 qa(box, '.ovk-sidebar-secs .ovk-sidebar-sec').forEach(function (sec) { sec.hidden = !!term && !sec.querySelector('.ovk-sidebar-item:not([hidden])'); });
314 });
315 }
316
317 // The composer. An inline Community thread (thread.renderThread) draws the comment preset: the same
318 // picture, box, tools, count and Reply as every site's comments, replacing the server-drawn form in place
319 // (without script that form still posts).
320 var form = null, text = null, send = null;
321 function bootComposer(box) {
322 var f = q(box, '[data-ovt-composer]');
323 if (!f) return;
324 if (!f.closest('[data-ovc-thread]')) {
325 if (!root.OpenVibeComposer) loadComposer();
326 else if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);
327 wireComposer(f);
328 return;
329 }
330 if (!root.OpenVibeComposer) { loadComposer(function () { bootComposer(box); }); return; }
331 if (root.OpenVibeComposer.mount) {
332 var upgraded = upgradeComposer(f);
333 if (upgraded) { wireComposer(upgraded); return; }
334 }
335 if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);
336 wireComposer(f);
337 }
338 // The form's own submit stays: mount() draws the box and the server's action, method and hidden fields
339 // (a reply's parent_id) go back on it, so the page still posts without a site script.
340 function upgradeComposer(f) {
341 if (!f || f.__ovtUpgraded || !root.OpenVibeComposer || !root.OpenVibeComposer.mount) return null;
342 var oldText = f.querySelector('[data-ovt-text]') || f.querySelector('textarea');
343 if (!oldText) return null;
344 var action = f.getAttribute('action') || '';
345 var method = f.getAttribute('method') || 'post';
346 var hidden = qa(f, 'input[type=hidden]').map(function (i) { return [i.name, i.value]; });
347 var file = f.querySelector('input[type=file]');
348 var note = q(f, '[data-ovt-note]');
349 var sendBtn = q(f, '[data-ovt-send]');
350 var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';
351 var preset = f.getAttribute('data-ovt-preset') || 'comment';
352 var host = document.createElement('div');
353 f.replaceWith(host);
354 var c = null;
355 try {
356 c = root.OpenVibeComposer.mount(host, {
357 preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',
358 maxLength: Number(oldText.getAttribute('maxlength')) || 2000, submitLabel: label,
359 tools: file ? ['photo', 'emoji'] : ['emoji'], maxFiles: Number(f.getAttribute('data-ovc-max-files')) || 10,
360 });
361 } catch (e) { c = null; }
362 if (!c) { host.replaceWith(f); return null; }
363 c.form.action = action; c.form.method = method;
364 if (file) {
365 c.form.enctype = 'multipart/form-data';
366 var field = file.name || 'files';
367 qa(c.form, 'input[type=file]').forEach(function (i) { i.name = field; if (file.accept) i.accept = file.accept; });
368 }
369 hidden.forEach(function (h) { var i = document.createElement('input'); i.type = 'hidden'; i.name = h[0]; i.value = h[1]; c.form.appendChild(i); });
370 if (oldText.value) c.text.value = oldText.value;
371 if (note && note.textContent) c.note.textContent = note.textContent;
372 c.form.__ovtUpgraded = true;
373 return c.form;
374 }
375 function wireComposer(f) {
376 if (!f || f.__ovtWired) return;
377 form = f;
378 text = q(f, '[data-ovt-text]') || q(f, 'textarea');
379 send = q(f, '[data-ovt-send]');
380 function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }
381 function syncSend() { if (send && text) send.disabled = !text.value.trim(); }
382 if (text && !text.__ovtKeys) {
383 text.__ovtKeys = true;
384 text.addEventListener('input', function () { grow(); syncSend(); });
385 text.addEventListener('keydown', function (e) {
386 if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {
387 e.preventDefault();
388 if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();
389 }
390 });
391 grow();
392 syncSend();
393 }
394 qa(box, '[data-ovt-starter]').forEach(function (b) {
395 if (b.__ovtStarter) return; b.__ovtStarter = true;
396 b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); if (form.__ovc && form.__ovc.ctx) form.__ovc.ctx.set(text.value); grow(); syncSend(); text.focus(); });
397 });
398 // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).
399 if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {
400 text.__ovtEx = true;
401 var exI = 0;
402 var exTimer = setInterval(function () {
403 if (!text.isConnected) { clearInterval(exTimer); return; }
404 var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }
405 if (!list.length || text.value || document.activeElement === text || document.hidden) return;
406 exI = (exI + 1) % list.length;
407 text.classList.add('is-ph-out');
408 setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);
409 }, 3600);
410 }
411 f.__ovtWired = true;
412 }
413 bootComposer(box);
414
415 // Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,
416 // Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as
417 // a chip beside the button, and its × puts the default back.
418 qa(box, '[data-ovt-opts]').forEach(function (d) {
419 if (d.__ovt) return; d.__ovt = true;
420 var chips = d.parentNode ? q(d.parentNode, '[data-ovt-opts-chips]') : null;
421 var dot = q(d, '.ovt-opts-dot');
422 function phone() { return root.matchMedia && root.matchMedia('(max-width: 640px)').matches; }
423 function lock(on) { document.documentElement.classList.toggle('ovt-sheet-open', !!on && phone()); }
424 function close() { if (d.open) { d.open = false; } }
425 d.addEventListener('toggle', function () {
426 lock(d.open);
427 // On a computer the panel grows out of its button (the navbar's shared motion); a phone's sheet rises.
428 if (d.open && !phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) { var pnl = q(d, '.ovt-opts-panel'); if (pnl) root.OpenVibeNavbar.pop(pnl, q(d, 'summary')); }
429 if (d.open) { var f = q(d, '.ovt-opts-body select, .ovt-opts-body input, .ovt-opts-body button'); if (f && !phone()) f.focus({ preventScroll: true }); }
430 });
431 document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) close(); }, true);
432 d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); close(); var s = q(d, 'summary'); if (s) s.focus(); } });
433 var x = q(d, '[data-ovt-opts-close]'); if (x) x.addEventListener('click', function (e) { e.preventDefault(); close(); });
434 function label(el) {
435 if (el.tagName === 'SELECT') { var o = el.options[el.selectedIndex]; return o ? o.textContent.trim() : el.value; }
436 var l = el.closest('label'); return l ? l.textContent.trim() : el.value;
437 }
438 function paint() {
439 if (!chips) return;
440 chips.textContent = '';
441 var any = false;
442 qa(d, '[data-ovt-default]').forEach(function (el) {
443 if (el.closest('[hidden]') || (el.offsetParent === null && !d.open && getComputedStyle(el.closest('label, .ovt-opt') || el).display === 'none')) return;
444 var def = el.getAttribute('data-ovt-default');
445 var cur = el.tagName === 'SELECT' ? el : q(el, 'input:checked');
446 var val = cur ? cur.value : def;
447 if (val === def) return;
448 any = true;
449 var chip = document.createElement('span'); chip.className = 'ovt-opt-chip';
450 var t = document.createElement('span'); t.textContent = label(cur).slice(0, 40); chip.appendChild(t);
451 var b = document.createElement('button'); b.type = 'button'; b.className = 'ovt-opt-chip-x'; b.setAttribute('aria-label', 'Back to the default: ' + t.textContent); b.textContent = '×';
452 b.addEventListener('click', function () {
453 if (el.tagName === 'SELECT') el.value = def; else { var r = q(el, 'input[value="' + (root.CSS && CSS.escape ? CSS.escape(def) : def) + '"]'); if (r) r.checked = true; }
454 (el.tagName === 'SELECT' ? el : q(el, 'input:checked') || el).dispatchEvent(new Event('change', { bubbles: true }));
455 });
456 chip.appendChild(b);
457 chip.addEventListener('click', function (e) { if (e.target === b) return; d.open = true; });
458 chips.appendChild(chip);
459 });
460 if (dot) dot.hidden = !any;
461 }
462 d.addEventListener('change', paint);
463 if (form) form.addEventListener('ovt:options', paint);
464 paint();
465 });
466
467 // Attach's menu: a tap outside or a chosen file closes it.
468 qa(box, '[data-ovt-attach]').forEach(function (d) {
469 if (d.__ovt) return; d.__ovt = true;
470 document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) d.open = false; }, true);
471 d.addEventListener('change', function () { d.open = false; });
472 d.addEventListener('toggle', function () { if (d.open && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) { var pp = q(d, '.ovt-attach-pop'); if (pp) root.OpenVibeNavbar.pop(pp, q(d, 'summary')); } });
473 d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); d.open = false; var s = q(d, 'summary'); if (s) s.focus(); } });
474 });
475
476 // A kit sidebar's button goes into the navbar, before the brand, on a phone (11-09, the owner: the button on a
477 // row of its own under the bar wasted the space): the bar's lead slot opens the same drawer.
478 var sbBtn = q(box, '.ovt-head [data-ovk-sidebar-open]');
479 if (sbBtn) navLead(box, sbBtn, 0);
480
481 enhance(box);
482 if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
483 });
484 }
485
486 // The navbar may draw after this script: try for a few seconds. The slot goes when a page without a thread loads.
487 var leadOn = false;
488 function navLead(box, btn, n) {
489 var nav = root.OpenVibeNavbar;
490 if (!nav || typeof nav.lead !== 'function' || !nav.getElement || !nav.getElement()) { if (n < 40) setTimeout(function () { navLead(box, btn, n + 1); }, 125); return; }
491 nav.lead({ icon: 'sidebar', label: btn.getAttribute('aria-label') || 'Your conversations', sidebar: btn.getAttribute('aria-controls') || '', phoneOnly: true });
492 leadOn = true;
493 box.classList.add('has-nav-lead');
494 }
495 document.addEventListener('ov:boost:load', function () {
496 if (leadOn && !document.querySelector('[data-ovt] .ovt-head [data-ovk-sidebar-open]') && root.OpenVibeNavbar && root.OpenVibeNavbar.lead) { root.OpenVibeNavbar.lead(null); leadOn = false; }
497 });
498
499 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
500 document.addEventListener('ov:boost:load', start);
501})(typeof window !== 'undefined' ? window : globalThis);
502