openvibers/openvibe.shared thread.js
thread.js
| 1 | ; |
| 2 | // ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ |
| 3 | // openvibe-shared/thread — one conversation UI for the network (plan L55 C4): the chats in a sidebar, the thread of |
| 4 | // turns, the work an agent did folded under its answer, and the composer. Actor's chat proved the shape (L55 C1–C3); |
| 5 | // Vibe (openvibe.chat), Codes and the Dock use the same parts, so a conversation looks and behaves the same on every |
| 6 | // site. Server-rendered and complete without script; ov-thread.js adds the drawer, the search, the growing composer, |
| 7 | // Enter to send, copy, staying at the bottom, and helpers to add and update turns as they stream. Draw with |
| 8 | // thread.css. Theme tokens only. |
| 9 | // |
| 10 | // const thread = require('openvibe-shared/thread'); |
| 11 | // thread.page({ |
| 12 | // sidebar: thread.sidebar({ newHref: '/chat', groups: thread.group(chats, (c) => c.at) .map(...) }), |
| 13 | // head: thread.head({ title: 'Plan a weekend' }), |
| 14 | // turns: [thread.turn({ id, you: { text }, reply: thread.work({...}) + thread.answer({...}) })], |
| 15 | // composer: thread.composer({ action: '/chat', placeholder: 'Ask anything' }), |
| 16 | // }); |
| 17 | // |
| 18 | // Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown). |
| 19 | // ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ |
| 20 | |
| 21 | const kit = require('./kit'); |
| 22 | |
| 23 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 24 | const attr = (name, v) => (v == null || v === false ? '' : v === true ? ` ${name}` : ` ${name}="${esc(v)}"`); |
| 25 | const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; }; |
| 26 | |
| 27 | // Stroke icons, 24-unit grid, drawn in currentColor (thread.css sizes them). |
| 28 | const P = { |
| 29 | plus: 'M12 5v14M5 12h14', |
| 30 | close: 'M6 6l12 12M18 6L6 18', |
| 31 | menu: 'M4 7h16M4 12h16M4 17h16', |
| 32 | chev: 'M8 10l4 4 4-4', |
| 33 | copy: 'M9 9h10v10H9zM5 15V5h10', |
| 34 | retry: 'M4 12a8 8 0 0 1 14-5.3M20 4v4.5h-4.5M20 12a8 8 0 0 1-14 5.3M4 20v-4.5h4.5', |
| 35 | like: 'M7 11v9H4v-9zM7 11l4-7c1.5 0 2.5 1 2.5 2.5L13 10h5.5a2 2 0 0 1 2 2.3l-1.2 6A2 2 0 0 1 17.3 20H7', |
| 36 | dislike: 'M7 13V4H4v9zM7 13l4 7c1.5 0 2.5-1 2.5-2.5L13 14h5.5a2 2 0 0 0 2-2.3l-1.2-6A2 2 0 0 0 17.3 4H7', |
| 37 | share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6', |
| 38 | edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', |
| 39 | photo: 'M4 6h16v12H4zM4 15l4.5-4 3.5 3 2.5-2 5.5 4.5M15.5 9.5h.01', |
| 40 | camera: 'M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 11a3 3 0 1 1 0 6 3 3 0 0 1 0-6z', |
| 41 | file: 'M6 3h8l4 4v14H6zM14 3v4h4', |
| 42 | cloud: 'M7 18h10.5a3.5 3.5 0 0 0 .3-7 5.5 5.5 0 0 0-10.6-1.5A4 4 0 0 0 7 18z', |
| 43 | add: 'M12 5v14M5 12h14', |
| 44 | more: 'M5 12h.01M12 12h.01M19 12h.01', |
| 45 | 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', |
| 46 | ok: 'M5 12l5 5 9-10', |
| 47 | bad: 'M6 6l12 12M18 6L6 18', |
| 48 | step: 'M9 6l6 6-6 6', |
| 49 | note: 'M5 5h14v10H9l-4 4z', |
| 50 | you: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0', |
| 51 | file: 'M7 3h7l5 5v13H7zM14 3v5h5', |
| 52 | send: 'M5 12l14-7-5 14-2.5-5.5z', |
| 53 | stop: 'M7 7h10v10H7z', |
| 54 | attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8', |
| 55 | options: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5', |
| 56 | handover: 'M4 8h13l-3-3M20 16H7l3 3', |
| 57 | wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', |
| 58 | panel: 'M4 4h16v16H4zM15 4v16', |
| 59 | shield: 'M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6zM9 12l2 2 4-4', |
| 60 | // A step's kind (work rows' icon): read, find, edit, run, web, sub. |
| 61 | 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', |
| 62 | find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5', |
| 63 | edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', |
| 64 | run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4', |
| 65 | 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', |
| 66 | 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', |
| 67 | }; |
| 68 | const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`; |
| 69 | |
| 70 | /** Today / Yesterday / This week / Earlier, from an ISO time (or ms) and now. */ |
| 71 | function groupOf(when, now = Date.now()) { |
| 72 | const t = typeof when === 'number' ? when : Date.parse(when); |
| 73 | if (!Number.isFinite(t)) return 'Earlier'; |
| 74 | const day = (x) => { const d = new Date(x); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); }; |
| 75 | const days = Math.round((day(now) - day(t)) / 864e5); |
| 76 | return days <= 0 ? 'Today' : days === 1 ? 'Yesterday' : days < 7 ? 'This week' : 'Earlier'; |
| 77 | } |
| 78 | |
| 79 | /** Items (newest first) into the sidebar's groups: [{ name, items }]. `whenOf(item)` gives each one's time. */ |
| 80 | function group(items, whenOf, now = Date.now()) { |
| 81 | const out = []; |
| 82 | for (const it of items || []) { |
| 83 | const name = groupOf(whenOf(it), now); |
| 84 | if (!out.length || out[out.length - 1].name !== name) out.push({ name, items: [] }); |
| 85 | out[out.length - 1].items.push(it); |
| 86 | } |
| 87 | return out; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * The chats, in the network's one sidebar (kit.sidebar, plan L35: "the navigation makes more sense in a vertical way… |
| 92 | * shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without |
| 93 | * script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column |
| 94 | * beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button). |
| 95 | * groups: [{ name, items: [{ href, title, current, live, dot, meta, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }] |
| 96 | * pinned: chats kept above the groups (Codes' conductor), the same shape as a group's items |
| 97 | * dot: 'busy' (live sets it), 'needs' (waiting for the person) or 'bad'; meta: a quiet word after the title ("2h") |
| 98 | * findSubmit: Enter in Find a chat runs the site's search at findAction (every message), not only the filter |
| 99 | * Draw with kit.css and kit-web.js beside thread.css and ov-thread.js. |
| 100 | */ |
| 101 | function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', findPlaceholder = 'Find a chat', findSubmit = false, groups = [], pinned = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) { |
| 102 | const item = (c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.dot || (c.live ? 'busy' : null), meta: c.dot || c.live ? '' : (c.meta || ''), mark: c.mark || '' }); |
| 103 | const sections = [ |
| 104 | ...(pinned && pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []), |
| 105 | ...groups.filter((g) => g && g.items && g.items.length).map((g) => ({ title: g.name, items: g.items.map(item) })), |
| 106 | ]; |
| 107 | const html = kit.sidebar({ |
| 108 | id, label, |
| 109 | action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null, |
| 110 | find: find ? { action: findAction || newHref || '', name: 'q', placeholder: findPlaceholder, label: findPlaceholder } : null, |
| 111 | nav, |
| 112 | sections: sections.length ? sections : [{ title: '', items: [], empty }], |
| 113 | foot: foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : '', |
| 114 | }); |
| 115 | return findSubmit ? html : html.replace('<form class="ovk-sidebar-find"', '<form class="ovk-sidebar-find" data-ovt-filter-only'); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * The sticky head over the thread: the button that opens the chats on a phone (kit.sidebarButton), the title, New chat, |
| 120 | * and the caller's actions. `sidebar` names the sidebar's id (false for none). `panel` names a panel()'s id: a button |
| 121 | * that opens it where it is a drawer (below 1180 px), labelled `panelLabel`. |
| 122 | */ |
| 123 | function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', sidebarLabel = 'Your chats', actions = '', panel: pid = null, panelLabel = 'Details' } = {}) { |
| 124 | const panelBtn = pid ? `<button type="button" class="ovt-btn ovt-panel-open" data-ovt-panel-open aria-controls="${esc(pid)}" aria-expanded="false" aria-label="${esc(panelLabel)}" title="${esc(panelLabel)}">${icon('panel')}</button>` : ''; |
| 125 | return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: sidebarLabel }) : ''}${title ? `<h1 class="ovt-title">${esc(title)}</h1>` : '<span class="ovt-title"></span>'}${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}${panelBtn}</header>`; |
| 126 | } |
| 127 | |
| 128 | // An agent's colour, only as a colour: #rgb…#rrggbbaa or a theme token, never anything else in a style attribute. |
| 129 | const COLOUR = /^(#[0-9a-f]{3,8}|var\(--[a-z0-9-]+\))$/i; |
| 130 | /** An agent's face: its initials (or the caller's safe mark) in its own colour. who: { name, initials, color, mark } */ |
| 131 | function face(who = {}) { |
| 132 | const colour = who.color && COLOUR.test(String(who.color)) ? ` style="--ovt-who:${who.color}"` : ''; |
| 133 | const inner = who.mark != null ? who.mark : esc(String(who.initials || String(who.name || '?').slice(0, 1).toUpperCase()).slice(0, 2)); |
| 134 | return `<span class="ovt-avatar is-who"${colour} aria-hidden="true">${inner}</span>`; |
| 135 | } |
| 136 | |
| 137 | const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`); |
| 138 | /** Files beside a message: a picture as a thumbnail, anything else as a named chip. files: [{ href, name, size, image }] */ |
| 139 | function files(list = []) { |
| 140 | if (!list || !list.length) return ''; |
| 141 | return `<ul class="ovt-files">${list.map((f) => `<li>${f.image |
| 142 | ? `<a href="${esc(f.href)}" target="_blank" rel="noopener"><img src="${esc(f.href)}" alt="${esc(f.name || '')}" loading="lazy"></a>` |
| 143 | : `<a class="ovt-file" href="${esc(f.href)}" target="_blank" rel="noopener">${icon('file')}<span><b>${esc(f.name || 'File')}</b>${f.size != null ? `<small>${bytes(f.size)}</small>` : ''}</span></a>`}</li>`).join('')}</ul>`; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(), |
| 148 | * card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it). |
| 149 | * who: { name, initials, color, sub, mark } heads the reply with an agent's face and name, for a site where several |
| 150 | * agents work in one conversation (Codes hands a task from one to another); sub is a quiet line ("sonnet · Thorough") |
| 151 | * you.caption: a small word over the person's bubble ("Hand-over brief"); reply: null draws only the person's side |
| 152 | */ |
| 153 | function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, who = null, attrs = {} } = {}) { |
| 154 | const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join(''); |
| 155 | const mine = you && (you.html != null || you.text != null || (you.files && you.files.length)) |
| 156 | ? `<div class="ovt-you">${you.caption ? `<span class="ovt-caption">${esc(you.caption)}</span>` : ''}${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : ''; |
| 157 | const mark = who ? face(who) : avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : ''; |
| 158 | const name = who ? `<p class="ovt-who"><b>${esc(who.name || 'Agent')}</b>${who.sub ? `<span>${esc(who.sub)}</span>` : ''}</p>` : ''; |
| 159 | const bot = reply == null ? '' : `<div class="ovt-bot">${mark}<div class="ovt-reply">${name}${reply}</div></div>`; |
| 160 | return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}> |
| 161 | ${mine}${bot} |
| 162 | </li>`; |
| 163 | } |
| 164 | |
| 165 | /** A line across the thread between turns: a hand-over to another agent ("Codex took over"), a wait. icon: handover | wait */ |
| 166 | function divider({ text = '', icon: ic = 'handover' } = {}) { |
| 167 | return `<li class="ovt-divider"><span>${icon(ic)}${esc(text)}</span></li>`; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the |
| 172 | * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running, icon }]. |
| 173 | * icon: a thread icon for the row (a coding agent's read, find, edit, run, web, sub); a tool that finished with no output |
| 174 | * is a plain row, with nothing to open. running: false with no ok is a step that ended without a result: no mark. |
| 175 | */ |
| 176 | function work({ open = false, live = 'Working', took = null, rows = [] } = {}) { |
| 177 | const tools = rows.filter((r) => r.kind === 'tool').length; |
| 178 | if (!open && !rows.length) return ''; |
| 179 | const items = rows.map((r) => { |
| 180 | if (r.kind === 'tool') { |
| 181 | const st = r.running === false && r.ok == null ? 'is-done' : r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad'; |
| 182 | const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : icon(st === 'is-ok' ? 'ok' : 'bad'); |
| 183 | const head = `${icon(r.icon || 'tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${mark}</span>`; |
| 184 | if (r.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}</div></li>`; |
| 185 | return `<li class="ovt-w-tool ${st}"><details><summary>${head}</summary> |
| 186 | ${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`; |
| 187 | } |
| 188 | if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`; |
| 189 | if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`; |
| 190 | return `<li class="ovt-w-step">${icon(r.icon || 'step')}<span>${esc(clip(r.text, 300))}</span></li>`; |
| 191 | }); |
| 192 | const line = open |
| 193 | ? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>` |
| 194 | : `<span>${took ? `Worked for ${esc(took)}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`; |
| 195 | return `<details class="ovt-work"${open ? ' open data-open' : ''}><summary>${line}${icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${items.join('')}</ol></details>`; |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * The answer: rendered Markdown (html, already safe), files and charts, sources as quiet links, and a footer with what |
| 200 | * it ran on and the actions (Copy is drawn hidden and shown by ov-thread.js, which can copy). |
| 201 | * meta: ['Balanced · DeepSeek', '$0.002', '14 s']; actions: [{ label, href, icon }] |
| 202 | */ |
| 203 | function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) { |
| 204 | const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } }; |
| 205 | const acts = [ |
| 206 | // Icon buttons, as every assistant draws them (11-09, the owner: "Copy and Try again should be actual icons"): the |
| 207 | // word is the tooltip and the accessible name. |
| 208 | copy ? `<button type="button" class="ovt-act" data-ovt-copy title="Copy" aria-label="Copy" hidden>${icon('copy')}<span class="ovt-sr">Copy</span></button>` : '', |
| 209 | ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}" title="${esc(a.label)}" aria-label="${esc(a.label)}">${icon(a.icon || (/again|retry/i.test(String(a.label)) ? 'retry' : 'more'))}<span class="ovt-sr">${esc(a.label)}</span></a>`), |
| 210 | ].join(''); |
| 211 | return `<div class="ovt-answer" data-ovt-answer>${html}</div> |
| 212 | ${files(att)} |
| 213 | ${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a href="${esc(u)}" rel="nofollow ugc noopener" target="_blank">${esc(host(u))}</a>`).join('')}</p>` : ''} |
| 214 | ${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup. |
| 219 | * With form it is a form that asks something (an agent wanting to run a command: Allow or Deny), posting without script: |
| 220 | * code: what it wants to run (a block that scrolls inside itself); icon: a thread icon before the title (shield, wait…) |
| 221 | * form: { action, hidden: { k: v }, buttons: [{ name, value, label, quiet }] } |
| 222 | */ |
| 223 | function card({ tone = 'info', title = '', text = null, html = '', code = null, icon: ic = null, form = null } = {}) { |
| 224 | const lead = ic ? icon(ic) : ''; |
| 225 | const said = title ? `<b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}` : text != null ? esc(text) : ''; |
| 226 | const words = !said ? '' : ic ? `<p class="ovt-card-h">${lead}<span>${said}</span></p>` : `<p>${said}</p>`; |
| 227 | const block = code != null ? `<pre class="ovt-card-code" tabindex="0">${esc(code)}</pre>` : ''; |
| 228 | if (!form) return `<div class="ovt-card is-${esc(tone)}">${words}${block}${html}</div>`; |
| 229 | const hid = Object.entries(form.hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join(''); |
| 230 | const buttons = (form.buttons || []).map((b) => `<button type="submit" class="ovt-card-btn${b.quiet ? ' is-quiet' : ''}"${attr('name', b.name)}${attr('value', b.value)}>${esc(b.label)}</button>`).join(''); |
| 231 | return `<form class="ovt-card is-${esc(tone)}" method="post" action="${esc(form.action || '')}">${hid}${words}${block}${html}${buttons ? `<div class="ovt-card-acts">${buttons}</div>` : ''}</form>`; |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * The panel beside the conversation: what the work produced (who worked on it, where, the changes, the proof). A third |
| 236 | * column from 1180 px; below that a drawer from the right that head({ panel: id })'s button opens (ov-thread.js). |
| 237 | * sections: [{ title, html, rows: [{ who, icon, iconHtml, title, sub, html }] }]; a row's who draws an agent's face (turn's |
| 238 | * who), icon a thread icon, iconHtml the site's own safe icon; html is the caller's safe markup in place of title and sub. |
| 239 | */ |
| 240 | function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) { |
| 241 | const row = (r) => `<li>${r.who ? face(r.who) : r.iconHtml != null ? r.iconHtml : r.icon ? icon(r.icon) : ''}<span>${r.html != null ? r.html : `<b>${esc(r.title || '')}</b>${r.sub ? `<small>${esc(r.sub)}</small>` : ''}`}</span></li>`; |
| 242 | const secs = sections.filter(Boolean).map((s) => `<section class="ovt-panel-sec">${s.title ? `<h3>${esc(s.title)}</h3>` : ''}${s.rows && s.rows.length ? `<ul class="ovt-panel-rows">${s.rows.map(row).join('')}</ul>` : ''}${s.html || ''}</section>`).join(''); |
| 243 | return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`; |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Attach (11-09, the owner: "a better file attach button… support all file types"): one + button. Every file type by |
| 248 | * default (attach.accept narrows it). With attach.menu, a small menu: Files, Photos and videos, Take a photo (a phone's |
| 249 | * camera), and From your OpenVibe files when the site gives attach.library (an href). All the inputs share one name, so |
| 250 | * the plain form posts them all. |
| 251 | */ |
| 252 | function attachHtml(attach) { |
| 253 | const name = esc(attach.name || 'files'); |
| 254 | const input = (accept, extra = '') => `<input type="file" name="${name}"${accept ? ` accept="${esc(accept)}"` : ''} multiple data-ovt-files${extra} hidden>`; |
| 255 | if (!attach.menu) return `<label class="ovt-btn ovt-attach" title="Attach files">${icon('add')}<span class="ovt-sr">Attach files</span>${input(attach.accept)}</label>`; |
| 256 | const row = (ic, label, inner) => `<label class="ovt-attach-row" role="menuitem">${icon(ic)}<span>${esc(label)}</span>${inner}</label>`; |
| 257 | return `<details class="ovt-attach-menu" data-ovt-attach><summary class="ovt-btn ovt-attach" title="Attach" aria-label="Attach">${icon('add')}</summary><div class="ovt-attach-pop" role="menu">` |
| 258 | + row('file', 'Files', input(attach.accept)) |
| 259 | + row('photo', 'Photos and videos', input('image/*,video/*')) |
| 260 | + `<label class="ovt-attach-row ovt-attach-cam" role="menuitem">${icon('camera')}<span>Take a photo</span>${input('image/*', ' capture="environment"')}</label>` |
| 261 | + (attach.library ? `<a class="ovt-attach-row" role="menuitem" href="${esc(attach.library)}">${icon('cloud')}<span>From your OpenVibe files</span></a>` : '') |
| 262 | + '</div></details>'; |
| 263 | } |
| 264 | |
| 265 | /** The composer's Options: one button, the pickers behind it, and the chips of what differs from the defaults. */ |
| 266 | function optionsHtml({ html, label = 'Options' }) { |
| 267 | return `<details class="ovt-opts" data-ovt-opts><summary class="ovt-btn ovt-opts-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon('options')}<i class="ovt-opts-dot" aria-hidden="true"></i></summary>` |
| 268 | + `<div class="ovt-opts-panel" role="group" aria-label="${esc(label)}"><div class="ovt-opts-head"><b>${esc(label)}</b><button type="button" class="ovt-btn ovt-opts-x" data-ovt-opts-close aria-label="Done">${icon('close')}</button></div><div class="ovt-opts-body">${html}</div></div></details>` |
| 269 | + '<span class="ovt-opts-chips" data-ovt-opts-chips></span>'; |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model |
| 274 | * chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script. |
| 275 | * hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat) |
| 276 | * top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit |
| 277 | * stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself |
| 278 | * id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes |
| 279 | * foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that, |
| 280 | * which is also the form's status line (role="status": a site's script says "Sending…" there) |
| 281 | * options: { html, label } the site's safe pickers behind one Options button (11-09, the owner: "too many options… |
| 282 | * hidden under a cog"): a panel on a computer, a sheet on a phone, a <details> without script. A picker whose |
| 283 | * <select> or radio group carries data-ovt-default="<value>" shows as a chip beside the button while it differs |
| 284 | * (its × puts the default back), and the button wears a dot. |
| 285 | * examples: ['Make the home page faster on phones', …] the box's placeholder walks through them while it is empty |
| 286 | * commands: [{ name: 'fix', hint: 'Fix a bug', insert: 'Fix this: ' }] the box's "/" list (ov-composer.js, 4.201.0), |
| 287 | * with the person's own commands and the site's mods (files, @, voice and drafts come with it on every box) |
| 288 | */ |
| 289 | function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [], commands = [], preset = null } = {}) { |
| 290 | const cmds = (commands || []).filter((c) => c && /^[\w-]{1,30}$/.test(c.name || '')).slice(0, 30).map((c) => ({ name: c.name, hint: String(c.hint || '').slice(0, 120), insert: String(c.insert || '').slice(0, 4000) })); |
| 291 | const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('') + (cmds.length ? attr('data-ovt-commands', JSON.stringify(cmds)) : ''); |
| 292 | const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join(''); |
| 293 | return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''} |
| 294 | <form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${preset ? attr('data-ovt-preset', preset) : ''}${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid} |
| 295 | ${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label> |
| 296 | <textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea> |
| 297 | <div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div> |
| 298 | ${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div> |
| 299 | ${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p> |
| 300 | </form>`; |
| 301 | } |
| 302 | |
| 303 | // The inline Community thread a page shows under a thing (MediaHub's Video and Pics item pages, Community's paste |
| 304 | // and /c pages): the same markup on every site, from the comment service's answer. Draw with thread-inline.css. |
| 305 | const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/; |
| 306 | const USERNAME = /^[A-Za-z0-9_]{1,32}$/; |
| 307 | const ID = /^\d{1,15}$/; |
| 308 | const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } }; |
| 309 | const author = (c) => c.author || c; |
| 310 | const username = (c) => author(c).username || c.username || ''; |
| 311 | const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member'; |
| 312 | const date = (iso) => { |
| 313 | const ms = Date.parse(String(iso || '')); |
| 314 | if (!Number.isFinite(ms)) return { short: '', full: '' }; |
| 315 | const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC'; |
| 316 | const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); |
| 317 | const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)] |
| 318 | : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)] |
| 319 | : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)]; |
| 320 | return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full }; |
| 321 | }; |
| 322 | |
| 323 | function query(base, sort, extra = {}, anchor = 'comments') { |
| 324 | const q = new URLSearchParams({ sort, ...extra }); |
| 325 | return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * The inline Community thread a page shows under a thing (Video, Pics, a paste page, /c): the quiet head, the |
| 330 | * composer or one sign-in line, the comments with their replies, targets and attachments, the order (Top and |
| 331 | * Newest only when there is something to put in order), More, and the link to the discussion. Server-rendered and |
| 332 | * complete without script; ov-thread.js and ov-composer.js give the composer its behaviour. |
| 333 | * answer: the comment service's answer ({ thread, comments, next_cursor, viewer }), or null for a thread nobody |
| 334 | * has commented on; base: the page's own address (sorting, paging and reply links come back here) |
| 335 | * post, remove: where the composer and Delete post; signedIn: this person may write |
| 336 | * attachmentsAllowed: the composer offers files (Community's paste page and Pics say yes; elsewhere no) |
| 337 | * draft: the message kept in the box after a failed post |
| 338 | * sort, replyId, expandedId, expandedReplies, error: as the page received them |
| 339 | */ |
| 340 | function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) { |
| 341 | const comments = Array.isArray(answer && answer.comments) ? answer.comments : []; |
| 342 | const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || ''); |
| 343 | const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null); |
| 344 | const validReply = ID.test(String(replyId || '')) ? String(replyId) : null; |
| 345 | const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null; |
| 346 | // Signed out, the composer's spot is one line; with no comments yet that line is the whole empty state (no second |
| 347 | // box saying the same thing). |
| 348 | const signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`; |
| 349 | const composer_html = (parent = null) => signedIn ? composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000, |
| 350 | sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {}, |
| 351 | attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment', |
| 352 | note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '', |
| 353 | }) : signIn; |
| 354 | const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => { |
| 355 | if (!MEDIA_ID.test(String(a.id || ''))) return ''; |
| 356 | const url = `https://openvibe.media/o/${a.id}`; |
| 357 | const title = esc(a.name || 'Attached file'); |
| 358 | return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/') |
| 359 | ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`; |
| 360 | }).join(''); |
| 361 | const comment = (c, root = null) => { |
| 362 | const id = String(c.id); |
| 363 | if (!ID.test(id)) return ''; |
| 364 | const gone = !!(c.deleted || c.is_deleted); |
| 365 | const user = username(c); |
| 366 | const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null; |
| 367 | const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null); |
| 368 | const when = date(c.created_at); |
| 369 | const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null; |
| 370 | const topId = root || id; |
| 371 | const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="margin-right:4px;vertical-align:-2px"><path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/></svg>@${esc(c.reply_to.display_name || 'Member')}</a>` : ''; |
| 372 | const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : ''; |
| 373 | let replies = ''; |
| 374 | if (!root) { |
| 375 | const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []); |
| 376 | const more = Math.max(0, Number(c.reply_count || 0) - rows.length); |
| 377 | replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`; |
| 378 | } |
| 379 | return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer_html(id)}</div>` : ''}${replies}</article>`; |
| 380 | }; |
| 381 | const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply)); |
| 382 | // Top and Newest only when there is something to put in order. |
| 383 | const order = comments.length < 2 && !(answer && answer.next_cursor) ? '' : `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`; |
| 384 | const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : ''; |
| 385 | return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer_html()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : (signedIn ? '<p class="ovc-none">Be the first to comment.</p>' : '')}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`; |
| 386 | } |
| 387 | |
| 388 | /** |
| 389 | * The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in |
| 390 | * the site's page; on a phone the composer stays at the bottom above the app bar. |
| 391 | * panel: a panel() beside the conversation; main: a plain page in the conversation's place (a site's settings, |
| 392 | * agents or usage, drawn in the same frame as its chats), with no composer |
| 393 | * center: with no turns yet, the welcome and the composer sit together in the middle of the screen (a start page); |
| 394 | * cls: the site's classes on the root |
| 395 | */ |
| 396 | function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', panel: pan = '', label = 'Conversation', main = null, center = false, cls = '' } = {}) { |
| 397 | // The kit's sidebar closes on its scrim (kit-web.js); the panel on its own (ov-thread.js). |
| 398 | const scrim = side && side.includes('data-ovk-sidebar') ? '<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>' : ''; |
| 399 | const body = main != null ? `<div class="ovt-scroll ovt-page">${main}</div>` |
| 400 | : `<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div> |
| 401 | <div class="ovt-dock">${comp}</div>`; |
| 402 | const classes = ['ovt', pan ? 'has-panel' : '', center && !turns.length && main == null ? 'is-start' : '', cls || ''].filter(Boolean).map(esc).join(' '); |
| 403 | return `<div class="${classes}" data-ovt>${side}${scrim}<section class="ovt-main" aria-label="${esc(label)}">${top} |
| 404 | ${body}</section>${pan ? `${pan}<div class="ovt-panel-scrim" data-ovt-panel-scrim hidden></div>` : ''}</div>`; |
| 405 | } |
| 406 | |
| 407 | module.exports = { page, sidebar, head, turn, divider, work, answer, card, panel, composer, renderThread, files, face, group, groupOf, icon, esc }; |
| 408 |