openvibers/openvibe.shared Commits
openvibe-shared 4.216.0: one capture core, a message's actions in one cluster, edit and reply in the composer (#489)
Changes
@@@ -1,5 +1,43 @@# Changelog++## 4.216.0 — 2026-11-12++++One capture core for every camera, and a chat message's actions in one place on every site.++++- **`ov-capture.js`: `window.OVCapture`, one way to open a camera, a microphone or a screen, take a photo and record a++ clip**, shared by Live's go-live page, OpenVibe.Cam's recorder and `<ov-camera>`, and the composer (the owner, 11-12:++ Live's capture must be "completely customize[able] … highly over engineered modular editable", then "OpenVibe.cam is++ actually fine … Harmony"). It began as Live's own system (OpenVibe.Live #847) and every site serves its own copy at++ `/shared/ov-capture.js`, so a camera never waits on another site, a content policy or a cache (from 11-09 to 11-12++ nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it).++ - `open({ mode, facing, lens, mic, resolution })` runs the strategies in order (together, then each on its own, then++ anything), each with a time limit; a refusal stops at once; the failure that says most is the one reported.++ - `screen()`, `photo(stream, { mirror })`, `record(stream, { maxSeconds })` → `{ stop() → Blob, cancel() }` (the first++ format the browser supports), `devices()`, `meter()`, `network()`, `can()` (what this browser allows here).++ - `explain(error, mode, { instead })`: plain steps for each browser (Safari's "aA", Android's site settings,++ Firefox's blocked icon, an app's in-app browser, a Mac's Screen Recording switch), with a last step of the site's++ own.++ - Settings: this device first; `settings.sync({ load, save })` lets the choices that mean the same anywhere (facing,++ resolution, mirror, effects) follow the person, for example through OpenVibe.Cam's camera settings; camera and++ microphone ids never leave the device.++ - Every part (`use(name, impl)` returns the one it replaced), every strategy (`strategies.add/remove/replace/reset`)++ and every event (`on('opened' | 'failed' | 'settings' | 'synced' | 'recorded' …)`) can be changed; it fetches++ nothing. A newer copy replaces an older one, never the reverse.++- **A message's actions are one cluster at its corner** (openvibe.design rule 138; the owner, 11-12, with phone++ screenshots of a stream's chat: "The edit button needs improvement … so it never happens again", "it also looks++ stupid as shit after you edit it"). `messages.js`:++ - `render(root, { …, actions, onAction })`: `actions` names what to offer (`reply`, `edit`, `react`, `translate`,++ `copy`, `delete`; edit and delete on your own messages only) as icon buttons in one cluster just above the++ message's right end, on hover or focus on a computer and a tap on a phone (thumb-sized there), never in the words;++ a press calls `onAction(id, message, row)` and bubbles `ovm:action`.++ - `actions(row, ids, { onAction, message, mine })` gives a site's own rows (Live's chat) the same cluster.++ - `edited(textNode, at)`: "edited" is one quiet word right after the words, the time as its title, never a line of++ its own.++- **The composer edits and replies in the box**: `api.edit({ text, onSave, onCancel })` puts your message's words in++ the box under an "Editing your message" bar (Enter saves through `onSave`, never `onSubmit`; Esc or × puts back what++ was in the box; the send is a check); `api.reply({ name, text, data })` shows "Replying to …" and the send carries++ `reply: data`; `clearBar()`, `editing()`.++## 4.215.0 — 2026-11-12Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.215.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.216.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.215.0",++ "version": "4.216.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -58,7 +58,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': '60c9858ff204' };++ const VIBE_REV = { 'ov-composer.js': '96e59dd5193b' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -89,7 +89,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '674e9cf1b2f5', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': '5d8876db7e15', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -58,6 +58,7 @@ const BROWSER = Object.freeze(['messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-composer.js', // shared composer++ 'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)'ov-combo.js', // shared field dropdown'ov-window.js', // shared dialog, sheet, and panel'agent-sheet.js', // an agent's sheet in the one window (4.211.0)@@@ -32,6 +32,26 @@.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }++/* A message's actions: one cluster just above its right end (openvibe.design rule 138), on hover or focus on a computer,++ a tap on a phone; never in the words. "edited" is one quiet word after them. */++.ovm-has-actions { position: relative; }++.ovm-actions { position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6; display: none; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 10px; background: var(--bg-card, #131c2e); box-shadow: 0 6px 18px rgba(0, 0, 0, .35); }++.ovm-messages > :first-child .ovm-actions, .ovm-messages > :first-child.ovm-has-actions > .ovm-actions { bottom: auto; top: calc(100% - 6px); }++.ovm-has-actions:hover > .ovm-actions, .ovm-has-actions:focus-within > .ovm-actions, .ovm-has-actions.is-acting > .ovm-actions { display: inline-flex; }++.ovm-act { display: grid; place-items: center; width: 28px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }++.ovm-act:hover, .ovm-act:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++.ovm-act:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }++.ovm-act-delete:hover { color: #fca5a5; background: color-mix(in srgb, #ef4444 20%, transparent); }++@media (hover: none) {++ .ovm-has-actions:hover:not(.is-acting):not(:focus-within) > .ovm-actions { display: none; }++ .ovm-actions { right: 4px; border-radius: 12px; }++ .ovm-act { width: 44px; height: 40px; border-radius: 9px; }++ .ovm-act svg { width: 18px; height: 18px; }++}++@media (hover: hover) { .ovm-room.ovm-has-actions:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); } }++.ovm-room.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }++.ovm-edited { margin-left: 5px; font-size: .72em; color: var(--text-muted, #93a0b4); opacity: .85; white-space: nowrap; }++.ovm-group[data-mine="true"] .ovm-edited { color: inherit; opacity: .7; }/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */.ovm-messages[data-view="room"] { gap: 0; }@@@ -1,5 +1,17 @@'use strict';--/** Render Chat messages as a compact room or grouped conversation bubbles. */++/**++ * Render Chat messages as a compact room or grouped conversation bubbles.++ *++ * render(root, { messages, view: 'room' | 'bubbles', me, now, actions, onAction })++ * actions: the ids to offer on each message ('reply', 'edit', 'react', 'translate', 'copy', 'delete'), or++ * (message, { mine }) → ids. 'edit' and 'delete' are offered only on your own messages.++ * onAction(id, message, row): a button was pressed (an 'ovm:action' event with { action, message } also bubbles).++ * actions(row, ids, { onAction, message }) → the cluster: the same buttons for a row a site draws itself (Live's chat).++ * edited(textNode, at) → the quiet "edited" word after a message's words, its time as the title.++ *++ * A message's actions are one cluster at its corner (openvibe.design rule 138): shown on hover or focus on a computer,++ * on a tap on a phone, never in the words. "edited" is one quiet word after them, never a line of its own.++ */const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));function safeLinkify(node, value) {const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;@@@ -20,7 +32,74 @@ function dayLabel(date, now) {if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);}--function render(root, { messages = [], view = 'room', me = null, now = new Date() } = {}) {++// The actions' icons (24-unit strokes, as the composer's).++const ICON = {++ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',++ edit: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',++ react: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01',++ translate: 'M4 5h9M8.5 3v2M6 5c.6 3 2.6 5.5 5.5 7M11 5c-.8 3.6-3.2 6.4-6.5 8M13 21l4-10 4 10M14.5 17.5h5',++ copy: 'M9 9h10v12H9zM5 15V3h10',++ delete: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3',++};++const LABEL = { reply: 'Reply', edit: 'Edit your message', react: 'React', translate: 'Translate', copy: 'Copy text', delete: 'Delete your message' };++const OWN = new Set(['edit', 'delete']);++function svgIcon(path) {++ const ns = 'http://www.w3.org/2000/svg';++ const s = document.createElementNS(ns, 'svg');++ s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', '16'); s.setAttribute('height', '16'); s.setAttribute('aria-hidden', 'true');++ s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round');++ const p = document.createElementNS(ns, 'path'); p.setAttribute('d', path); s.appendChild(p);++ return s;++}++/** One cluster of icon buttons at a row's corner. A site's own rows (Live's chat) use it too, so every chat matches. */++function actions(row, ids, { onAction = null, message = null, mine = false } = {}) {++ if (!row) return null;++ let box = row.querySelector(':scope > .ovm-actions');++ if (!box) { box = document.createElement('span'); box.className = 'ovm-actions'; row.appendChild(box); }++ box.replaceChildren();++ (ids || []).filter((id) => ICON[id] && (!OWN.has(id) || mine)).forEach((id) => {++ const b = document.createElement('button');++ b.type = 'button'; b.className = `ovm-act ovm-act-${id}`; b.dataset.action = id; b.title = LABEL[id]; b.setAttribute('aria-label', LABEL[id]);++ b.appendChild(svgIcon(ICON[id]));++ b.addEventListener('click', (e) => {++ e.stopPropagation();++ row.classList.remove('is-acting');++ if (typeof onAction === 'function') onAction(id, message, row);++ const view = row.ownerDocument && row.ownerDocument.defaultView;++ const CE = (view && view.CustomEvent) || (typeof CustomEvent === 'function' ? CustomEvent : null);++ if (CE) row.dispatchEvent(new CE('ovm:action', { bubbles: true, detail: { action: id, message } }));++ });++ box.appendChild(b);++ });++ row.classList.add('ovm-has-actions');++ if (!box.children.length) { box.remove(); row.classList.remove('ovm-has-actions'); return null; }++ return box;++}++/** "edited": one quiet word right after the words, the time it changed as its title. */++function edited(after, at) {++ if (!after || !after.parentNode) return null;++ let mark = after.nextElementSibling && after.nextElementSibling.classList.contains('ovm-edited') ? after.nextElementSibling : null;++ if (!mark) { mark = document.createElement('small'); mark.className = 'ovm-edited'; after.after(mark); }++ mark.textContent = 'edited';++ const raw = String(at || '');++ const d = raw ? new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw.replace(' ', 'T')) ? raw.replace(' ', 'T') : raw.replace(' ', 'T') + 'Z') : null;++ mark.title = d && !isNaN(d) ? `Edited at ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(d)}` : 'Edited';++ return mark;++}++/** A phone has no hover: a tap on a message shows its actions, a tap elsewhere hides them. */++function tapToAct(root) {++ if (!root || root.__ovmTap) return;++ root.__ovmTap = true;++ root.addEventListener('click', (e) => {++ const t = e.target;++ if (!t || !t.closest || t.closest('.ovm-actions')) return;++ const noHover = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches;++ const row = t.closest('.ovm-has-actions');++ root.querySelectorAll('.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });++ if (noHover && row && !t.closest('a, button, input, textarea')) row.classList.toggle('is-acting');++ });++}++function render(root, { messages = [], view = 'room', me = null, now = new Date(), actions: offer = null, onAction = null } = {}) {if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;@@@ -37,6 +116,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);++ if (message.edited_at) edited(body, message.edited_at);++ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;++ if (ids && ids.length) actions(row, ids, { onAction, message, mine });} else {const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);if (same) group.dataset.continued = 'true';@@@ -50,6 +132,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);++ if (message.edited_at) edited(content, message.edited_at);++ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;++ if (ids && ids.length) actions(bubble, ids, { onAction, message, mine });if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }@@@ -59,9 +144,10 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(}previous = { sender, stamp };});++ if (offer) tapToAct(root);return root;}--const api = { render };++const api = { render, actions, edited, tapToAct };if (typeof module !== 'undefined' && module.exports) module.exports = api;if (typeof window !== 'undefined') {window.OVMessages = api;@@@ -602,7 +602,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aaad15c6adb6', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'c1ca9b80e1b5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.nnew file mode 100644@@@ -0,0 +1,406 @@++/*++ * ov-capture.js: OpenVibe's capture core, window.OVCapture. One way to open a camera, a microphone or a screen, take a++ * photo and record a clip, on every OpenVibe site: Live's go-live page, OpenVibe.Cam's recorder and <ov-camera>, and the++ * composer's camera button. Built from parts that can each be replaced, and served by each site from its own copy++ * (/shared/ov-capture.js), so a camera never waits on another site, a content policy or a cache.++ *++ * The owner, 2026-11-12: Live's capture must be "completely customize[able] … implemented in a highly over engineered++ * modular editable way", and then "OpenVibe.cam is actually fine … maybe we should be using that for live … Harmony".++ * Both hold: this file began as Live's own system (OpenVibe.Live #847) and is now the one Live and Cam share. From 11-09++ * to 11-12 nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it; a site's own++ * copy of this file cannot be refused that way.++ *++ * Using it:++ * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. mode: 'camera' (both), 'camera_only',++ * 'mic_only'. Runs the strategies in order, each with its++ * own time limit; the first that opens wins.++ * OVCapture.screen({ audio, frameRate }) → a MediaStream of a screen, a window or a tab.++ * OVCapture.photo(stream, { type, quality, mirror }) → a Blob: the camera's picture now.++ * OVCapture.record(stream, { maxSeconds, mimeType }) → { stop() → Promise<Blob>, cancel(), seconds(), mimeType }.++ * OVCapture.devices() → the browser's cameras and microphones.++ * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.++ * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).++ * OVCapture.can() → { camera, screen, record, secure }: what this browser++ * allows here, so a page can hide what it cannot do.++ * OVCapture.stop(stream)++ * OVCapture.explain(error, mode, { instead }) → { title, steps }: what happened and what to tap, for++ * this browser. `instead` adds a last step of the site's own++ * (Live: "go live with your screen instead").++ * OVCapture.settings.load() / .save(partial) → the person's choices: facing, lens, mic, resolution,++ * mirror, effects (the 'store' part; this browser first).++ * OVCapture.settings.sync({ load, save }) → unsync(): the choices that are the same on every device++ * (the 'portable' part: facing, resolution, mirror,++ * effects) follow the person through a remote store, such++ * as OpenVibe.Cam's camera settings. Camera and microphone++ * ids stay on the device that has them.++ *++ * Changing it:++ * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:++ * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));++ * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, getDisplayMedia, enumerate,++ * meter, network, still, recorder, mimeTypes, store, portable, explain, timeoutMs.++ * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),++ * .remove(name), .replace(name, strategy), .reset(). A strategy is++ * { name, note?, timeoutMs?, open(options, capture) }.++ * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·++ * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings } ·++ * 'synced' { settings } · 'sync-failed' { error } · 'recording' { seconds } ·++ * 'recorded' { blob, seconds }. Returns off().++ * Every name and every default is in this file. It fetches nothing; a page that wants the person's settings to follow++ * them passes its own remote store to settings.sync().++ */++(function (root) {++ 'use strict';++ const VERSION = 2;++ if (!root || (root.OVCapture && Number(root.OVCapture.version) >= VERSION)) return;++ const nav = () => root.navigator || {};++ const doc = () => root.document || null;++++ // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────++ const parts = {++ /** What to ask the browser for, from the options (facing, lens, mic, resolution). */++ constraints(o = {}) {++ const height = Number(o.resolution) || 720;++ const video = o.video === false ? false : {++ facingMode: { ideal: o.facing || 'user' },++ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),++ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },++ };++ const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);++ return { video, audio };++ },++ getUserMedia: (constraints) => nav().mediaDevices.getUserMedia(constraints),++ getDisplayMedia: (constraints) => nav().mediaDevices.getDisplayMedia(constraints),++ enumerate: () => nav().mediaDevices.enumerateDevices(),++ meter(stream, onLevel) {++ const AC = root.AudioContext || root.webkitAudioContext;++ if (!AC || !stream || !stream.getAudioTracks().length) return () => {};++ const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512);++ let frame = 0, closed = false;++ analyser.fftSize = 512;++ ctx.createMediaStreamSource(stream).connect(analyser);++ if (ctx.state === 'suspended') {++ ctx.resume().catch(() => {});++ const d = doc();++ if (d) d.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true });++ }++ const tick = () => {++ if (closed) return;++ analyser.getByteTimeDomainData(data);++ let sum = 0;++ for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; }++ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));++ frame = root.requestAnimationFrame(tick);++ };++ frame = root.requestAnimationFrame(tick);++ return () => { closed = true; root.cancelAnimationFrame(frame); ctx.close().catch(() => {}); };++ },++ network() {++ const n = nav();++ if (n.onLine === false) return 'offline';++ const c = n.connection || n.mozConnection || n.webkitConnection;++ if (!c || !(c.effectiveType || c.downlink)) return '';++ return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';++ },++ /** The camera's picture now, as a Blob: the video track drawn once into a canvas. */++ async still(stream, o = {}) {++ const d = doc();++ const track = stream && stream.getVideoTracks()[0];++ if (!d || !track) throw Object.assign(new Error('No camera picture to take'), { name: 'NotFoundError' });++ const video = d.createElement('video');++ video.muted = true; video.playsInline = true; video.srcObject = new root.MediaStream([track]);++ await video.play().catch(() => {});++ if (!video.videoWidth) await new Promise((r) => { video.addEventListener('loadeddata', r, { once: true }); root.setTimeout(r, 1500); });++ const w = video.videoWidth || 1280, h = video.videoHeight || 720;++ const canvas = d.createElement('canvas');++ canvas.width = w; canvas.height = h;++ const g = canvas.getContext('2d');++ if (o.mirror) { g.translate(w, 0); g.scale(-1, 1); }++ g.drawImage(video, 0, 0, w, h);++ video.srcObject = null;++ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('The picture could not be made'))), o.type || 'image/jpeg', o.quality || 0.92));++ },++ /** A MediaRecorder for the stream. */++ recorder: (stream, options) => new root.MediaRecorder(stream, options),++ /** The recording formats to try, best first; the first this browser supports is used. */++ mimeTypes: {++ video: ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'],++ audio: ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'],++ },++ /** Where the person's choices live on this device. Device ids are this browser's own, so this store is local. */++ store: {++ key: 'ov_capture_settings',++ load() { try { return JSON.parse(root.localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },++ save(settings) { try { root.localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },++ },++ /** The choices that mean the same on any device, so they may follow the person (settings.sync). */++ portable: ['facing', 'resolution', 'mirror', 'effects'],++ explain: null, // set below (explainFailure): the plain words for a failure, by browser++ timeoutMs: 10000,++ };++++ // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────++ const listeners = new Map();++ function on(event, fn) {++ if (!listeners.has(event)) listeners.set(event, new Set());++ listeners.get(event).add(fn);++ return () => listeners.get(event).delete(fn);++ }++ function emit(event, detail) {++ (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });++ }++++ // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────++ const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });++ const merge = (...streams) => {++ const out = new root.MediaStream();++ streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));++ return out;++ };++ const defaults = [++ {++ name: 'preferred',++ note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',++ open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),++ },++ {++ name: 'separately',++ note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',++ async open(o, c) {++ const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');++ let video = null, audio = null, last = null;++ if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }++ if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }++ if (!video && !audio) throw last || new root.DOMException('No camera or microphone', 'NotFoundError');++ return merge(video, audio);++ },++ },++ {++ name: 'basic',++ note: 'Any camera and any microphone, nothing asked of them.',++ open: (o, c) => c.part('getUserMedia')(wants(o)),++ },++ ];++ let order = defaults.slice();++ const strategies = {++ list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),++ add(strategy, at = {}) {++ if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');++ order = order.filter((s) => s.name !== strategy.name);++ const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;++ if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);++ },++ remove(name) { order = order.filter((s) => s.name !== name); },++ replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },++ reset() { order = defaults.slice(); },++ };++++ // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.++ const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };++ const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);++ const refused = (e) => !!e && (e.name === 'NotAllowedError' || e.name === 'PermissionDeniedError' || e.name === 'SecurityError');++ function within(promise, ms, name) {++ let timer;++ return Promise.race([promise, new Promise((_, reject) => { timer = root.setTimeout(() => reject(new root.DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])++ .finally(() => root.clearTimeout(timer));++ }++ const noApi = (what) => Object.assign(new Error(`This browser has no ${what} here`), { name: 'NoApi' });++++ async function open(options = {}) {++ const md = nav().mediaDevices;++ if (!md || !md.getUserMedia) throw noApi('camera');++ const o = { ...capture.settings.load(), ...options };++ let worst = null;++ for (const s of order) {++ emit('opening', { strategy: s.name, options: o });++ try {++ const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);++ const w = wants(o);++ // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.++ if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {++ stream.getTracks().forEach((t) => t.stop());++ throw new root.DOMException('The requested device was not available', 'NotFoundError');++ }++ emit('opened', { strategy: s.name, stream });++ return stream;++ } catch (error) {++ worst = heavier(worst, error);++ emit('failed', { strategy: s.name, error });++ // A refusal is the person's answer: asking again in another way would only ask again.++ if (refused(error)) break;++ }++ }++ emit('gave-up', { error: worst });++ throw worst || new Error('The camera did not open');++ }++++ async function screen(options = {}) {++ const md = nav().mediaDevices;++ if (!md || !md.getDisplayMedia) throw noApi('screen sharing');++ const constraints = { video: { frameRate: { ideal: Number(options.frameRate) || 30 } }, audio: options.audio !== false };++ emit('opening', { strategy: 'screen', options });++ try {++ const stream = await parts.getDisplayMedia(constraints);++ emit('opened', { strategy: 'screen', stream });++ return stream;++ } catch (error) {++ emit('failed', { strategy: 'screen', error });++ emit('gave-up', { error });++ throw error;++ }++ }++++ // ── Recording ─────────────────────────────────────────────────────────────────────────────────────────────++ function pickMime(kind, wanted) {++ const MR = root.MediaRecorder;++ const list = wanted ? [wanted] : (parts.mimeTypes[kind] || []);++ if (!MR || typeof MR.isTypeSupported !== 'function') return '';++ return list.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';++ }++ function record(stream, o = {}) {++ if (!root.MediaRecorder) throw noApi('recorder');++ const kind = stream.getVideoTracks().length ? 'video' : 'audio';++ const mimeType = pickMime(kind, o.mimeType);++ const rec = parts.recorder(stream, mimeType ? { mimeType } : undefined);++ const chunks = [];++ const max = Math.max(1, Number(o.maxSeconds) || 60);++ const started = Date.now();++ let ticker = 0, limit = 0, done = null, cancelled = false;++ const seconds = () => Math.min(max, Math.round((Date.now() - started) / 100) / 10);++ const finished = new Promise((resolve, reject) => {++ rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };++ rec.onerror = (e) => reject((e && e.error) || new Error('The recording failed'));++ rec.onstop = () => {++ root.clearInterval(ticker); root.clearTimeout(limit);++ if (cancelled) { resolve(null); return; }++ const blob = new root.Blob(chunks, { type: (mimeType || rec.mimeType || `${kind}/webm`).split(';')[0] });++ emit('recorded', { blob, seconds: seconds() });++ resolve(blob);++ };++ });++ rec.start(1000);++ ticker = root.setInterval(() => emit('recording', { seconds: seconds() }), 500);++ limit = root.setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); }, max * 1000);++ return {++ mimeType: mimeType || rec.mimeType || '',++ seconds,++ stop() { if (!done) { done = finished; if (rec.state !== 'inactive') rec.stop(); } return done; },++ cancel() { cancelled = true; if (rec.state !== 'inactive') rec.stop(); },++ };++ }++++ // ── Plain words for a failure, by browser. ───────────────────────────────────────────────────────────────++ function explainFailure(err, mode, extra = {}) {++ const n = nav(), ua = n.userAgent || '';++ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && n.maxTouchPoints > 1);++ const mac = /Macintosh/.test(ua) && !ios;++ const android = /Android/.test(ua);++ const firefox = /Firefox|FxiOS/.test(ua);++ const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);++ const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua);++ const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : mode === 'screen' ? 'screen' : 'camera and microphone';++ const both = mode === 'camera_only' ? '' : ' and Microphone';++ const name = err && err.name;++ const instead = extra && extra.instead ? [extra.instead] : [];++ if (name === 'NoApi' || (inApp && name !== 'NotReadableError')) {++ return { title: `This browser can't use your ${mode === 'screen' ? 'screen' : 'camera'} here`, steps: inApp++ ? ['You opened this inside another app (like Discord, Instagram or TikTok). Those can\'t share a camera.', `Tap the ⋮ or ⋯ menu and choose "Open in ${ios ? 'Safari' : 'Chrome'}" (or "Open in browser").`, 'Sign in there if asked, then try again.']++ : mode === 'screen' && (ios || android) ? ['Phones can\'t share their screen from a browser. Use a computer, or your camera instead.', ...instead]++ : ['Open this page in Chrome, Safari, Edge or Firefox, then try again.', ...instead] };++ }++ if (mode === 'screen' && refused(err)) {++ return { title: 'Screen sharing was stopped', steps: mac++ ? ['Try again and pick a screen, a window or a tab.', 'Nothing to pick? Open System Settings → Privacy & Security → Screen Recording, turn on your browser, then reopen it.']++ : ['Try again and pick a screen, a window or a tab, then press Share.'] };++ }++ if (refused(err)) {++ const steps = ios && safari ? ['Tap "aA" (or the page icon) at the left of the address bar.', 'Tap Website Settings.', `Set Camera${both} to Allow.`]++ : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.']++ : android && !firefox ? ['Tap the icon at the left of the address bar (a lock or two sliders).', 'Tap Permissions (or Site settings).', `Set Camera${both} to Allow.`]++ : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).']++ : safari ? ['Open Safari\'s menu → Settings for this website.', `Set Camera${both} to Allow.`]++ : ['Click the camera icon at the right end of the address bar (or the lock at the left).', `Choose "Always allow" for the ${what}, then Done.`];++ return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again.'] };++ }++ if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') {++ return { title: 'Another app is using your camera', steps: ['Close apps that use the camera, like Zoom, FaceTime, WhatsApp, Instagram or the Camera app (and other tabs with a call).', 'Then press Try again.'] };++ }++ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {++ return { title: `No ${what} found`, steps: [mode === 'mic_only' ? 'Plug in a microphone or headset, then press Try again.' : 'Plug in a camera (or open its privacy cover), then press Try again.', ...instead] };++ }++ if (name === 'OverconstrainedError') {++ return { title: 'That camera can\'t start with these settings', steps: ['Press Try again: it starts with simpler settings.', 'Still stuck? Pick another camera in the camera settings.'] };++ }++ if (name === 'TimeoutError') {++ return { title: 'Your browser didn\'t answer', steps: ['Look for the browser\'s question at the top of the screen and tap Allow.', 'Nothing there? Press Try again.'] };++ }++ return { title: `The ${mode === 'screen' ? 'screen sharing' : 'camera'} didn't start`, steps: ['Press Try again.', 'Still stuck? Reload this page, or try Chrome or Safari.'] };++ }++ parts.explain = explainFailure;++++ // ── Settings: this device first; the portable ones may follow the person. ──────────────────────────────────────++ const DEFAULTS = { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, effects: 'none' };++ const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o && o[k] !== undefined).map((k) => [k, o[k]]));++ let remote = null;++ const settings = {++ load: () => ({ ...DEFAULTS, ...parts.store.load() }),++ save(partial) {++ const next = { ...settings.load(), ...(partial || {}) };++ parts.store.save(next);++ emit('settings', { settings: next });++ const r = remote;++ const portable = pick(partial || {}, parts.portable);++ if (r && Object.keys(portable).length) {++ Promise.resolve().then(() => r.save(pick(next, parts.portable))).catch((error) => emit('sync-failed', { error }));++ }++ return next;++ },++ /** Follow the person: take the remote store's portable choices now, and send each later change to it. */++ sync(store) {++ if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') throw new TypeError('settings.sync takes { load(), save(settings) }');++ remote = store;++ const mine = store;++ Promise.resolve().then(() => store.load()).then((theirs) => {++ if (remote !== mine || !theirs || typeof theirs !== 'object') return;++ const next = { ...settings.load(), ...pick(theirs, parts.portable) };++ parts.store.save(next);++ emit('settings', { settings: next });++ emit('synced', { settings: next });++ }).catch((error) => emit('sync-failed', { error }));++ return () => { if (remote === mine) remote = null; };++ },++ };++++ // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────++ const capture = {++ version: VERSION,++ part(name) {++ if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);++ return parts[name];++ },++ use(name, impl) {++ const before = capture.part(name);++ parts[name] = impl;++ return before;++ },++ parts: () => Object.keys(parts),++ strategies,++ on,++ open,++ screen,++ photo: (stream, o = {}) => parts.still(stream, { mirror: false, ...o }),++ record,++ devices: () => parts.enumerate(),++ meter: (stream, onLevel) => parts.meter(stream, onLevel),++ network: () => parts.network(),++ can() {++ const md = nav().mediaDevices || {};++ return { camera: typeof md.getUserMedia === 'function', screen: typeof md.getDisplayMedia === 'function', record: typeof root.MediaRecorder === 'function', secure: root.isSecureContext !== false };++ },++ stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },++ explain: (error, mode, extra) => parts.explain(error, mode, extra),++ settings,++ };++ root.OVCapture = capture;++ if (typeof module === 'object' && module && module.exports) module.exports = capture;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -151,6 +151,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }++.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }++.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }++.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }++.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }++.ovc-v2 .ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }++.ovc-v2 .ovc-bar-x:hover, .ovc-v2 .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }++.ovc-v2 .ovc-bar-x svg { width: 15px; height: 15px; }++@media (pointer: coarse) { .ovc-v2 .ovc-bar-x { width: 40px; height: 40px; } }++.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }.ovc-v2[data-preset=post] .ovc-file.ovc-image, .ovc-v2[data-preset=comment] .ovc-file.ovc-image { flex-direction: column; flex-wrap: nowrap; align-items: stretch; width: 150px; max-width: 150px; height: auto; padding: 0; border-radius: 16px; overflow: hidden; }@@@ -223,6 +232,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }stop: 'M7 7h10v10H7z',cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',++ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',++ pen: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',++ check: 'M5 12.5l4.5 4.5L19 7',};const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };@@@ -636,7 +648,11 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }// submitLabel, upload(file, onProgress), mentions, commands, beforeSend,// onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }// → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop-- // while a run works, as in the agents portal), values() (every chip's value by name) }.++ // while a run works, as in the agents portal), values() (every chip's value by name),++ // edit({ text, onSave(text) → false keeps editing, onCancel, label }) (the box takes your message's words under an++ // "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),++ // reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),++ // clearBar(), editing() }.// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.const PRESET = {@@@ -797,21 +813,83 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }};++ // One bar above the box at a time: "Editing your message" or "Replying to …" (openvibe.design rule 138: a++ // message's actions are a cluster at its corner, and what they start is said here, in the box).++ let bar = null, barClose = null, editing = null, replying = null;++ const clearBar = () => {++ const f = barClose; barClose = null;++ if (bar) { bar.remove(); bar = null; }++ form.classList.remove('is-editing', 'is-replying');++ if (f) f();++ };++ const showBar = (icon, title, detail, onClose) => {++ clearBar();++ bar = el('div', 'ovc-bar', svg(icon));++ const t = el('b'); t.textContent = title;++ const d = el('span'); d.textContent = detail || '';++ const x = el('button', 'ovc-bar-x', svg(IC.x)); x.type = 'button'; x.title = 'Cancel'; x.setAttribute('aria-label', `Cancel: ${title}`);++ x.addEventListener('click', () => { clearBar(); ta.focus(); });++ bar.append(t, d, x);++ body.insertBefore(bar, head.parentNode === body ? head.nextSibling : body.firstChild);++ barClose = onClose;++ };++ const sendFaceOf = () => { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; };++ const edit = (spec = {}) => {++ const draft = editing ? editing.draft : ta.value;++ if (replying) clearBar();++ showBar(IC.pen, spec.label || 'Editing your message', 'Enter saves · Esc cancels', () => {++ const was = editing; editing = null;++ sendFaceOf();++ if (was && !was.saved) { ta.value = was.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); if (typeof was.onCancel === 'function') was.onCancel(); }++ api.refresh();++ });++ editing = { draft, onSave: spec.onSave, onCancel: spec.onCancel, saved: false };++ form.classList.add('is-editing');++ ta.value = String(spec.text || '');++ ta.dispatchEvent(new Event('input', { bubbles: true }));++ send.innerHTML = svg(IC.check); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Save'); send.title = 'Save';++ api.refresh();++ ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length;++ };++ const reply = (spec = {}) => {++ if (editing) clearBar();++ const who = String(spec.name || 'someone');++ showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); });++ replying = { data: spec.data !== undefined ? spec.data : { name: who, text: spec.text }, onClear: spec.onClear };++ form.classList.add('is-replying');++ ta.focus();++ };++ ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });++ // Saving an edit is not a send: it runs first and nothing else hears the submit.++ form.addEventListener('submit', async (e) => {++ if (!editing) return;++ e.preventDefault(); e.stopImmediatePropagation();++ if (form.classList.contains('is-busy')) return;++ const text = ta.value.trim(), ed = editing;++ if (!text) { api.ctx.note('A message can\'t be empty.'); return; }++ setBusy(true);++ let keep = false;++ try { keep = typeof ed.onSave === 'function' ? (await ed.onSave(text)) === false : false; }++ catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }++ setBusy(false);++ if (!keep && editing === ed) { ed.saved = true; ta.value = ed.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); clearBar(); }++ api.refresh();++ }, true);if (typeof o.onSubmit === 'function') {form.addEventListener('submit', async (e) => {e.preventDefault();if (form.classList.contains('is-busy')) return;-- const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values() };++ const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values(), reply: replying ? replying.data : null };setBusy(true);let keep = false;try { keep = (await o.onSubmit(value, api)) === false; }catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }setBusy(false);-- if (!keep) api.clear();++ if (!keep) { api.clear(); if (replying) clearBar(); }api.refresh();});}-- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values });++ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing });}// ── Your settings, in a sheet: what sends, voice, compact, your own commands.@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': '60c9858ff204', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': '96e59dd5193b', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.215.0",++ "version": "4.216.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.215.0",++ "version": "4.216.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.215.0",++ "version": "4.216.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -150,6 +150,7 @@"./ov-float": "./ov-float.js","./ov-combo": "./ov-combo.js","./ov-composer": "./ov-composer.js",++ "./ov-capture": "./ov-capture.js","./ov-ai-choice": "./ai-choice.js","./ov-ai-choice.js": "./ov-ai-choice.js","./ov-wallet": "./ov-wallet.js",nnew file mode 100644@@@ -0,0 +1,235 @@++'use strict';++/**++ * ov-capture.js (window.OVCapture), the capture core Live, Cam and the composer share, run against a fake browser:++ * - the strategies in order, the first that opens wins; a refusal stops at once; a hung strategy is timed out;++ * - every part can be replaced (and the replaced one called through); strategies added, removed, replaced, reset;++ * - the screen, a photo and a recording;++ * - the person's settings: this device first; the portable ones follow them through settings.sync(), while camera and++ * microphone ids stay on the device;++ * - plain words for every failure, with the site's own last step;++ * - one copy wins (a newer one is never replaced by an older), and the module fetches nothing from anywhere.++ * node test/capture-core.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const source = fs.readFileSync(path.join(__dirname, '..', 'ov-capture.js'), 'utf8');++const code = source.replace(/^\s*(\/\/|\*|\/\*).*$/gm, '');++assert.ok(!/https?:\/\//.test(code), 'the module names no URL');++assert.ok(!/\bfetch\s*\(|XMLHttpRequest|WebSocket|importScripts|createElement\(['"]script/.test(code), 'the module fetches nothing and loads nothing');++++class FakeTrack { constructor(kind) { this.kind = kind; this.stopped = false; } stop() { this.stopped = true; } }++class FakeStream {++ constructor(tracks = []) { this.tracks = tracks.slice(); }++ getTracks() { return this.tracks; }++ getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); }++ getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); }++ addTrack(t) { this.tracks.push(t); }++}++class FakeDOMException extends Error { constructor(message, name) { super(message); this.name = name; } }++class FakeBlob { constructor(parts, o = {}) { this.parts = parts; this.type = o.type || ''; this.size = parts.length; } }++class FakeRecorder {++ constructor(stream, options) { this.stream = stream; this.mimeType = (options && options.mimeType) || 'video/webm'; this.state = 'inactive'; FakeRecorder.last = this; }++ static isTypeSupported(t) { return /webm/.test(t); }++ start() { this.state = 'recording'; }++ stop() { this.state = 'inactive'; this.ondataavailable({ data: { size: 3 } }); this.onstop(); }++}++++function load({ gum, display, ua, extra = {} } = {}) {++ const store = new Map();++ const calls = [];++ const ctx = vm.createContext({++ navigator: {++ userAgent: ua || 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile Safari/537.36', maxTouchPoints: 5, onLine: true,++ mediaDevices: {++ getUserMedia: gum ? (c) => { calls.push(c); return gum(c, calls.length); } : undefined,++ getDisplayMedia: display ? (c) => { calls.push(c); return display(c); } : undefined,++ enumerateDevices: async () => [{ kind: 'videoinput', deviceId: 'a', label: 'Front' }],++ },++ },++ MediaStream: FakeStream, DOMException: FakeDOMException, Blob: FakeBlob, MediaRecorder: FakeRecorder,++ setTimeout, clearTimeout, setInterval, clearInterval, console, isSecureContext: true,++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },++ document: { addEventListener() {} },++ ...extra,++ });++ ctx.window = ctx;++ vm.runInContext(source, ctx);++ return { capture: ctx.OVCapture, calls, store, ctx };++}++// Values made inside the vm come from another realm: compare them as JSON.++const same = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);++const both = (c) => new FakeStream([...(c.video ? [new FakeTrack('video')] : []), ...(c.audio ? [new FakeTrack('audio')] : [])]);++const tick = () => new Promise((r) => setImmediate(r));++++(async () => {++ // ── Opening a camera ──++ let t = load({ gum: async (c) => both(c) });++ assert.strictEqual(t.capture.version, 2);++ let s = await t.capture.open({ mode: 'camera', facing: 'environment', resolution: '1080' });++ assert.strictEqual(s.getVideoTracks().length, 1);++ assert.strictEqual(t.calls.length, 1, 'the preferred way opened it in one call');++ same(t.calls[0].video.facingMode, { ideal: 'environment' });++ assert.strictEqual(t.calls[0].video.height.ideal, 1080);++++ // Both at once refused by the hardware (an Android phone): the camera, then the microphone, each on its own.++ t = load({ gum: async (c) => { if (c.video && c.audio) throw new FakeDOMException('busy', 'OverconstrainedError'); return both(c); } });++ const seen = [];++ t.capture.on('failed', (e) => seen.push(`failed:${e.strategy}`));++ t.capture.on('opened', (e) => seen.push(`opened:${e.strategy}`));++ s = await t.capture.open({ mode: 'camera' });++ assert.strictEqual(s.getVideoTracks().length + s.getAudioTracks().length, 2);++ same(seen, ['failed:preferred', 'opened:separately']);++++ // A refusal is the person's answer: no second way of asking.++ t = load({ gum: async () => { throw new FakeDOMException('denied', 'NotAllowedError'); } });++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotAllowedError');++ assert.strictEqual(t.calls.length, 1, 'stopped after the refusal');++++ // Several failures: the one that says most about what to do is the one reported.++ t = load({ gum: async (c, n) => { throw new FakeDOMException('x', n === 1 ? 'NotFoundError' : 'NotReadableError'); } });++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotReadableError');++++ // A strategy that never answers is timed out, and the next one runs.++ t = load({ gum: async (c, n) => (n === 1 ? new Promise(() => {}) : both(c)) });++ t.capture.use('timeoutMs', 30);++ s = await t.capture.open({ mode: 'camera' });++ assert.ok(s.getVideoTracks().length, 'opened after the timed-out strategy');++++ // Asked for a camera and given none: not opened, and its tracks are stopped.++ const lone = new FakeTrack('audio');++ t = load({ gum: async () => new FakeStream([lone]) });++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotFoundError');++ assert.ok(lone.stopped, 'the half-open stream was stopped');++++ // ── Parts and strategies ──++ t = load({ gum: async (c) => both(c) });++ const before = t.capture.use('constraints', (o) => ({ ...before(o), audio: { echoCancellation: false } }));++ await t.capture.open({ mode: 'camera' });++ same(t.calls[0].audio, { echoCancellation: false });++ assert.throws(() => t.capture.part('nope'), /no capture part "nope"/);++ for (const p of ['constraints', 'getUserMedia', 'getDisplayMedia', 'enumerate', 'meter', 'network', 'still', 'recorder', 'mimeTypes', 'store', 'portable', 'explain', 'timeoutMs']) {++ assert.ok(t.capture.parts().includes(p), `the ${p} part can be replaced`);++ }++ t = load({ gum: async (c) => both(c) });++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);++ let ran = '';++ t.capture.strategies.add({ name: 'mine', open: async () => { ran = 'mine'; return both({ video: true, audio: true }); } }, { before: 'preferred' });++ same(t.capture.strategies.list().map((x) => x.name), ['mine', 'preferred', 'separately', 'basic']);++ await t.capture.open({ mode: 'camera' });++ assert.strictEqual(ran, 'mine');++ t.capture.strategies.remove('mine');++ t.capture.strategies.add({ name: 'last', open: async () => both({ video: true, audio: true }) }, { after: 'basic' });++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic', 'last']);++ t.capture.strategies.reset();++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);++ assert.throws(() => t.capture.strategies.add({ name: 'x' }), /a strategy is/);++++ // ── The screen ──++ t = load({ gum: async (c) => both(c), display: async (c) => both({ video: true, audio: c.audio }) });++ s = await t.capture.screen({ frameRate: 15 });++ assert.strictEqual(s.getVideoTracks().length, 1);++ assert.strictEqual(t.calls[0].video.frameRate.ideal, 15);++ same(t.capture.can(), { camera: true, screen: true, record: true, secure: true });++ t = load({ gum: async (c) => both(c) });++ await assert.rejects(t.capture.screen(), (e) => e.name === 'NoApi');++ assert.strictEqual(t.capture.can().screen, false, 'a page can hide "share your screen" where it cannot work');++++ // ── A photo, through the replaceable still part ──++ t = load({ gum: async (c) => both(c) });++ let asked = null;++ t.capture.use('still', async (stream, o) => { asked = o; return new FakeBlob(['jpg'], { type: o.type || 'image/jpeg' }); });++ const photo = await t.capture.photo(await t.capture.open({ mode: 'camera_only' }), { mirror: true });++ assert.strictEqual(photo.type, 'image/jpeg');++ assert.strictEqual(asked.mirror, true);++ // The real still part says so plainly when there is no picture to take.++ t = load({ gum: async (c) => both(c) });++ await assert.rejects(t.capture.photo(new FakeStream([new FakeTrack('audio')])), (e) => e.name === 'NotFoundError');++++ // ── A recording: the first supported format, events, a stop that gives a Blob ──++ t = load({ gum: async (c) => both(c) });++ const got = [];++ t.capture.on('recorded', (e) => got.push(e.blob.type));++ const rec = t.capture.record(await t.capture.open({ mode: 'camera' }), { maxSeconds: 5 });++ assert.strictEqual(rec.mimeType, 'video/webm;codecs=vp9,opus', 'mp4 is not supported by the fake browser: the first webm is used');++ const clip = await rec.stop();++ assert.strictEqual(clip.type, 'video/webm');++ same(got, ['video/webm']);++ assert.strictEqual(await rec.stop(), clip, 'stop twice gives the same clip');++ // Cancelled: nothing comes back.++ const rec2 = t.capture.record(new FakeStream([new FakeTrack('audio')]));++ assert.strictEqual(rec2.mimeType, 'audio/webm;codecs=opus', 'an audio-only stream records audio');++ rec2.cancel();++++ // ── Settings: this device, then the person ──++ t = load({ gum: async (c) => both(c) });++ let saved = null;++ t.capture.on('settings', (e) => { saved = e.settings; });++ t.capture.settings.save({ mirror: false, resolution: '1080', lens: 'cam-back-1' });++ assert.strictEqual(t.capture.settings.load().resolution, '1080');++ assert.strictEqual(saved.mirror, false);++ assert.ok(t.store.get('ov_capture_settings').includes('1080'), 'kept on this device');++ // A remote store (OpenVibe.Cam's camera settings, for example): its portable choices arrive; device ids stay here.++ const remote = { v: { mirror: true, resolution: '480', effects: 'warm', lens: 'someone-elses-camera' }, saves: [] };++ const synced = [];++ t.capture.on('synced', (e) => synced.push(e.settings));++ const unsync = t.capture.settings.sync({ load: async () => remote.v, save: async (v) => { remote.saves.push(v); } });++ await tick(); await tick();++ const now = t.capture.settings.load();++ assert.strictEqual(now.resolution, '480', 'the person\'s resolution followed them');++ assert.strictEqual(now.effects, 'warm');++ assert.strictEqual(now.lens, 'cam-back-1', 'a camera id from another device is never taken');++ assert.strictEqual(synced.length, 1);++ // A change here goes to the remote store, without device ids.++ t.capture.settings.save({ mirror: false });++ await tick();++ assert.strictEqual(remote.saves.length, 1);++ assert.strictEqual(remote.saves[0].mirror, false);++ assert.ok(!('lens' in remote.saves[0]) && !('mic' in remote.saves[0]), 'device ids never leave the device');++ // A device-only change is not sent.++ t.capture.settings.save({ mic: 'usb-mic' });++ await tick();++ assert.strictEqual(remote.saves.length, 1);++ // A remote that fails leaves this device working, and says so.++ unsync();++ let failed = null;++ t.capture.on('sync-failed', (e) => { failed = e.error; });++ t.capture.settings.sync({ load: async () => { throw new Error('offline'); }, save: async () => {} });++ await tick(); await tick();++ assert.ok(failed && /offline/.test(failed.message));++ assert.strictEqual(t.capture.settings.load().mic, 'usb-mic');++ assert.throws(() => t.capture.settings.sync({}), /settings\.sync takes/);++ // A replaced store is used.++ const mem = {};++ t.capture.use('store', { load: () => mem.v || {}, save: (v) => { mem.v = v; } });++ t.capture.settings.save({ lens: 'back-wide' });++ assert.strictEqual(mem.v.lens, 'back-wide');++++ // ── Plain words ──++ t = load({ gum: async (c) => both(c) });++ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'camera').title, /said no to the camera and microphone/);++ const found = t.capture.explain({ name: 'NotFoundError' }, 'camera', { instead: 'Or go live with your screen instead.' });++ assert.strictEqual(found.steps[found.steps.length - 1], 'Or go live with your screen instead.', 'the site adds its own last step');++ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'screen').title, /Screen sharing was stopped/);++ assert.match(t.capture.explain({ name: 'NoApi' }, 'screen').steps[0], /Phones can't share their screen/);++ assert.ok(!/openvibe\.live/.test(JSON.stringify(t.capture.explain({ name: 'NoApi' }, 'camera'))), 'no site\'s name in the shared words');++ const mac = load({ gum: async (c) => both(c), ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15', extra: {} });++ mac.ctx.navigator.maxTouchPoints = 0;++ assert.match(mac.capture.explain({ name: 'NotAllowedError' }, 'screen').steps.join(' '), /Screen Recording/);++ const inApp = load({ ua: 'Instagram 350 (iPhone)' });++ await assert.rejects(inApp.capture.open({}), (e) => e.name === 'NoApi');++ assert.match(inApp.capture.explain({ name: 'NoApi' }, 'camera').steps.join(' '), /inside another app/);++++ // ── One copy wins: an older or equal copy loaded later changes nothing ──++ t = load({ gum: async (c) => both(c) });++ const first = t.ctx.OVCapture;++ vm.runInContext(source, t.ctx);++ assert.strictEqual(t.ctx.OVCapture, first, 'a second copy of the same version keeps the first');++ t.ctx.OVCapture = { version: 1 };++ vm.runInContext(source, t.ctx);++ assert.strictEqual(t.ctx.OVCapture.version, 2, 'a newer copy replaces an older one (Live\'s own, version 1)');++++ console.log('capture-core: strategies, parts, screen, photo, recording, settings that follow the person, plain words, one copy wins');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -27,7 +27,8 @@ window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1',tools: ['photo', 'file', 'emoji', 'poll', 'voice'],audience: { name: 'audience', label: 'Who sees it', options: [['public', 'Everyone'], ['followers', 'Followers']], value: 'public' },onSubmit: async (v) => { window.got.push({ text: v.text, files: v.files.map((f) => f.name), audience: v.audience }); return window.keepNext ? false : true; } });--window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async () => true });++window.chatSent = []; window.saved = [];++window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async (v) => { window.chatSent.push({ text: v.text, reply: v.reply }); return true; } });window.agent = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1', mode: { name: 'mode', label: 'Mode', options: [['balanced', 'Balanced'], ['best', 'Best']], value: 'balanced' },chips: [{ name: 'effort', label: 'Reasoning', icon: 'gauge', options: [['medium', 'Medium'], ['high', 'High']], value: 'high' }],onSubmit: async (v) => { window.got.push({ mode: v.mode, effort: v.chips.effort }); } });@@@ -104,10 +105,33 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'rawait page.evaluate(`agent.setRunning(false)`);assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Send');++ // Editing your message in the box (openvibe.design rule 138): a bar says so, the send is Save, Enter saves++ // through onSave (not onSubmit), Esc puts back what was in the box.++ await page.evaluate(`(() => { const t = chat.text; t.value = 'half a draft'; t.dispatchEvent(new Event('input', { bubbles: true })); chat.edit({ text: 'Yo', onSave: async (x) => { window.saved.push(x); } }); })()`);++ const ed = await page.evaluate(`(() => { const f = document.getElementById('c1'); return { bar: (f.querySelector('.ovc-bar b') || {}).textContent, text: f.querySelector('textarea').value, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), editing: chat.editing(), first: f.querySelector('.ovc-body').firstElementChild.className }; })()`);++ assert.deepEqual(ed, { bar: 'Editing your message', text: 'Yo', send: 'Save', editing: true, first: 'ovc-bar' });++ await page.evaluate(`(() => { const t = chat.text; t.value += ' there'; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);++ await until(`window.saved.length === 1`);++ assert.deepEqual(await page.evaluate(`window.saved`), ['Yo there'], 'Save sends the edit to onSave');++ assert.equal(await page.evaluate(`window.chatSent.length`), 0, 'an edit is not a new message');++ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, send: document.querySelector('#c1 [data-ovt-send]').getAttribute('aria-label') })`),++ { bar: false, text: 'half a draft', send: 'Send' }, 'after saving: the draft is back and the send is Send');++ await page.evaluate(`chat.edit({ text: 'Yo', onSave: async () => {} })`);++ await page.evaluate(`chat.text.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))`);++ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, editing: chat.editing() })`), { bar: false, text: 'half a draft', editing: false }, 'Esc cancels');++ // Replying: a bar names who, and the send carries the reply.++ await page.evaluate(`chat.reply({ name: 'goosely', text: 'Yo', data: { id: 7 } })`);++ assert.equal(await page.evaluate(`document.querySelector('#c1 .ovc-bar b').textContent`), 'Replying to goosely');++ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);++ await until(`window.chatSent.length === 1`);++ assert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });++ assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');++// Nothing is wider than the phone.assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);await page.close();-- console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, 390 px');++ console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, editing and replying bars, 390 px');} finally {await browser.close();server.close();@@@ -17,4 +17,31 @@ assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https:assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');--console.log('messages: room and bubbles, groups, dates, links and profile picture ok');++// Actions (openvibe.design rule 138): one cluster per message at its corner; edit and delete only on your own;++// "edited" is one word after the words.++const pressed = [];++render(root, { view: 'room', me: 1, actions: ['reply', 'edit', 'delete'], onAction: (id, m) => pressed.push(`${id}:${m.id}`), messages: [++ { id: 5, sender_id: 2, username: 'bob', message: 'theirs' },++ { id: 6, sender_id: 1, username: 'alice', message: 'mine', edited_at: '2026-11-12 02:49:00' },++] });++const rows = root.querySelectorAll('.ovm-room');++assert.strictEqual(rows[0].querySelectorAll('.ovm-actions .ovm-act').length, 1, 'someone else\'s message: reply only');++assert.deepStrictEqual([...rows[1].querySelectorAll('.ovm-act')].map((b) => b.dataset.action), ['reply', 'edit', 'delete']);++assert.strictEqual(rows[1].querySelectorAll('.ovm-actions').length, 1, 'one cluster');++assert.ok(!rows[1].querySelector('.ovm-room-text .ovm-act'), 'no action inside the words');++assert.strictEqual(rows[1].querySelector('.ovm-room-text').nextElementSibling.className, 'ovm-edited', '"edited" right after the words');++assert.strictEqual(rows[1].querySelector('.ovm-edited').textContent, 'edited');++assert.ok(/^Edited/.test(rows[1].querySelector('.ovm-edited').title));++rows[1].querySelector('.ovm-act-edit').dispatchEvent(new window.Event('click', { bubbles: true }));++assert.deepStrictEqual(pressed, ['edit:6']);++assert.ok(rows[1].querySelector('.ovm-act-edit').getAttribute('aria-label'), 'each action is labelled');++// A site's own row gets the same cluster.++const own = document.createElement('div'); own.className = 'chat-msg'; root.appendChild(own);++const { actions } = require('../messages');++assert.strictEqual(actions(own, ['reply', 'edit'], { mine: false }).children.length, 1);++assert.strictEqual(actions(own, ['edit'], { mine: false }), null, 'nothing to offer: no cluster');++// The CSS: absolute, hidden until asked for, thumb-sized without hover.++const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'messages.css'), 'utf8');++assert.match(css, /\.ovm-actions \{ position: absolute;[^}]*display: none;/);++assert.match(css, /@media \(hover: none\) \{[\s\S]{0,300}\.ovm-act \{ width: 44px; height: 40px;/);++console.log('messages: room and bubbles, groups, dates, links and profile picture ok; actions in one cluster; edited after the words');
@@ -1,5 +1,43 @@
@@ -1,5 +1,43 @@
# Changelog
# Changelog
+## 4.216.0 — 2026-11-12
+
+One capture core for every camera, and a chat message's actions in one place on every site.
+
+- **`ov-capture.js`: `window.OVCapture`, one way to open a camera, a microphone or a screen, take a photo and record a
+ clip**, shared by Live's go-live page, OpenVibe.Cam's recorder and `<ov-camera>`, and the composer (the owner, 11-12:
+ Live's capture must be "completely customize[able] … highly over engineered modular editable", then "OpenVibe.cam is
+ actually fine … Harmony"). It began as Live's own system (OpenVibe.Live #847) and every site serves its own copy at
+ `/shared/ov-capture.js`, so a camera never waits on another site, a content policy or a cache (from 11-09 to 11-12
+ nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it).
+ - `open({ mode, facing, lens, mic, resolution })` runs the strategies in order (together, then each on its own, then
+ anything), each with a time limit; a refusal stops at once; the failure that says most is the one reported.
+ - `screen()`, `photo(stream, { mirror })`, `record(stream, { maxSeconds })` → `{ stop() → Blob, cancel() }` (the first
+ format the browser supports), `devices()`, `meter()`, `network()`, `can()` (what this browser allows here).
+ - `explain(error, mode, { instead })`: plain steps for each browser (Safari's "aA", Android's site settings,
+ Firefox's blocked icon, an app's in-app browser, a Mac's Screen Recording switch), with a last step of the site's
+ own.
+ - Settings: this device first; `settings.sync({ load, save })` lets the choices that mean the same anywhere (facing,
+ resolution, mirror, effects) follow the person, for example through OpenVibe.Cam's camera settings; camera and
+ microphone ids never leave the device.
+ - Every part (`use(name, impl)` returns the one it replaced), every strategy (`strategies.add/remove/replace/reset`)
+ and every event (`on('opened' | 'failed' | 'settings' | 'synced' | 'recorded' …)`) can be changed; it fetches
+ nothing. A newer copy replaces an older one, never the reverse.
+- **A message's actions are one cluster at its corner** (openvibe.design rule 138; the owner, 11-12, with phone
+ screenshots of a stream's chat: "The edit button needs improvement … so it never happens again", "it also looks
+ stupid as shit after you edit it"). `messages.js`:
+ - `render(root, { …, actions, onAction })`: `actions` names what to offer (`reply`, `edit`, `react`, `translate`,
+ `copy`, `delete`; edit and delete on your own messages only) as icon buttons in one cluster just above the
+ message's right end, on hover or focus on a computer and a tap on a phone (thumb-sized there), never in the words;
+ a press calls `onAction(id, message, row)` and bubbles `ovm:action`.
+ - `actions(row, ids, { onAction, message, mine })` gives a site's own rows (Live's chat) the same cluster.
+ - `edited(textNode, at)`: "edited" is one quiet word right after the words, the time as its title, never a line of
+ its own.
+- **The composer edits and replies in the box**: `api.edit({ text, onSave, onCancel })` puts your message's words in
+ the box under an "Editing your message" bar (Enter saves through `onSave`, never `onSubmit`; Esc or × puts back what
+ was in the box; the send is a check); `api.reply({ name, text, data })` shows "Replying to …" and the send carries
+ `reply: data`; `clearBar()`, `editing()`.
+
## 4.215.0 — 2026-11-12
## 4.215.0 — 2026-11-12
Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and
Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.215.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.216.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.215.0",
+ "version": "4.216.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': '60c9858ff204' };
+ const VIBE_REV = { 'ov-composer.js': '96e59dd5193b' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '674e9cf1b2f5', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': '5d8876db7e15', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-composer.js', // shared composer
'ov-composer.js', // shared composer
+ 'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-combo.js', // shared field dropdown
'ov-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
'ov-window.js', // shared dialog, sheet, and panel
'agent-sheet.js', // an agent's sheet in the one window (4.211.0)
'agent-sheet.js', // an agent's sheet in the one window (4.211.0)
@@ -32,6 +32,26 @@
@@ -32,6 +32,26 @@
.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }
.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }
.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }
.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }
.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
+/* A message's actions: one cluster just above its right end (openvibe.design rule 138), on hover or focus on a computer,
+ a tap on a phone; never in the words. "edited" is one quiet word after them. */
+.ovm-has-actions { position: relative; }
+.ovm-actions { position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6; display: none; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 10px; background: var(--bg-card, #131c2e); box-shadow: 0 6px 18px rgba(0, 0, 0, .35); }
+.ovm-messages > :first-child .ovm-actions, .ovm-messages > :first-child.ovm-has-actions > .ovm-actions { bottom: auto; top: calc(100% - 6px); }
+.ovm-has-actions:hover > .ovm-actions, .ovm-has-actions:focus-within > .ovm-actions, .ovm-has-actions.is-acting > .ovm-actions { display: inline-flex; }
+.ovm-act { display: grid; place-items: center; width: 28px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
+.ovm-act:hover, .ovm-act:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+.ovm-act:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }
+.ovm-act-delete:hover { color: #fca5a5; background: color-mix(in srgb, #ef4444 20%, transparent); }
+@media (hover: none) {
+ .ovm-has-actions:hover:not(.is-acting):not(:focus-within) > .ovm-actions { display: none; }
+ .ovm-actions { right: 4px; border-radius: 12px; }
+ .ovm-act { width: 44px; height: 40px; border-radius: 9px; }
+ .ovm-act svg { width: 18px; height: 18px; }
+}
+@media (hover: hover) { .ovm-room.ovm-has-actions:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); } }
+.ovm-room.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
+.ovm-edited { margin-left: 5px; font-size: .72em; color: var(--text-muted, #93a0b4); opacity: .85; white-space: nowrap; }
+.ovm-group[data-mine="true"] .ovm-edited { color: inherit; opacity: .7; }
/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */
/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */
.ovm-messages[data-view="room"] { gap: 0; }
.ovm-messages[data-view="room"] { gap: 0; }
@@ -1,5 +1,17 @@
@@ -1,5 +1,17 @@
'use strict';
'use strict';
-/** Render Chat messages as a compact room or grouped conversation bubbles. */
+/**
+ * Render Chat messages as a compact room or grouped conversation bubbles.
+ *
+ * render(root, { messages, view: 'room' | 'bubbles', me, now, actions, onAction })
+ * actions: the ids to offer on each message ('reply', 'edit', 'react', 'translate', 'copy', 'delete'), or
+ * (message, { mine }) → ids. 'edit' and 'delete' are offered only on your own messages.
+ * onAction(id, message, row): a button was pressed (an 'ovm:action' event with { action, message } also bubbles).
+ * actions(row, ids, { onAction, message }) → the cluster: the same buttons for a row a site draws itself (Live's chat).
+ * edited(textNode, at) → the quiet "edited" word after a message's words, its time as the title.
+ *
+ * A message's actions are one cluster at its corner (openvibe.design rule 138): shown on hover or focus on a computer,
+ * on a tap on a phone, never in the words. "edited" is one quiet word after them, never a line of its own.
+ */
const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));
const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));
function safeLinkify(node, value) {
function safeLinkify(node, value) {
const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;
const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;
@@ -20,7 +32,74 @@ function dayLabel(date, now) {
@@ -20,7 +32,74 @@ function dayLabel(date, now) {
if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';
if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';
return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);
return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);
}
}
-function render(root, { messages = [], view = 'room', me = null, now = new Date() } = {}) {
+// The actions' icons (24-unit strokes, as the composer's).
+const ICON = {
+ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
+ edit: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
+ react: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01',
+ translate: 'M4 5h9M8.5 3v2M6 5c.6 3 2.6 5.5 5.5 7M11 5c-.8 3.6-3.2 6.4-6.5 8M13 21l4-10 4 10M14.5 17.5h5',
+ copy: 'M9 9h10v12H9zM5 15V3h10',
+ delete: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3',
+};
+const LABEL = { reply: 'Reply', edit: 'Edit your message', react: 'React', translate: 'Translate', copy: 'Copy text', delete: 'Delete your message' };
+const OWN = new Set(['edit', 'delete']);
+function svgIcon(path) {
+ const ns = 'http://www.w3.org/2000/svg';
+ const s = document.createElementNS(ns, 'svg');
+ s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', '16'); s.setAttribute('height', '16'); s.setAttribute('aria-hidden', 'true');
+ s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round');
+ const p = document.createElementNS(ns, 'path'); p.setAttribute('d', path); s.appendChild(p);
+ return s;
+}
+/** One cluster of icon buttons at a row's corner. A site's own rows (Live's chat) use it too, so every chat matches. */
+function actions(row, ids, { onAction = null, message = null, mine = false } = {}) {
+ if (!row) return null;
+ let box = row.querySelector(':scope > .ovm-actions');
+ if (!box) { box = document.createElement('span'); box.className = 'ovm-actions'; row.appendChild(box); }
+ box.replaceChildren();
+ (ids || []).filter((id) => ICON[id] && (!OWN.has(id) || mine)).forEach((id) => {
+ const b = document.createElement('button');
+ b.type = 'button'; b.className = `ovm-act ovm-act-${id}`; b.dataset.action = id; b.title = LABEL[id]; b.setAttribute('aria-label', LABEL[id]);
+ b.appendChild(svgIcon(ICON[id]));
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ row.classList.remove('is-acting');
+ if (typeof onAction === 'function') onAction(id, message, row);
+ const view = row.ownerDocument && row.ownerDocument.defaultView;
+ const CE = (view && view.CustomEvent) || (typeof CustomEvent === 'function' ? CustomEvent : null);
+ if (CE) row.dispatchEvent(new CE('ovm:action', { bubbles: true, detail: { action: id, message } }));
+ });
+ box.appendChild(b);
+ });
+ row.classList.add('ovm-has-actions');
+ if (!box.children.length) { box.remove(); row.classList.remove('ovm-has-actions'); return null; }
+ return box;
+}
+/** "edited": one quiet word right after the words, the time it changed as its title. */
+function edited(after, at) {
+ if (!after || !after.parentNode) return null;
+ let mark = after.nextElementSibling && after.nextElementSibling.classList.contains('ovm-edited') ? after.nextElementSibling : null;
+ if (!mark) { mark = document.createElement('small'); mark.className = 'ovm-edited'; after.after(mark); }
+ mark.textContent = 'edited';
+ const raw = String(at || '');
+ const d = raw ? new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw.replace(' ', 'T')) ? raw.replace(' ', 'T') : raw.replace(' ', 'T') + 'Z') : null;
+ mark.title = d && !isNaN(d) ? `Edited at ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(d)}` : 'Edited';
+ return mark;
+}
+/** A phone has no hover: a tap on a message shows its actions, a tap elsewhere hides them. */
+function tapToAct(root) {
+ if (!root || root.__ovmTap) return;
+ root.__ovmTap = true;
+ root.addEventListener('click', (e) => {
+ const t = e.target;
+ if (!t || !t.closest || t.closest('.ovm-actions')) return;
+ const noHover = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches;
+ const row = t.closest('.ovm-has-actions');
+ root.querySelectorAll('.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });
+ if (noHover && row && !t.closest('a, button, input, textarea')) row.classList.toggle('is-acting');
+ });
+}
+function render(root, { messages = [], view = 'room', me = null, now = new Date(), actions: offer = null, onAction = null } = {}) {
if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');
if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');
if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');
if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');
root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;
root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;
@@ -37,6 +116,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -37,6 +116,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);
const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);
const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
+ if (message.edited_at) edited(body, message.edited_at);
+ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
+ if (ids && ids.length) actions(row, ids, { onAction, message, mine });
} else {
} else {
const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);
const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);
if (same) group.dataset.continued = 'true';
if (same) group.dataset.continued = 'true';
@@ -50,6 +132,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -50,6 +132,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');
if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');
safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);
safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);
+ if (message.edited_at) edited(content, message.edited_at);
+ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
+ if (ids && ids.length) actions(bubble, ids, { onAction, message, mine });
if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {
if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {
const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);
const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);
if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }
if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }
@@ -59,9 +144,10 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
@@ -59,9 +144,10 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
}
}
previous = { sender, stamp };
previous = { sender, stamp };
});
});
+ if (offer) tapToAct(root);
return root;
return root;
}
}
-const api = { render };
+const api = { render, actions, edited, tapToAct };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') {
if (typeof window !== 'undefined') {
window.OVMessages = api;
window.OVMessages = api;
@@ -602,7 +602,7 @@
@@ -602,7 +602,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aaad15c6adb6', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'c1ca9b80e1b5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
new file mode 100644
new file mode 100644
@@ -0,0 +1,406 @@
@@ -0,0 +1,406 @@
+/*
+ * ov-capture.js: OpenVibe's capture core, window.OVCapture. One way to open a camera, a microphone or a screen, take a
+ * photo and record a clip, on every OpenVibe site: Live's go-live page, OpenVibe.Cam's recorder and <ov-camera>, and the
+ * composer's camera button. Built from parts that can each be replaced, and served by each site from its own copy
+ * (/shared/ov-capture.js), so a camera never waits on another site, a content policy or a cache.
+ *
+ * The owner, 2026-11-12: Live's capture must be "completely customize[able] … implemented in a highly over engineered
+ * modular editable way", and then "OpenVibe.cam is actually fine … maybe we should be using that for live … Harmony".
+ * Both hold: this file began as Live's own system (OpenVibe.Live #847) and is now the one Live and Cam share. From 11-09
+ * to 11-12 nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it; a site's own
+ * copy of this file cannot be refused that way.
+ *
+ * Using it:
+ * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. mode: 'camera' (both), 'camera_only',
+ * 'mic_only'. Runs the strategies in order, each with its
+ * own time limit; the first that opens wins.
+ * OVCapture.screen({ audio, frameRate }) → a MediaStream of a screen, a window or a tab.
+ * OVCapture.photo(stream, { type, quality, mirror }) → a Blob: the camera's picture now.
+ * OVCapture.record(stream, { maxSeconds, mimeType }) → { stop() → Promise<Blob>, cancel(), seconds(), mimeType }.
+ * OVCapture.devices() → the browser's cameras and microphones.
+ * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.
+ * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).
+ * OVCapture.can() → { camera, screen, record, secure }: what this browser
+ * allows here, so a page can hide what it cannot do.
+ * OVCapture.stop(stream)
+ * OVCapture.explain(error, mode, { instead }) → { title, steps }: what happened and what to tap, for
+ * this browser. `instead` adds a last step of the site's own
+ * (Live: "go live with your screen instead").
+ * OVCapture.settings.load() / .save(partial) → the person's choices: facing, lens, mic, resolution,
+ * mirror, effects (the 'store' part; this browser first).
+ * OVCapture.settings.sync({ load, save }) → unsync(): the choices that are the same on every device
+ * (the 'portable' part: facing, resolution, mirror,
+ * effects) follow the person through a remote store, such
+ * as OpenVibe.Cam's camera settings. Camera and microphone
+ * ids stay on the device that has them.
+ *
+ * Changing it:
+ * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:
+ * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));
+ * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, getDisplayMedia, enumerate,
+ * meter, network, still, recorder, mimeTypes, store, portable, explain, timeoutMs.
+ * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),
+ * .remove(name), .replace(name, strategy), .reset(). A strategy is
+ * { name, note?, timeoutMs?, open(options, capture) }.
+ * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·
+ * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings } ·
+ * 'synced' { settings } · 'sync-failed' { error } · 'recording' { seconds } ·
+ * 'recorded' { blob, seconds }. Returns off().
+ * Every name and every default is in this file. It fetches nothing; a page that wants the person's settings to follow
+ * them passes its own remote store to settings.sync().
+ */
+(function (root) {
+ 'use strict';
+ const VERSION = 2;
+ if (!root || (root.OVCapture && Number(root.OVCapture.version) >= VERSION)) return;
+ const nav = () => root.navigator || {};
+ const doc = () => root.document || null;
+
+ // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────
+ const parts = {
+ /** What to ask the browser for, from the options (facing, lens, mic, resolution). */
+ constraints(o = {}) {
+ const height = Number(o.resolution) || 720;
+ const video = o.video === false ? false : {
+ facingMode: { ideal: o.facing || 'user' },
+ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
+ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
+ };
+ const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);
+ return { video, audio };
+ },
+ getUserMedia: (constraints) => nav().mediaDevices.getUserMedia(constraints),
+ getDisplayMedia: (constraints) => nav().mediaDevices.getDisplayMedia(constraints),
+ enumerate: () => nav().mediaDevices.enumerateDevices(),
+ meter(stream, onLevel) {
+ const AC = root.AudioContext || root.webkitAudioContext;
+ if (!AC || !stream || !stream.getAudioTracks().length) return () => {};
+ const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512);
+ let frame = 0, closed = false;
+ analyser.fftSize = 512;
+ ctx.createMediaStreamSource(stream).connect(analyser);
+ if (ctx.state === 'suspended') {
+ ctx.resume().catch(() => {});
+ const d = doc();
+ if (d) d.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true });
+ }
+ const tick = () => {
+ if (closed) return;
+ analyser.getByteTimeDomainData(data);
+ let sum = 0;
+ for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; }
+ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
+ frame = root.requestAnimationFrame(tick);
+ };
+ frame = root.requestAnimationFrame(tick);
+ return () => { closed = true; root.cancelAnimationFrame(frame); ctx.close().catch(() => {}); };
+ },
+ network() {
+ const n = nav();
+ if (n.onLine === false) return 'offline';
+ const c = n.connection || n.mozConnection || n.webkitConnection;
+ if (!c || !(c.effectiveType || c.downlink)) return '';
+ return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
+ },
+ /** The camera's picture now, as a Blob: the video track drawn once into a canvas. */
+ async still(stream, o = {}) {
+ const d = doc();
+ const track = stream && stream.getVideoTracks()[0];
+ if (!d || !track) throw Object.assign(new Error('No camera picture to take'), { name: 'NotFoundError' });
+ const video = d.createElement('video');
+ video.muted = true; video.playsInline = true; video.srcObject = new root.MediaStream([track]);
+ await video.play().catch(() => {});
+ if (!video.videoWidth) await new Promise((r) => { video.addEventListener('loadeddata', r, { once: true }); root.setTimeout(r, 1500); });
+ const w = video.videoWidth || 1280, h = video.videoHeight || 720;
+ const canvas = d.createElement('canvas');
+ canvas.width = w; canvas.height = h;
+ const g = canvas.getContext('2d');
+ if (o.mirror) { g.translate(w, 0); g.scale(-1, 1); }
+ g.drawImage(video, 0, 0, w, h);
+ video.srcObject = null;
+ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('The picture could not be made'))), o.type || 'image/jpeg', o.quality || 0.92));
+ },
+ /** A MediaRecorder for the stream. */
+ recorder: (stream, options) => new root.MediaRecorder(stream, options),
+ /** The recording formats to try, best first; the first this browser supports is used. */
+ mimeTypes: {
+ video: ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'],
+ audio: ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'],
+ },
+ /** Where the person's choices live on this device. Device ids are this browser's own, so this store is local. */
+ store: {
+ key: 'ov_capture_settings',
+ load() { try { return JSON.parse(root.localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },
+ save(settings) { try { root.localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },
+ },
+ /** The choices that mean the same on any device, so they may follow the person (settings.sync). */
+ portable: ['facing', 'resolution', 'mirror', 'effects'],
+ explain: null, // set below (explainFailure): the plain words for a failure, by browser
+ timeoutMs: 10000,
+ };
+
+ // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────
+ const listeners = new Map();
+ function on(event, fn) {
+ if (!listeners.has(event)) listeners.set(event, new Set());
+ listeners.get(event).add(fn);
+ return () => listeners.get(event).delete(fn);
+ }
+ function emit(event, detail) {
+ (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });
+ }
+
+ // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────
+ const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });
+ const merge = (...streams) => {
+ const out = new root.MediaStream();
+ streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));
+ return out;
+ };
+ const defaults = [
+ {
+ name: 'preferred',
+ note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',
+ open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),
+ },
+ {
+ name: 'separately',
+ note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',
+ async open(o, c) {
+ const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');
+ let video = null, audio = null, last = null;
+ if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }
+ if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }
+ if (!video && !audio) throw last || new root.DOMException('No camera or microphone', 'NotFoundError');
+ return merge(video, audio);
+ },
+ },
+ {
+ name: 'basic',
+ note: 'Any camera and any microphone, nothing asked of them.',
+ open: (o, c) => c.part('getUserMedia')(wants(o)),
+ },
+ ];
+ let order = defaults.slice();
+ const strategies = {
+ list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),
+ add(strategy, at = {}) {
+ if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');
+ order = order.filter((s) => s.name !== strategy.name);
+ const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;
+ if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);
+ },
+ remove(name) { order = order.filter((s) => s.name !== name); },
+ replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },
+ reset() { order = defaults.slice(); },
+ };
+
+ // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.
+ const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };
+ const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);
+ const refused = (e) => !!e && (e.name === 'NotAllowedError' || e.name === 'PermissionDeniedError' || e.name === 'SecurityError');
+ function within(promise, ms, name) {
+ let timer;
+ return Promise.race([promise, new Promise((_, reject) => { timer = root.setTimeout(() => reject(new root.DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])
+ .finally(() => root.clearTimeout(timer));
+ }
+ const noApi = (what) => Object.assign(new Error(`This browser has no ${what} here`), { name: 'NoApi' });
+
+ async function open(options = {}) {
+ const md = nav().mediaDevices;
+ if (!md || !md.getUserMedia) throw noApi('camera');
+ const o = { ...capture.settings.load(), ...options };
+ let worst = null;
+ for (const s of order) {
+ emit('opening', { strategy: s.name, options: o });
+ try {
+ const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);
+ const w = wants(o);
+ // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.
+ if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {
+ stream.getTracks().forEach((t) => t.stop());
+ throw new root.DOMException('The requested device was not available', 'NotFoundError');
+ }
+ emit('opened', { strategy: s.name, stream });
+ return stream;
+ } catch (error) {
+ worst = heavier(worst, error);
+ emit('failed', { strategy: s.name, error });
+ // A refusal is the person's answer: asking again in another way would only ask again.
+ if (refused(error)) break;
+ }
+ }
+ emit('gave-up', { error: worst });
+ throw worst || new Error('The camera did not open');
+ }
+
+ async function screen(options = {}) {
+ const md = nav().mediaDevices;
+ if (!md || !md.getDisplayMedia) throw noApi('screen sharing');
+ const constraints = { video: { frameRate: { ideal: Number(options.frameRate) || 30 } }, audio: options.audio !== false };
+ emit('opening', { strategy: 'screen', options });
+ try {
+ const stream = await parts.getDisplayMedia(constraints);
+ emit('opened', { strategy: 'screen', stream });
+ return stream;
+ } catch (error) {
+ emit('failed', { strategy: 'screen', error });
+ emit('gave-up', { error });
+ throw error;
+ }
+ }
+
+ // ── Recording ─────────────────────────────────────────────────────────────────────────────────────────────
+ function pickMime(kind, wanted) {
+ const MR = root.MediaRecorder;
+ const list = wanted ? [wanted] : (parts.mimeTypes[kind] || []);
+ if (!MR || typeof MR.isTypeSupported !== 'function') return '';
+ return list.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';
+ }
+ function record(stream, o = {}) {
+ if (!root.MediaRecorder) throw noApi('recorder');
+ const kind = stream.getVideoTracks().length ? 'video' : 'audio';
+ const mimeType = pickMime(kind, o.mimeType);
+ const rec = parts.recorder(stream, mimeType ? { mimeType } : undefined);
+ const chunks = [];
+ const max = Math.max(1, Number(o.maxSeconds) || 60);
+ const started = Date.now();
+ let ticker = 0, limit = 0, done = null, cancelled = false;
+ const seconds = () => Math.min(max, Math.round((Date.now() - started) / 100) / 10);
+ const finished = new Promise((resolve, reject) => {
+ rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
+ rec.onerror = (e) => reject((e && e.error) || new Error('The recording failed'));
+ rec.onstop = () => {
+ root.clearInterval(ticker); root.clearTimeout(limit);
+ if (cancelled) { resolve(null); return; }
+ const blob = new root.Blob(chunks, { type: (mimeType || rec.mimeType || `${kind}/webm`).split(';')[0] });
+ emit('recorded', { blob, seconds: seconds() });
+ resolve(blob);
+ };
+ });
+ rec.start(1000);
+ ticker = root.setInterval(() => emit('recording', { seconds: seconds() }), 500);
+ limit = root.setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); }, max * 1000);
+ return {
+ mimeType: mimeType || rec.mimeType || '',
+ seconds,
+ stop() { if (!done) { done = finished; if (rec.state !== 'inactive') rec.stop(); } return done; },
+ cancel() { cancelled = true; if (rec.state !== 'inactive') rec.stop(); },
+ };
+ }
+
+ // ── Plain words for a failure, by browser. ───────────────────────────────────────────────────────────────
+ function explainFailure(err, mode, extra = {}) {
+ const n = nav(), ua = n.userAgent || '';
+ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && n.maxTouchPoints > 1);
+ const mac = /Macintosh/.test(ua) && !ios;
+ const android = /Android/.test(ua);
+ const firefox = /Firefox|FxiOS/.test(ua);
+ const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);
+ const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua);
+ const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : mode === 'screen' ? 'screen' : 'camera and microphone';
+ const both = mode === 'camera_only' ? '' : ' and Microphone';
+ const name = err && err.name;
+ const instead = extra && extra.instead ? [extra.instead] : [];
+ if (name === 'NoApi' || (inApp && name !== 'NotReadableError')) {
+ return { title: `This browser can't use your ${mode === 'screen' ? 'screen' : 'camera'} here`, steps: inApp
+ ? ['You opened this inside another app (like Discord, Instagram or TikTok). Those can\'t share a camera.', `Tap the ⋮ or ⋯ menu and choose "Open in ${ios ? 'Safari' : 'Chrome'}" (or "Open in browser").`, 'Sign in there if asked, then try again.']
+ : mode === 'screen' && (ios || android) ? ['Phones can\'t share their screen from a browser. Use a computer, or your camera instead.', ...instead]
+ : ['Open this page in Chrome, Safari, Edge or Firefox, then try again.', ...instead] };
+ }
+ if (mode === 'screen' && refused(err)) {
+ return { title: 'Screen sharing was stopped', steps: mac
+ ? ['Try again and pick a screen, a window or a tab.', 'Nothing to pick? Open System Settings → Privacy & Security → Screen Recording, turn on your browser, then reopen it.']
+ : ['Try again and pick a screen, a window or a tab, then press Share.'] };
+ }
+ if (refused(err)) {
+ const steps = ios && safari ? ['Tap "aA" (or the page icon) at the left of the address bar.', 'Tap Website Settings.', `Set Camera${both} to Allow.`]
+ : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.']
+ : android && !firefox ? ['Tap the icon at the left of the address bar (a lock or two sliders).', 'Tap Permissions (or Site settings).', `Set Camera${both} to Allow.`]
+ : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).']
+ : safari ? ['Open Safari\'s menu → Settings for this website.', `Set Camera${both} to Allow.`]
+ : ['Click the camera icon at the right end of the address bar (or the lock at the left).', `Choose "Always allow" for the ${what}, then Done.`];
+ return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again.'] };
+ }
+ if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') {
+ return { title: 'Another app is using your camera', steps: ['Close apps that use the camera, like Zoom, FaceTime, WhatsApp, Instagram or the Camera app (and other tabs with a call).', 'Then press Try again.'] };
+ }
+ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
+ return { title: `No ${what} found`, steps: [mode === 'mic_only' ? 'Plug in a microphone or headset, then press Try again.' : 'Plug in a camera (or open its privacy cover), then press Try again.', ...instead] };
+ }
+ if (name === 'OverconstrainedError') {
+ return { title: 'That camera can\'t start with these settings', steps: ['Press Try again: it starts with simpler settings.', 'Still stuck? Pick another camera in the camera settings.'] };
+ }
+ if (name === 'TimeoutError') {
+ return { title: 'Your browser didn\'t answer', steps: ['Look for the browser\'s question at the top of the screen and tap Allow.', 'Nothing there? Press Try again.'] };
+ }
+ return { title: `The ${mode === 'screen' ? 'screen sharing' : 'camera'} didn't start`, steps: ['Press Try again.', 'Still stuck? Reload this page, or try Chrome or Safari.'] };
+ }
+ parts.explain = explainFailure;
+
+ // ── Settings: this device first; the portable ones may follow the person. ──────────────────────────────────────
+ const DEFAULTS = { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, effects: 'none' };
+ const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o && o[k] !== undefined).map((k) => [k, o[k]]));
+ let remote = null;
+ const settings = {
+ load: () => ({ ...DEFAULTS, ...parts.store.load() }),
+ save(partial) {
+ const next = { ...settings.load(), ...(partial || {}) };
+ parts.store.save(next);
+ emit('settings', { settings: next });
+ const r = remote;
+ const portable = pick(partial || {}, parts.portable);
+ if (r && Object.keys(portable).length) {
+ Promise.resolve().then(() => r.save(pick(next, parts.portable))).catch((error) => emit('sync-failed', { error }));
+ }
+ return next;
+ },
+ /** Follow the person: take the remote store's portable choices now, and send each later change to it. */
+ sync(store) {
+ if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') throw new TypeError('settings.sync takes { load(), save(settings) }');
+ remote = store;
+ const mine = store;
+ Promise.resolve().then(() => store.load()).then((theirs) => {
+ if (remote !== mine || !theirs || typeof theirs !== 'object') return;
+ const next = { ...settings.load(), ...pick(theirs, parts.portable) };
+ parts.store.save(next);
+ emit('settings', { settings: next });
+ emit('synced', { settings: next });
+ }).catch((error) => emit('sync-failed', { error }));
+ return () => { if (remote === mine) remote = null; };
+ },
+ };
+
+ // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────
+ const capture = {
+ version: VERSION,
+ part(name) {
+ if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);
+ return parts[name];
+ },
+ use(name, impl) {
+ const before = capture.part(name);
+ parts[name] = impl;
+ return before;
+ },
+ parts: () => Object.keys(parts),
+ strategies,
+ on,
+ open,
+ screen,
+ photo: (stream, o = {}) => parts.still(stream, { mirror: false, ...o }),
+ record,
+ devices: () => parts.enumerate(),
+ meter: (stream, onLevel) => parts.meter(stream, onLevel),
+ network: () => parts.network(),
+ can() {
+ const md = nav().mediaDevices || {};
+ return { camera: typeof md.getUserMedia === 'function', screen: typeof md.getDisplayMedia === 'function', record: typeof root.MediaRecorder === 'function', secure: root.isSecureContext !== false };
+ },
+ stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },
+ explain: (error, mode, extra) => parts.explain(error, mode, extra),
+ settings,
+ };
+ root.OVCapture = capture;
+ if (typeof module === 'object' && module && module.exports) module.exports = capture;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -151,6 +151,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -151,6 +151,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
+.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }
+.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
+.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
+.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
+.ovc-v2 .ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
+.ovc-v2 .ovc-bar-x:hover, .ovc-v2 .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }
+.ovc-v2 .ovc-bar-x svg { width: 15px; height: 15px; }
+@media (pointer: coarse) { .ovc-v2 .ovc-bar-x { width: 40px; height: 40px; } }
+.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image, .ovc-v2[data-preset=comment] .ovc-file.ovc-image { flex-direction: column; flex-wrap: nowrap; align-items: stretch; width: 150px; max-width: 150px; height: auto; padding: 0; border-radius: 16px; overflow: hidden; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image, .ovc-v2[data-preset=comment] .ovc-file.ovc-image { flex-direction: column; flex-wrap: nowrap; align-items: stretch; width: 150px; max-width: 150px; height: auto; padding: 0; border-radius: 16px; overflow: hidden; }
@@ -223,6 +232,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -223,6 +232,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
stop: 'M7 7h10v10H7z',
stop: 'M7 7h10v10H7z',
cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',
cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',
gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',
gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',
+ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
+ pen: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
+ check: 'M5 12.5l4.5 4.5L19 7',
};
};
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
@@ -636,7 +648,11 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -636,7 +648,11 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// submitLabel, upload(file, onProgress), mentions, commands, beforeSend,
// submitLabel, upload(file, onProgress), mentions, commands, beforeSend,
// onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }
// onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }
// → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop
// → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop
- // while a run works, as in the agents portal), values() (every chip's value by name) }.
+ // while a run works, as in the agents portal), values() (every chip's value by name),
+ // edit({ text, onSave(text) → false keeps editing, onCancel, label }) (the box takes your message's words under an
+ // "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
+ // reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
+ // clearBar(), editing() }.
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
const PRESET = {
const PRESET = {
@@ -797,21 +813,83 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -797,21 +813,83 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }
if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }
else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }
else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }
};
};
+ // One bar above the box at a time: "Editing your message" or "Replying to …" (openvibe.design rule 138: a
+ // message's actions are a cluster at its corner, and what they start is said here, in the box).
+ let bar = null, barClose = null, editing = null, replying = null;
+ const clearBar = () => {
+ const f = barClose; barClose = null;
+ if (bar) { bar.remove(); bar = null; }
+ form.classList.remove('is-editing', 'is-replying');
+ if (f) f();
+ };
+ const showBar = (icon, title, detail, onClose) => {
+ clearBar();
+ bar = el('div', 'ovc-bar', svg(icon));
+ const t = el('b'); t.textContent = title;
+ const d = el('span'); d.textContent = detail || '';
+ const x = el('button', 'ovc-bar-x', svg(IC.x)); x.type = 'button'; x.title = 'Cancel'; x.setAttribute('aria-label', `Cancel: ${title}`);
+ x.addEventListener('click', () => { clearBar(); ta.focus(); });
+ bar.append(t, d, x);
+ body.insertBefore(bar, head.parentNode === body ? head.nextSibling : body.firstChild);
+ barClose = onClose;
+ };
+ const sendFaceOf = () => { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; };
+ const edit = (spec = {}) => {
+ const draft = editing ? editing.draft : ta.value;
+ if (replying) clearBar();
+ showBar(IC.pen, spec.label || 'Editing your message', 'Enter saves · Esc cancels', () => {
+ const was = editing; editing = null;
+ sendFaceOf();
+ if (was && !was.saved) { ta.value = was.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); if (typeof was.onCancel === 'function') was.onCancel(); }
+ api.refresh();
+ });
+ editing = { draft, onSave: spec.onSave, onCancel: spec.onCancel, saved: false };
+ form.classList.add('is-editing');
+ ta.value = String(spec.text || '');
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ send.innerHTML = svg(IC.check); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Save'); send.title = 'Save';
+ api.refresh();
+ ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length;
+ };
+ const reply = (spec = {}) => {
+ if (editing) clearBar();
+ const who = String(spec.name || 'someone');
+ showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); });
+ replying = { data: spec.data !== undefined ? spec.data : { name: who, text: spec.text }, onClear: spec.onClear };
+ form.classList.add('is-replying');
+ ta.focus();
+ };
+ ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
+ // Saving an edit is not a send: it runs first and nothing else hears the submit.
+ form.addEventListener('submit', async (e) => {
+ if (!editing) return;
+ e.preventDefault(); e.stopImmediatePropagation();
+ if (form.classList.contains('is-busy')) return;
+ const text = ta.value.trim(), ed = editing;
+ if (!text) { api.ctx.note('A message can\'t be empty.'); return; }
+ setBusy(true);
+ let keep = false;
+ try { keep = typeof ed.onSave === 'function' ? (await ed.onSave(text)) === false : false; }
+ catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
+ setBusy(false);
+ if (!keep && editing === ed) { ed.saved = true; ta.value = ed.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); clearBar(); }
+ api.refresh();
+ }, true);
if (typeof o.onSubmit === 'function') {
if (typeof o.onSubmit === 'function') {
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
if (form.classList.contains('is-busy')) return;
if (form.classList.contains('is-busy')) return;
- const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values() };
+ const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values(), reply: replying ? replying.data : null };
setBusy(true);
setBusy(true);
let keep = false;
let keep = false;
try { keep = (await o.onSubmit(value, api)) === false; }
try { keep = (await o.onSubmit(value, api)) === false; }
catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
setBusy(false);
setBusy(false);
- if (!keep) api.clear();
+ if (!keep) { api.clear(); if (replying) clearBar(); }
api.refresh();
api.refresh();
});
});
}
}
- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values });
+ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing });
}
}
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': '60c9858ff204', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '96e59dd5193b', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.215.0",
+ "version": "4.216.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.215.0",
+ "version": "4.216.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.215.0",
+ "version": "4.216.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -150,6 +150,7 @@
@@ -150,6 +150,7 @@
"./ov-float": "./ov-float.js",
"./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
"./ov-composer": "./ov-composer.js",
+ "./ov-capture": "./ov-capture.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-wallet": "./ov-wallet.js",
"./ov-wallet": "./ov-wallet.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
+'use strict';
+/**
+ * ov-capture.js (window.OVCapture), the capture core Live, Cam and the composer share, run against a fake browser:
+ * - the strategies in order, the first that opens wins; a refusal stops at once; a hung strategy is timed out;
+ * - every part can be replaced (and the replaced one called through); strategies added, removed, replaced, reset;
+ * - the screen, a photo and a recording;
+ * - the person's settings: this device first; the portable ones follow them through settings.sync(), while camera and
+ * microphone ids stay on the device;
+ * - plain words for every failure, with the site's own last step;
+ * - one copy wins (a newer one is never replaced by an older), and the module fetches nothing from anywhere.
+ * node test/capture-core.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const source = fs.readFileSync(path.join(__dirname, '..', 'ov-capture.js'), 'utf8');
+const code = source.replace(/^\s*(\/\/|\*|\/\*).*$/gm, '');
+assert.ok(!/https?:\/\//.test(code), 'the module names no URL');
+assert.ok(!/\bfetch\s*\(|XMLHttpRequest|WebSocket|importScripts|createElement\(['"]script/.test(code), 'the module fetches nothing and loads nothing');
+
+class FakeTrack { constructor(kind) { this.kind = kind; this.stopped = false; } stop() { this.stopped = true; } }
+class FakeStream {
+ constructor(tracks = []) { this.tracks = tracks.slice(); }
+ getTracks() { return this.tracks; }
+ getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); }
+ getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); }
+ addTrack(t) { this.tracks.push(t); }
+}
+class FakeDOMException extends Error { constructor(message, name) { super(message); this.name = name; } }
+class FakeBlob { constructor(parts, o = {}) { this.parts = parts; this.type = o.type || ''; this.size = parts.length; } }
+class FakeRecorder {
+ constructor(stream, options) { this.stream = stream; this.mimeType = (options && options.mimeType) || 'video/webm'; this.state = 'inactive'; FakeRecorder.last = this; }
+ static isTypeSupported(t) { return /webm/.test(t); }
+ start() { this.state = 'recording'; }
+ stop() { this.state = 'inactive'; this.ondataavailable({ data: { size: 3 } }); this.onstop(); }
+}
+
+function load({ gum, display, ua, extra = {} } = {}) {
+ const store = new Map();
+ const calls = [];
+ const ctx = vm.createContext({
+ navigator: {
+ userAgent: ua || 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile Safari/537.36', maxTouchPoints: 5, onLine: true,
+ mediaDevices: {
+ getUserMedia: gum ? (c) => { calls.push(c); return gum(c, calls.length); } : undefined,
+ getDisplayMedia: display ? (c) => { calls.push(c); return display(c); } : undefined,
+ enumerateDevices: async () => [{ kind: 'videoinput', deviceId: 'a', label: 'Front' }],
+ },
+ },
+ MediaStream: FakeStream, DOMException: FakeDOMException, Blob: FakeBlob, MediaRecorder: FakeRecorder,
+ setTimeout, clearTimeout, setInterval, clearInterval, console, isSecureContext: true,
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
+ document: { addEventListener() {} },
+ ...extra,
+ });
+ ctx.window = ctx;
+ vm.runInContext(source, ctx);
+ return { capture: ctx.OVCapture, calls, store, ctx };
+}
+// Values made inside the vm come from another realm: compare them as JSON.
+const same = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);
+const both = (c) => new FakeStream([...(c.video ? [new FakeTrack('video')] : []), ...(c.audio ? [new FakeTrack('audio')] : [])]);
+const tick = () => new Promise((r) => setImmediate(r));
+
+(async () => {
+ // ── Opening a camera ──
+ let t = load({ gum: async (c) => both(c) });
+ assert.strictEqual(t.capture.version, 2);
+ let s = await t.capture.open({ mode: 'camera', facing: 'environment', resolution: '1080' });
+ assert.strictEqual(s.getVideoTracks().length, 1);
+ assert.strictEqual(t.calls.length, 1, 'the preferred way opened it in one call');
+ same(t.calls[0].video.facingMode, { ideal: 'environment' });
+ assert.strictEqual(t.calls[0].video.height.ideal, 1080);
+
+ // Both at once refused by the hardware (an Android phone): the camera, then the microphone, each on its own.
+ t = load({ gum: async (c) => { if (c.video && c.audio) throw new FakeDOMException('busy', 'OverconstrainedError'); return both(c); } });
+ const seen = [];
+ t.capture.on('failed', (e) => seen.push(`failed:${e.strategy}`));
+ t.capture.on('opened', (e) => seen.push(`opened:${e.strategy}`));
+ s = await t.capture.open({ mode: 'camera' });
+ assert.strictEqual(s.getVideoTracks().length + s.getAudioTracks().length, 2);
+ same(seen, ['failed:preferred', 'opened:separately']);
+
+ // A refusal is the person's answer: no second way of asking.
+ t = load({ gum: async () => { throw new FakeDOMException('denied', 'NotAllowedError'); } });
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotAllowedError');
+ assert.strictEqual(t.calls.length, 1, 'stopped after the refusal');
+
+ // Several failures: the one that says most about what to do is the one reported.
+ t = load({ gum: async (c, n) => { throw new FakeDOMException('x', n === 1 ? 'NotFoundError' : 'NotReadableError'); } });
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotReadableError');
+
+ // A strategy that never answers is timed out, and the next one runs.
+ t = load({ gum: async (c, n) => (n === 1 ? new Promise(() => {}) : both(c)) });
+ t.capture.use('timeoutMs', 30);
+ s = await t.capture.open({ mode: 'camera' });
+ assert.ok(s.getVideoTracks().length, 'opened after the timed-out strategy');
+
+ // Asked for a camera and given none: not opened, and its tracks are stopped.
+ const lone = new FakeTrack('audio');
+ t = load({ gum: async () => new FakeStream([lone]) });
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotFoundError');
+ assert.ok(lone.stopped, 'the half-open stream was stopped');
+
+ // ── Parts and strategies ──
+ t = load({ gum: async (c) => both(c) });
+ const before = t.capture.use('constraints', (o) => ({ ...before(o), audio: { echoCancellation: false } }));
+ await t.capture.open({ mode: 'camera' });
+ same(t.calls[0].audio, { echoCancellation: false });
+ assert.throws(() => t.capture.part('nope'), /no capture part "nope"/);
+ for (const p of ['constraints', 'getUserMedia', 'getDisplayMedia', 'enumerate', 'meter', 'network', 'still', 'recorder', 'mimeTypes', 'store', 'portable', 'explain', 'timeoutMs']) {
+ assert.ok(t.capture.parts().includes(p), `the ${p} part can be replaced`);
+ }
+ t = load({ gum: async (c) => both(c) });
+ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
+ let ran = '';
+ t.capture.strategies.add({ name: 'mine', open: async () => { ran = 'mine'; return both({ video: true, audio: true }); } }, { before: 'preferred' });
+ same(t.capture.strategies.list().map((x) => x.name), ['mine', 'preferred', 'separately', 'basic']);
+ await t.capture.open({ mode: 'camera' });
+ assert.strictEqual(ran, 'mine');
+ t.capture.strategies.remove('mine');
+ t.capture.strategies.add({ name: 'last', open: async () => both({ video: true, audio: true }) }, { after: 'basic' });
+ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic', 'last']);
+ t.capture.strategies.reset();
+ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
+ assert.throws(() => t.capture.strategies.add({ name: 'x' }), /a strategy is/);
+
+ // ── The screen ──
+ t = load({ gum: async (c) => both(c), display: async (c) => both({ video: true, audio: c.audio }) });
+ s = await t.capture.screen({ frameRate: 15 });
+ assert.strictEqual(s.getVideoTracks().length, 1);
+ assert.strictEqual(t.calls[0].video.frameRate.ideal, 15);
+ same(t.capture.can(), { camera: true, screen: true, record: true, secure: true });
+ t = load({ gum: async (c) => both(c) });
+ await assert.rejects(t.capture.screen(), (e) => e.name === 'NoApi');
+ assert.strictEqual(t.capture.can().screen, false, 'a page can hide "share your screen" where it cannot work');
+
+ // ── A photo, through the replaceable still part ──
+ t = load({ gum: async (c) => both(c) });
+ let asked = null;
+ t.capture.use('still', async (stream, o) => { asked = o; return new FakeBlob(['jpg'], { type: o.type || 'image/jpeg' }); });
+ const photo = await t.capture.photo(await t.capture.open({ mode: 'camera_only' }), { mirror: true });
+ assert.strictEqual(photo.type, 'image/jpeg');
+ assert.strictEqual(asked.mirror, true);
+ // The real still part says so plainly when there is no picture to take.
+ t = load({ gum: async (c) => both(c) });
+ await assert.rejects(t.capture.photo(new FakeStream([new FakeTrack('audio')])), (e) => e.name === 'NotFoundError');
+
+ // ── A recording: the first supported format, events, a stop that gives a Blob ──
+ t = load({ gum: async (c) => both(c) });
+ const got = [];
+ t.capture.on('recorded', (e) => got.push(e.blob.type));
+ const rec = t.capture.record(await t.capture.open({ mode: 'camera' }), { maxSeconds: 5 });
+ assert.strictEqual(rec.mimeType, 'video/webm;codecs=vp9,opus', 'mp4 is not supported by the fake browser: the first webm is used');
+ const clip = await rec.stop();
+ assert.strictEqual(clip.type, 'video/webm');
+ same(got, ['video/webm']);
+ assert.strictEqual(await rec.stop(), clip, 'stop twice gives the same clip');
+ // Cancelled: nothing comes back.
+ const rec2 = t.capture.record(new FakeStream([new FakeTrack('audio')]));
+ assert.strictEqual(rec2.mimeType, 'audio/webm;codecs=opus', 'an audio-only stream records audio');
+ rec2.cancel();
+
+ // ── Settings: this device, then the person ──
+ t = load({ gum: async (c) => both(c) });
+ let saved = null;
+ t.capture.on('settings', (e) => { saved = e.settings; });
+ t.capture.settings.save({ mirror: false, resolution: '1080', lens: 'cam-back-1' });
+ assert.strictEqual(t.capture.settings.load().resolution, '1080');
+ assert.strictEqual(saved.mirror, false);
+ assert.ok(t.store.get('ov_capture_settings').includes('1080'), 'kept on this device');
+ // A remote store (OpenVibe.Cam's camera settings, for example): its portable choices arrive; device ids stay here.
+ const remote = { v: { mirror: true, resolution: '480', effects: 'warm', lens: 'someone-elses-camera' }, saves: [] };
+ const synced = [];
+ t.capture.on('synced', (e) => synced.push(e.settings));
+ const unsync = t.capture.settings.sync({ load: async () => remote.v, save: async (v) => { remote.saves.push(v); } });
+ await tick(); await tick();
+ const now = t.capture.settings.load();
+ assert.strictEqual(now.resolution, '480', 'the person\'s resolution followed them');
+ assert.strictEqual(now.effects, 'warm');
+ assert.strictEqual(now.lens, 'cam-back-1', 'a camera id from another device is never taken');
+ assert.strictEqual(synced.length, 1);
+ // A change here goes to the remote store, without device ids.
+ t.capture.settings.save({ mirror: false });
+ await tick();
+ assert.strictEqual(remote.saves.length, 1);
+ assert.strictEqual(remote.saves[0].mirror, false);
+ assert.ok(!('lens' in remote.saves[0]) && !('mic' in remote.saves[0]), 'device ids never leave the device');
+ // A device-only change is not sent.
+ t.capture.settings.save({ mic: 'usb-mic' });
+ await tick();
+ assert.strictEqual(remote.saves.length, 1);
+ // A remote that fails leaves this device working, and says so.
+ unsync();
+ let failed = null;
+ t.capture.on('sync-failed', (e) => { failed = e.error; });
+ t.capture.settings.sync({ load: async () => { throw new Error('offline'); }, save: async () => {} });
+ await tick(); await tick();
+ assert.ok(failed && /offline/.test(failed.message));
+ assert.strictEqual(t.capture.settings.load().mic, 'usb-mic');
+ assert.throws(() => t.capture.settings.sync({}), /settings\.sync takes/);
+ // A replaced store is used.
+ const mem = {};
+ t.capture.use('store', { load: () => mem.v || {}, save: (v) => { mem.v = v; } });
+ t.capture.settings.save({ lens: 'back-wide' });
+ assert.strictEqual(mem.v.lens, 'back-wide');
+
+ // ── Plain words ──
+ t = load({ gum: async (c) => both(c) });
+ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'camera').title, /said no to the camera and microphone/);
+ const found = t.capture.explain({ name: 'NotFoundError' }, 'camera', { instead: 'Or go live with your screen instead.' });
+ assert.strictEqual(found.steps[found.steps.length - 1], 'Or go live with your screen instead.', 'the site adds its own last step');
+ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'screen').title, /Screen sharing was stopped/);
+ assert.match(t.capture.explain({ name: 'NoApi' }, 'screen').steps[0], /Phones can't share their screen/);
+ assert.ok(!/openvibe\.live/.test(JSON.stringify(t.capture.explain({ name: 'NoApi' }, 'camera'))), 'no site\'s name in the shared words');
+ const mac = load({ gum: async (c) => both(c), ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15', extra: {} });
+ mac.ctx.navigator.maxTouchPoints = 0;
+ assert.match(mac.capture.explain({ name: 'NotAllowedError' }, 'screen').steps.join(' '), /Screen Recording/);
+ const inApp = load({ ua: 'Instagram 350 (iPhone)' });
+ await assert.rejects(inApp.capture.open({}), (e) => e.name === 'NoApi');
+ assert.match(inApp.capture.explain({ name: 'NoApi' }, 'camera').steps.join(' '), /inside another app/);
+
+ // ── One copy wins: an older or equal copy loaded later changes nothing ──
+ t = load({ gum: async (c) => both(c) });
+ const first = t.ctx.OVCapture;
+ vm.runInContext(source, t.ctx);
+ assert.strictEqual(t.ctx.OVCapture, first, 'a second copy of the same version keeps the first');
+ t.ctx.OVCapture = { version: 1 };
+ vm.runInContext(source, t.ctx);
+ assert.strictEqual(t.ctx.OVCapture.version, 2, 'a newer copy replaces an older one (Live\'s own, version 1)');
+
+ console.log('capture-core: strategies, parts, screen, photo, recording, settings that follow the person, plain words, one copy wins');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -27,7 +27,8 @@ window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1',
@@ -27,7 +27,8 @@ window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1',
tools: ['photo', 'file', 'emoji', 'poll', 'voice'],
tools: ['photo', 'file', 'emoji', 'poll', 'voice'],
audience: { name: 'audience', label: 'Who sees it', options: [['public', 'Everyone'], ['followers', 'Followers']], value: 'public' },
audience: { name: 'audience', label: 'Who sees it', options: [['public', 'Everyone'], ['followers', 'Followers']], value: 'public' },
onSubmit: async (v) => { window.got.push({ text: v.text, files: v.files.map((f) => f.name), audience: v.audience }); return window.keepNext ? false : true; } });
onSubmit: async (v) => { window.got.push({ text: v.text, files: v.files.map((f) => f.name), audience: v.audience }); return window.keepNext ? false : true; } });
-window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async () => true });
+window.chatSent = []; window.saved = [];
+window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async (v) => { window.chatSent.push({ text: v.text, reply: v.reply }); return true; } });
window.agent = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1', mode: { name: 'mode', label: 'Mode', options: [['balanced', 'Balanced'], ['best', 'Best']], value: 'balanced' },
window.agent = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1', mode: { name: 'mode', label: 'Mode', options: [['balanced', 'Balanced'], ['best', 'Best']], value: 'balanced' },
chips: [{ name: 'effort', label: 'Reasoning', icon: 'gauge', options: [['medium', 'Medium'], ['high', 'High']], value: 'high' }],
chips: [{ name: 'effort', label: 'Reasoning', icon: 'gauge', options: [['medium', 'Medium'], ['high', 'High']], value: 'high' }],
onSubmit: async (v) => { window.got.push({ mode: v.mode, effort: v.chips.effort }); } });
onSubmit: async (v) => { window.got.push({ mode: v.mode, effort: v.chips.effort }); } });
@@ -104,10 +105,33 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -104,10 +105,33 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
await page.evaluate(`agent.setRunning(false)`);
await page.evaluate(`agent.setRunning(false)`);
assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Send');
assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Send');
+ // Editing your message in the box (openvibe.design rule 138): a bar says so, the send is Save, Enter saves
+ // through onSave (not onSubmit), Esc puts back what was in the box.
+ await page.evaluate(`(() => { const t = chat.text; t.value = 'half a draft'; t.dispatchEvent(new Event('input', { bubbles: true })); chat.edit({ text: 'Yo', onSave: async (x) => { window.saved.push(x); } }); })()`);
+ const ed = await page.evaluate(`(() => { const f = document.getElementById('c1'); return { bar: (f.querySelector('.ovc-bar b') || {}).textContent, text: f.querySelector('textarea').value, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), editing: chat.editing(), first: f.querySelector('.ovc-body').firstElementChild.className }; })()`);
+ assert.deepEqual(ed, { bar: 'Editing your message', text: 'Yo', send: 'Save', editing: true, first: 'ovc-bar' });
+ await page.evaluate(`(() => { const t = chat.text; t.value += ' there'; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);
+ await until(`window.saved.length === 1`);
+ assert.deepEqual(await page.evaluate(`window.saved`), ['Yo there'], 'Save sends the edit to onSave');
+ assert.equal(await page.evaluate(`window.chatSent.length`), 0, 'an edit is not a new message');
+ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, send: document.querySelector('#c1 [data-ovt-send]').getAttribute('aria-label') })`),
+ { bar: false, text: 'half a draft', send: 'Send' }, 'after saving: the draft is back and the send is Send');
+ await page.evaluate(`chat.edit({ text: 'Yo', onSave: async () => {} })`);
+ await page.evaluate(`chat.text.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))`);
+ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, editing: chat.editing() })`), { bar: false, text: 'half a draft', editing: false }, 'Esc cancels');
+ // Replying: a bar names who, and the send carries the reply.
+ await page.evaluate(`chat.reply({ name: 'goosely', text: 'Yo', data: { id: 7 } })`);
+ assert.equal(await page.evaluate(`document.querySelector('#c1 .ovc-bar b').textContent`), 'Replying to goosely');
+ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);
+ await until(`window.chatSent.length === 1`);
+ assert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });
+ assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');
+
// Nothing is wider than the phone.
// Nothing is wider than the phone.
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
await page.close();
await page.close();
- console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, 390 px');
+ console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, editing and replying bars, 390 px');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -17,4 +17,31 @@ assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https:
@@ -17,4 +17,31 @@ assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https:
assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');
assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');
render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });
render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });
assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');
assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');
-console.log('messages: room and bubbles, groups, dates, links and profile picture ok');
+// Actions (openvibe.design rule 138): one cluster per message at its corner; edit and delete only on your own;
+// "edited" is one word after the words.
+const pressed = [];
+render(root, { view: 'room', me: 1, actions: ['reply', 'edit', 'delete'], onAction: (id, m) => pressed.push(`${id}:${m.id}`), messages: [
+ { id: 5, sender_id: 2, username: 'bob', message: 'theirs' },
+ { id: 6, sender_id: 1, username: 'alice', message: 'mine', edited_at: '2026-11-12 02:49:00' },
+] });
+const rows = root.querySelectorAll('.ovm-room');
+assert.strictEqual(rows[0].querySelectorAll('.ovm-actions .ovm-act').length, 1, 'someone else\'s message: reply only');
+assert.deepStrictEqual([...rows[1].querySelectorAll('.ovm-act')].map((b) => b.dataset.action), ['reply', 'edit', 'delete']);
+assert.strictEqual(rows[1].querySelectorAll('.ovm-actions').length, 1, 'one cluster');
+assert.ok(!rows[1].querySelector('.ovm-room-text .ovm-act'), 'no action inside the words');
+assert.strictEqual(rows[1].querySelector('.ovm-room-text').nextElementSibling.className, 'ovm-edited', '"edited" right after the words');
+assert.strictEqual(rows[1].querySelector('.ovm-edited').textContent, 'edited');
+assert.ok(/^Edited/.test(rows[1].querySelector('.ovm-edited').title));
+rows[1].querySelector('.ovm-act-edit').dispatchEvent(new window.Event('click', { bubbles: true }));
+assert.deepStrictEqual(pressed, ['edit:6']);
+assert.ok(rows[1].querySelector('.ovm-act-edit').getAttribute('aria-label'), 'each action is labelled');
+// A site's own row gets the same cluster.
+const own = document.createElement('div'); own.className = 'chat-msg'; root.appendChild(own);
+const { actions } = require('../messages');
+assert.strictEqual(actions(own, ['reply', 'edit'], { mine: false }).children.length, 1);
+assert.strictEqual(actions(own, ['edit'], { mine: false }), null, 'nothing to offer: no cluster');
+// The CSS: absolute, hidden until asked for, thumb-sized without hover.
+const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'messages.css'), 'utf8');
+assert.match(css, /\.ovm-actions \{ position: absolute;[^}]*display: none;/);
+assert.match(css, /@media \(hover: none\) \{[\s\S]{0,300}\.ovm-act \{ width: 44px; height: 40px;/);
+console.log('messages: room and bubbles, groups, dates, links and profile picture ok; actions in one cluster; edited after the words');