openvibers/openvibe.shared ov-thread.js
ov-thread.js
| 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 | ; |
| 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 |