'use strict'; // ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ // openvibe-shared/thread — one conversation UI for the network (plan L55 C4): the chats in a sidebar, the thread of // turns, the work an agent did folded under its answer, and the composer. Actor's chat proved the shape (L55 C1–C3); // Vibe (openvibe.chat), Codes and the Dock use the same parts, so a conversation looks and behaves the same on every // site. Server-rendered and complete without script; ov-thread.js adds the drawer, the search, the growing composer, // Enter to send, copy, staying at the bottom, and helpers to add and update turns as they stream. Draw with // thread.css. Theme tokens only. // // const thread = require('openvibe-shared/thread'); // thread.page({ // sidebar: thread.sidebar({ newHref: '/chat', groups: thread.group(chats, (c) => c.at) .map(...) }), // head: thread.head({ title: 'Plan a weekend' }), // turns: [thread.turn({ id, you: { text }, reply: thread.work({...}) + thread.answer({...}) })], // composer: thread.composer({ action: '/chat', placeholder: 'Ask anything' }), // }); // // Every string is escaped; a field named html is markup the caller already made safe (an answer's rendered Markdown). // ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════ const kit = require('./kit'); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const attr = (name, v) => (v == null || v === false ? '' : v === true ? ` ${name}` : ` ${name}="${esc(v)}"`); const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; }; // Stroke icons, 24-unit grid, drawn in currentColor (thread.css sizes them). const P = { plus: 'M12 5v14M5 12h14', close: 'M6 6l12 12M18 6L6 18', menu: 'M4 7h16M4 12h16M4 17h16', chev: 'M8 10l4 4 4-4', copy: 'M9 9h10v10H9zM5 15V5h10', 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', 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', 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', share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6', edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', photo: 'M4 6h16v12H4zM4 15l4.5-4 3.5 3 2.5-2 5.5 4.5M15.5 9.5h.01', camera: 'M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 11a3 3 0 1 1 0 6 3 3 0 0 1 0-6z', file: 'M6 3h8l4 4v14H6zM14 3v4h4', 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', add: 'M12 5v14M5 12h14', more: 'M5 12h.01M12 12h.01M19 12h.01', 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', ok: 'M5 12l5 5 9-10', bad: 'M6 6l12 12M18 6L6 18', step: 'M9 6l6 6-6 6', note: 'M5 5h14v10H9l-4 4z', you: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0', file: 'M7 3h7l5 5v13H7zM14 3v5h5', send: 'M5 12l14-7-5 14-2.5-5.5z', stop: 'M7 7h10v10H7z', 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', options: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5', handover: 'M4 8h13l-3-3M20 16H7l3 3', wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', panel: 'M4 4h16v16H4zM15 4v16', shield: 'M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6zM9 12l2 2 4-4', // A step's kind (work rows' icon): read, find, edit, run, web, sub. 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', find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5', edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4', 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', 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', }; const icon = (name, cls = '') => ``; /** Today / Yesterday / This week / Earlier, from an ISO time (or ms) and now. */ function groupOf(when, now = Date.now()) { const t = typeof when === 'number' ? when : Date.parse(when); if (!Number.isFinite(t)) return 'Earlier'; const day = (x) => { const d = new Date(x); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); }; const days = Math.round((day(now) - day(t)) / 864e5); return days <= 0 ? 'Today' : days === 1 ? 'Yesterday' : days < 7 ? 'This week' : 'Earlier'; } /** Items (newest first) into the sidebar's groups: [{ name, items }]. `whenOf(item)` gives each one's time. */ function group(items, whenOf, now = Date.now()) { const out = []; for (const it of items || []) { const name = groupOf(whenOf(it), now); if (!out.length || out[out.length - 1].name !== name) out.push({ name, items: [] }); out[out.length - 1].items.push(it); } return out; } /** * The chats, in the network's one sidebar (kit.sidebar, plan L35: "the navigation makes more sense in a vertical way… * shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without * script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column * beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button). * groups: [{ name, items: [{ href, title, current, live, dot, meta, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }] * pinned: chats kept above the groups (Codes' conductor), the same shape as a group's items * dot: 'busy' (live sets it), 'needs' (waiting for the person) or 'bad'; meta: a quiet word after the title ("2h") * findSubmit: Enter in Find a chat runs the site's search at findAction (every message), not only the filter * Draw with kit.css and kit-web.js beside thread.css and ov-thread.js. */ 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 = [] } = {}) { 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 || '' }); const sections = [ ...(pinned && pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []), ...groups.filter((g) => g && g.items && g.items.length).map((g) => ({ title: g.name, items: g.items.map(item) })), ]; const html = kit.sidebar({ id, label, action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null, find: find ? { action: findAction || newHref || '', name: 'q', placeholder: findPlaceholder, label: findPlaceholder } : null, nav, sections: sections.length ? sections : [{ title: '', items: [], empty }], foot: foot.length ? `

${foot.map((f) => `${esc(f.label)}`).join('')}

` : '', }); return findSubmit ? html : html.replace('`; } /** * The panel beside the conversation: what the work produced (who worked on it, where, the changes, the proof). A third * column from 1180 px; below that a drawer from the right that head({ panel: id })'s button opens (ov-thread.js). * sections: [{ title, html, rows: [{ who, icon, iconHtml, title, sub, html }] }]; a row's who draws an agent's face (turn's * 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. */ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) { const row = (r) => `
  • ${r.who ? face(r.who) : r.iconHtml != null ? r.iconHtml : r.icon ? icon(r.icon) : ''}${r.html != null ? r.html : `${esc(r.title || '')}${r.sub ? `${esc(r.sub)}` : ''}`}
  • `; const secs = sections.filter(Boolean).map((s) => `
    ${s.title ? `

    ${esc(s.title)}

    ` : ''}${s.rows && s.rows.length ? `` : ''}${s.html || ''}
    `).join(''); return ``; } /** * Attach (11-09, the owner: "a better file attach button… support all file types"): one + button. Every file type by * default (attach.accept narrows it). With attach.menu, a small menu: Files, Photos and videos, Take a photo (a phone's * camera), and From your OpenVibe files when the site gives attach.library (an href). All the inputs share one name, so * the plain form posts them all. */ function attachHtml(attach) { const name = esc(attach.name || 'files'); const input = (accept, extra = '') => ``; if (!attach.menu) return ``; const row = (ic, label, inner) => ``; return `
    ${icon('add')}
    '; } /** The composer's Options: one button, the pickers behind it, and the chips of what differs from the defaults. */ function optionsHtml({ html, label = 'Options' }) { return `
    ${icon('options')}` + `
    ${esc(label)}
    ${html}
    ` + ''; } /** * The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model * chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script. * hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat) * top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit * stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself * id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes * foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that, * which is also the form's status line (role="status": a site's script says "Sending…" there) * options: { html, label } the site's safe pickers behind one Options button (11-09, the owner: "too many options… * hidden under a cog"): a panel on a computer, a sheet on a phone, a
    without script. A picker whose * `).join(''); return `${starters.length ? `
    ${starters.map((s) => ``).join('')}
    ` : ''}
    ${hid} ${top || ''}
    ${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}
    ${stopAction != null ? `` : ''}
    ${foot || ''}

    ${esc(note || '')}

    `; } // The inline Community thread a page shows under a thing (MediaHub's Video and Pics item pages, Community's paste // and /c pages): the same markup on every site, from the comment service's answer. Draw with thread-inline.css. const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/; const USERNAME = /^[A-Za-z0-9_]{1,32}$/; const ID = /^\d{1,15}$/; const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } }; const author = (c) => c.author || c; const username = (c) => author(c).username || c.username || ''; const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member'; const date = (iso) => { const ms = Date.parse(String(iso || '')); if (!Number.isFinite(ms)) return { short: '', full: '' }; const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC'; const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)] : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)] : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)]; return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full }; }; function query(base, sort, extra = {}, anchor = 'comments') { const q = new URLSearchParams({ sort, ...extra }); return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`; } /** * The inline Community thread a page shows under a thing (Video, Pics, a paste page, /c): the quiet head, the * composer or one sign-in line, the comments with their replies, targets and attachments, the order (Top and * Newest only when there is something to put in order), More, and the link to the discussion. Server-rendered and * complete without script; ov-thread.js and ov-composer.js give the composer its behaviour. * answer: the comment service's answer ({ thread, comments, next_cursor, viewer }), or null for a thread nobody * has commented on; base: the page's own address (sorting, paging and reply links come back here) * post, remove: where the composer and Delete post; signedIn: this person may write * attachmentsAllowed: the composer offers files (Community's paste page and Pics say yes; elsewhere no) * draft: the message kept in the box after a failed post * sort, replyId, expandedId, expandedReplies, error: as the page received them */ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) { const comments = Array.isArray(answer && answer.comments) ? answer.comments : []; const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || ''); const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null); const validReply = ID.test(String(replyId || '')) ? String(replyId) : null; const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null; // Signed out, the composer's spot is one line; with no comments yet that line is the whole empty state (no second // box saying the same thing). const signIn = `

    ${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} Sign in

    `; 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, sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {}, attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment', note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '', }) : signIn; const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => { if (!MEDIA_ID.test(String(a.id || ''))) return ''; const url = `https://openvibe.media/o/${a.id}`; const title = esc(a.name || 'Attached file'); return `${String(a.media_type || '').startsWith('image/') ? `${title}` : title}`; }).join(''); const comment = (c, root = null) => { const id = String(c.id); if (!ID.test(id)) return ''; const gone = !!(c.deleted || c.is_deleted); const user = username(c); const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null; const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null); const when = date(c.created_at); const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null; const topId = root || id; const target = targetId && c.reply_to ? `@${esc(c.reply_to.display_name || 'Member')}` : ''; const controls = !gone ? `
    ${signedIn ? `Reply` : ''}${c.can_delete && signedIn ? `
    ` : ''}
    ` : ''; let replies = ''; if (!root) { const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []); const more = Math.max(0, Number(c.reply_count || 0) - rows.length); replies = `
    ${rows.map((r) => comment(r, id)).join('')}${more ? `${more} more ${more === 1 ? 'reply' : 'replies'}` : ''}
    `; } return `
    ${avatar ? `` : ''}${profile ? `${esc(name(c))}` : `${esc(name(c))}`}${when.short ? `` : ''}
    ${target}${gone ? '

    Comment removed

    ' : `

    ${esc(c.message || '')}

    ${attachments(c)}`}${controls}${validReply === id ? `
    ${composer_html(id)}
    ` : ''}${replies}
    `; }; 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)); // Top and Newest only when there is something to put in order. const order = comments.length < 2 && !(answer && answer.next_cursor) ? '' : ``; const next = answer && answer.next_cursor ? `More comments` : ''; return `

    Comments

    ${order}
    ${error ? `

    ${esc(error)}

    ` : ''}${!hasReplySlot ? `
    ${composer_html()}
    ` : ''}${comments.length ? `
    ${comments.map((c) => comment(c)).join('')}
    ` : (signedIn ? '

    Be the first to comment.

    ' : '')}${next}${threadUrl ? `

    Discuss on OpenVibe.Community

    ` : ''}
    `; } /** * The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in * the site's page; on a phone the composer stays at the bottom above the app bar. * panel: a panel() beside the conversation; main: a plain page in the conversation's place (a site's settings, * agents or usage, drawn in the same frame as its chats), with no composer * center: with no turns yet, the welcome and the composer sit together in the middle of the screen (a start page); * cls: the site's classes on the root */ function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', panel: pan = '', label = 'Conversation', main = null, center = false, cls = '' } = {}) { // The kit's sidebar closes on its scrim (kit-web.js); the panel on its own (ov-thread.js). const scrim = side && side.includes('data-ovk-sidebar') ? '' : ''; const body = main != null ? `
    ${main}
    ` : `
    ${turns.length ? `
      ${turns.join('')}
    ` : `
    ${empty}
      `}
      ${comp}
      `; const classes = ['ovt', pan ? 'has-panel' : '', center && !turns.length && main == null ? 'is-start' : '', cls || ''].filter(Boolean).map(esc).join(' '); return `
      ${side}${scrim}
      ${top} ${body}
      ${pan ? `${pan}` : ''}
      `; } module.exports = { page, sidebar, head, turn, divider, work, answer, card, panel, composer, renderThread, files, face, group, groupOf, icon, esc };