'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 ? `
` : '',
});
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.rows.map(row).join('')} ` : ''}${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 `${icon('add')}Attach files ${input(attach.accept)} `;
const row = (ic, label, inner) => `${icon(ic)}${esc(label)} ${inner} `;
return `';
}
/** 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)} ${icon('close')}
${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
* or radio group carries data-ovt-default="" shows as a chip beside the button while it differs
* (its × puts the default back), and the button wears a dot.
* examples: ['Make the home page faster on phones', …] the box's placeholder walks through them while it is empty
* commands: [{ name: 'fix', hint: 'Fix a bug', insert: 'Fix this: ' }] the box's "/" list (ov-composer.js, 4.201.0),
* with the person's own commands and the site's mods (files, @, voice and drafts come with it on every box)
*/
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 } = {}) {
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) }));
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('') + (cmds.length ? attr('data-ovt-commands', JSON.stringify(cmds)) : '');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => ` `).join('');
return `${starters.length ? `${starters.map((s) => `${esc(s)} `).join('')}
` : ''}
`;
}
// 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} `;
}).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 ? `
Delete ` : ''}
` : '';
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 = ``;
}
return ``;
};
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) ? '' : `${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `${label} `).join('')} `;
const next = answer && answer.next_cursor ? `More comments ` : '';
return ``;
}
/**
* 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}
`
: `
${comp}
`;
const classes = ['ovt', pan ? 'has-panel' : '', center && !turns.length && main == null ? 'is-start' : '', cls || ''].filter(Boolean).map(esc).join(' ');
return `${side}${scrim}
${pan ? `${pan}
` : ''}
`;
}
module.exports = { page, sidebar, head, turn, divider, work, answer, card, panel, composer, renderThread, files, face, group, groupOf, icon, esc };