openvibers/openvibe.shared Commits

openvibe-shared 4.217.0: a camera in the composer, Try again on a failed upload, pictures made smaller before they go (#491)

OP OpenVibers committed 1 days ago · 2cdf550ac4 · 13 files, +278 −25

The composer gets what the agents portal's own box had: a camera, Try again, and pictures made smaller before they go.

- **A camera in the box** (`camera`, in the post and agent presets; any site can name it): a sheet over the page, the
  whole screen on a phone, with the picture, Photo or Video, the shutter, Switch camera and Close, as a phone's camera
  app has. A photo or a clip (up to a minute) goes into the box as a file, nothing kept or sent before the message is.
  It opens through the shared capture core (`ov-capture.js`, loaded from beside the composer the first time), so its
  help when a camera does not open is the same plain help Live's go-live page gives; the tool is not drawn where the
  browser has no camera.
- **Try again on a failed upload**: the file's own picture (or icon) becomes the button, and a post's picture tile says
  "Try again" across it.
- **Pictures made smaller before they upload**: `resize` (the long side in pixels; `data-ovc-resize` on a box made by
  hand): 2560 for a post, a chat or a reply, 1568 for an agent (the size a model reads, as the agents portal sent them).
  JPEG, PNG and WebP only; a smaller file is never swapped for a bigger one; GIFs and SVGs are left as they are.
- **Fixed:** in the one-line chat box, the "Editing your message" and "Replying to …" bar is a line of its own above the
  words (4.216.0 drew it in the box's first column, over the words).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c1c42f0..26d73bc 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 4.217.0 — 2026-11-12
++
++The composer gets what the agents portal's own box had: a camera, Try again, and pictures made smaller before they go.
++
++- **A camera in the box** (`camera`, in the post and agent presets; any site can name it): a sheet over the page, the
++ whole screen on a phone, with the picture, Photo or Video, the shutter, Switch camera and Close, as a phone's camera
++ app has. A photo or a clip (up to a minute) goes into the box as a file, nothing kept or sent before the message is.
++ It opens through the shared capture core (`ov-capture.js`, loaded from beside the composer the first time), so its
++ help when a camera does not open is the same plain help Live's go-live page gives; the tool is not drawn where the
++ browser has no camera.
++- **Try again on a failed upload**: the file's own picture (or icon) becomes the button, and a post's picture tile says
++ "Try again" across it.
++- **Pictures made smaller before they upload**: `resize` (the long side in pixels; `data-ovc-resize` on a box made by
++ hand): 2560 for a post, a chat or a reply, 1568 for an agent (the size a model reads, as the agents portal sent them).
++ JPEG, PNG and WebP only; a smaller file is never swapped for a bigger one; GIFs and SVGs are left as they are.
++- **Fixed:** in the one-line chat box, the "Editing your message" and "Replying to …" bar is a line of its own above the
++ words (4.216.0 drew it in the box's first column, over the words).
++
## 4.216.0 — 2026-11-12
One capture core for every camera, and a chat message's actions in one place on every site.
ddiff --git a/README.md b/README.md
iindex bed2f50..0927568 100644
---- a/README.md
++++ b/README.md
@@@ -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.216.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.217.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 6f79a78..afb1cc4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.216.0",
++ "version": "4.217.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)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex fe1b0be..5720052 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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': '96e59dd5193b' };
++ const VIBE_REV = { 'ov-composer.js': '3ddb2aa1ca88' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex c19e810..d91de5d 100644
---- a/dock.js
++++ b/dock.js
@@@ -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': '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' };
++ const DOCK_REV = { 'dock-vibe.js': 'f8fca6720b89', '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);
ddiff --git a/navbar.js b/navbar.js
iindex 0c89424..047f853 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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': '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 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': '19f9b0805772', '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.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex d8ea422..aa93b02 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -151,6 +151,33 @@ 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-cam { padding: 0; border: 0; border-radius: 22px; width: min(560px, 96vw); max-height: 94vh; overflow: hidden; background: #05070c; color: #fff; display: flex; flex-direction: column; }
++.ovc-cam::backdrop { background: rgba(2, 6, 14, .72); }
++.ovc-cam-stage { position: relative; flex: 1; min-height: 0; background: #000; }
++.ovc-cam-v { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; background: #000; }
++.ovc-cam.is-mirror .ovc-cam-v { transform: scaleX(-1); }
++.ovc-cam-msg { position: absolute; inset: auto 14px 14px; margin: 0; padding: 10px 12px; border-radius: 12px; background: rgba(0, 0, 0, .72); font-size: 14px; line-height: 1.4; }
++.ovc-cam-t { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); padding: 3px 10px; border-radius: 999px; background: #dc2626; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
++.ovc-cam-modes { display: flex; justify-content: center; gap: 4px; padding: 10px 0 0; }
++.ovc-cam-modes button { min-height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: none; color: rgba(255, 255, 255, .7); font: 600 13px/1 inherit; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
++.ovc-cam-modes [aria-pressed="true"] { color: #fde047; }
++.ovc-cam-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 22px max(18px, env(safe-area-inset-bottom)); }
++.ovc-cam-b { display: grid; place-items: center; width: 48px; height: 48px; border: 0; border-radius: 50%; background: rgba(255, 255, 255, .14); color: #fff; cursor: pointer; }
++.ovc-cam-b svg { width: 22px; height: 22px; }
++.ovc-cam-shutter { width: 72px; height: 72px; border-radius: 50%; border: 4px solid #fff; background: #fff; box-shadow: inset 0 0 0 4px #05070c; cursor: pointer; transition: transform var(--ov-dur-1, .12s) ease, background-color var(--ov-dur-2, .2s) ease; }
++.ovc-cam-shutter:active { transform: scale(.94); }
++.ovc-cam.is-video .ovc-cam-shutter { background: #dc2626; }
++.ovc-cam.is-recording .ovc-cam-shutter { background: #dc2626; box-shadow: inset 0 0 0 20px #05070c; }
++.ovc-cam.is-recording .ovc-cam-modes, .ovc-cam.is-recording [data-a="flip"] { visibility: hidden; }
++@media (max-width: 600px) { .ovc-cam { width: 100vw; max-width: none; height: 100dvh; max-height: none; border-radius: 0; } .ovc-cam-v { height: 100%; aspect-ratio: auto; } }
++html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
++/* A failed upload: its own picture (or icon) becomes the Try again button; a post's tile says it in words. */
++.ovc-file-r { position: absolute; left: 5px; top: 5px; width: 40px; height: 40px; display: grid; grid-auto-flow: column; place-content: center; align-items: center; gap: 6px; padding: 0; border: 0; border-radius: 10px; background: rgba(5, 7, 12, .66); color: #fff; font: 700 13px/1 inherit; cursor: pointer; }
++.ovc-file-r svg { width: 18px; height: 18px; }
++.ovc-file-r:hover, .ovc-file-r:focus-visible { background: rgba(5, 7, 12, .8); }
++.ovc-file-r-t { display: none; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r { left: 0; top: 0; width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r-t { display: inline; }
.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); }
@@@ -173,7 +200,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
/* chat: one line that grows, the tools beside it, the send round. */
.ovc-v2[data-preset=chat].ovt-composer { padding: 6px 6px 6px 10px; border-radius: 26px; }
.ovc-v2[data-preset=chat] .ovc-body { grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 4px; }
--.ovc-v2[data-preset=chat] .ovc-files { grid-column: 1 / -1; }
++.ovc-v2[data-preset=chat] .ovc-files, .ovc-v2[data-preset=chat] .ovc-bar { grid-column: 1 / -1; } /* a line of their own above the one-line box */
.ovc-v2[data-preset=chat] textarea { font-size: 16px; padding: 9px 2px; }
.ovc-v2[data-preset=chat] .ovt-c-row { padding: 0; border: 0; }
.ovc-v2[data-preset=chat] .ovt-c-tools { flex: none; }
@@@ -196,7 +223,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// from beside this file; without it, a plain <dialog>.
-- const COMPOSER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
++ const COMPOSER_REV = { 'ov-window.js': 'd3d8f9e3e6d2', 'ov-capture.js': '91f3b2cb25e3' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
@@@ -209,6 +236,34 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
document.head.appendChild(sc);
});
}
++ // 4.217.0: the camera button opens the shared capture core (ov-capture.js, OVCapture), loaded from beside this file.
++ function needCapture() {
++ return new Promise((ok) => {
++ if (root.OVCapture && root.OVCapture.version >= 2) return ok(root.OVCapture);
++ const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ if (!m) return ok(root.OVCapture || null);
++ const sc = document.createElement('script');
++ sc.src = m[1] + 'ov-capture.js' + (COMPOSER_REV['ov-capture.js'] ? '?v=' + COMPOSER_REV['ov-capture.js'] : '');
++ sc.onload = () => ok(root.OVCapture || null); sc.onerror = () => ok(null);
++ document.head.appendChild(sc);
++ });
++ }
++ // 4.217.0: a picture bigger than the box wants (data-ovc-resize, the long side in pixels; mount(): 2560, an agent's
++ // 1568 as the agents portal sent them) is made smaller here before it uploads. GIFs and SVGs are left alone.
++ async function shrink(file, max) {
++ if (!max || !/^image\/(jpeg|png|webp)$/.test(file.type || '') || typeof createImageBitmap !== 'function') return file;
++ let bmp = null;
++ try { bmp = await createImageBitmap(file); } catch { return file; }
++ const long = Math.max(bmp.width, bmp.height);
++ if (long <= max) { if (bmp.close) bmp.close(); return file; }
++ const k = max / long, w = Math.round(bmp.width * k), h = Math.round(bmp.height * k);
++ const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
++ canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);
++ if (bmp.close) bmp.close();
++ const blob = await new Promise((r) => canvas.toBlob(r, file.type, 0.9));
++ if (!blob || blob.size >= file.size) return file;
++ try { return new File([blob], file.name, { type: file.type, lastModified: file.lastModified }); } catch { return file; }
++ }
const live = new Set();
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
@@@ -235,6 +290,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
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',
++ camera: 'M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM12 10a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z',
++ flip: 'M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4',
++ retry: 'M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5',
};
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; };
@@@ -348,6 +406,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const thumb = f.url && kind === 'image' ? `<img class="ovc-file-th" src="${f.url}" alt="">` : f.url && kind === 'video' ? `<video class="ovc-file-th" src="${f.url}" muted preload="metadata"></video>` : `<span class="ovc-file-th ovc-file-ic">${f.pasted ? svg(IC.file) : ext}</span>`;
chip.innerHTML = `${thumb}<span class="ovc-file-t"><span class="ovc-file-n"></span><span class="ovc-file-s"></span></span>`
+ (f.pasted ? `<button type="button" class="ovc-file-b" data-ovc-back aria-label="Put the text back in the box" title="Put it back in the box">${svg(IC.back)}</button>` : '')
++ + (f.error && uploader() ? `<button type="button" class="ovc-file-r" data-ovc-retry aria-label="Try the upload again" title="Try again">${svg(IC.retry)}<span class="ovc-file-r-t">Try again</span></button>` : '')
+ `<button type="button" class="ovc-file-x" data-ovc-x aria-label="Remove">${svg(IC.x)}</button>`;
chip.querySelector('.ovc-file-n').textContent = f.pasted ? 'Pasted text' : f.file.name || 'File';
chip.querySelector('.ovc-file-s').textContent = f.error || (f.pasted ? `${f.lines} lines · ${size(f.file.size)}` : f.progress != null && f.progress < 1 ? `${Math.round(f.progress * 100)}%` : size(f.file.size));
@@@ -376,26 +435,37 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
files.push(f);
paintFile(f);
form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
-- if (up) {
-- f.progress = 0; paintFile(f);
-- Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
-- .then((r) => {
-- if (!files.includes(f)) return;
-- f.progress = 1; f.id = r && r.id;
-- if (f.id) { f.hidden = el('input'); f.hidden.type = 'hidden'; f.hidden.name = form.getAttribute('data-ovc-ids') || 'attachments'; f.hidden.value = f.id; form.appendChild(f.hidden); }
-- paintFile(f); syncSend();
-- }, (e) => { f.error = (e && e.message) || 'Did not upload'; paintFile(f); syncSend(); });
++ const resize = Number(form.getAttribute('data-ovc-resize')) || 0;
++ if (up || resize) {
++ if (up) { f.progress = 0; paintFile(f); }
++ shrink(file, resize).then((small) => {
++ if (!files.includes(f)) return;
++ if (small !== f.file) { f.file = small; syncInputs(); paintFile(f); }
++ if (up) sendUp(f);
++ });
}
}
strip.hidden = !files.length;
syncInputs();
syncSend();
}
++ function sendUp(f) {
++ const up = uploader(); if (!up) return;
++ f.error = null; f.progress = 0; paintFile(f); syncSend();
++ Promise.resolve(up(f.file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
++ .then((r) => {
++ if (!files.includes(f)) return;
++ f.progress = 1; f.id = r && r.id;
++ if (f.id) { f.hidden = el('input'); f.hidden.type = 'hidden'; f.hidden.name = form.getAttribute('data-ovc-ids') || 'attachments'; f.hidden.value = f.id; form.appendChild(f.hidden); }
++ paintFile(f); syncSend();
++ }, (e) => { if (!files.includes(f)) return; f.error = (e && e.message) || 'Did not upload'; paintFile(f); syncSend(); });
++ }
strip.addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('.ovc-file'); if (!chip) return;
const f = files.find((x) => x.chip === chip); if (!f) return;
if (e.target.closest('[data-ovc-x]')) { remove(f); text.focus(); }
else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
++ else if (e.target.closest('[data-ovc-retry]')) sendUp(f);
});
form.addEventListener('change', (e) => {
const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
@@@ -656,10 +726,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// 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 = {
-- post: { tools: ['photo', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true },
-- chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false },
-- agent: { tools: ['attach', 'voice'], send: '', avatar: false, rows: 2, max: 16000, ring: false },
-- comment: { tools: ['photo', 'emoji'], send: 'Reply', avatar: true, rows: 1, max: 2000, ring: true },
++ post: { tools: ['photo', 'camera', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true, resize: 2560 },
++ chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false, resize: 2560 },
++ agent: { tools: ['attach', 'camera', 'voice'], send: '', avatar: false, rows: 2, max: 16000, ring: false, resize: 1568 },
++ comment: { tools: ['photo', 'emoji'], send: 'Reply', avatar: true, rows: 1, max: 2000, ring: true, resize: 2560 },
};
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const TOOL = {
@@@ -684,7 +754,73 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
},
},
voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
++ camera: { label: 'Camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button) },
};
++ // The camera (4.217.0): a photo or a clip straight into the box, through the shared capture core. A sheet over the
++ // page (the whole screen on a phone): the picture, Photo or Video, the shutter, flip and close. Nothing is kept or
++ // sent until the person sends the message.
++ async function openCamera(ctx, button) {
++ const C = await needCapture();
++ if (!C) { ctx.note('The camera could not start here.'); return; }
++ styles();
++ const dlg = el('dialog', 'ovc-cam'); dlg.setAttribute('aria-label', 'Camera');
++ dlg.innerHTML = `<div class="ovc-cam-stage"><video class="ovc-cam-v" playsinline muted autoplay></video><p class="ovc-cam-msg" hidden></p><span class="ovc-cam-t" hidden>0:00</span></div>
++<div class="ovc-cam-modes" role="group" aria-label="Photo or video"><button type="button" data-m="photo" aria-pressed="true">Photo</button><button type="button" data-m="video" aria-pressed="false">Video</button></div>
++<div class="ovc-cam-bar"><button type="button" class="ovc-cam-b" data-a="close" aria-label="Close the camera">${svg(IC.x)}</button><button type="button" class="ovc-cam-shutter" data-a="shoot" aria-label="Take a photo"></button><button type="button" class="ovc-cam-b" data-a="flip" aria-label="Switch camera">${svg(IC.flip)}</button></div>`;
++ document.body.appendChild(dlg);
++ const video = dlg.querySelector('video'), msg = dlg.querySelector('.ovc-cam-msg'), timer = dlg.querySelector('.ovc-cam-t'), shoot = dlg.querySelector('[data-a="shoot"]');
++ let stream = null, facing = (C.settings.load().facing) || 'user', mode = 'photo', rec = null, tick = 0;
++ const stamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
++ const stop = () => { if (rec) { rec.cancel(); rec = null; } clearInterval(tick); C.stop(stream); stream = null; };
++ const close = () => { stop(); try { dlg.close(); } catch { /* closed */ } dlg.remove(); if (button) button.focus(); };
++ async function start() {
++ C.stop(stream); stream = null; msg.hidden = true;
++ try {
++ stream = await C.open({ mode: mode === 'video' ? 'camera' : 'camera_only', facing });
++ video.srcObject = stream; dlg.classList.toggle('is-mirror', facing === 'user');
++ } catch (err) {
++ const fix = C.explain(err, mode === 'video' ? 'camera' : 'camera_only');
++ msg.textContent = `${fix.title}. ${fix.steps.join(' ')}`; msg.hidden = false;
++ }
++ }
++ dlg.addEventListener('click', async (e) => {
++ const b = e.target.closest && e.target.closest('button'); if (!b) return;
++ if (b.dataset.m && !rec) {
++ mode = b.dataset.m;
++ dlg.querySelectorAll('[data-m]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
++ dlg.classList.toggle('is-video', mode === 'video');
++ shoot.setAttribute('aria-label', mode === 'video' ? 'Record' : 'Take a photo');
++ start();
++ } else if (b.dataset.a === 'close') close();
++ else if (b.dataset.a === 'flip' && !rec) { facing = facing === 'user' ? 'environment' : 'user'; C.settings.save({ facing }); start(); }
++ else if (b.dataset.a === 'shoot' && stream) {
++ if (mode === 'photo') {
++ try {
++ const blob = await C.photo(stream, { mirror: facing === 'user' });
++ ctx.add([new File([blob], `photo-${stamp()}.jpg`, { type: blob.type || 'image/jpeg' })]);
++ close();
++ } catch (err) { msg.textContent = 'The photo did not take. Try again.'; msg.hidden = false; }
++ } else if (!rec) {
++ rec = C.record(stream, { maxSeconds: 60 });
++ dlg.classList.add('is-recording'); shoot.setAttribute('aria-label', 'Stop recording');
++ timer.hidden = false;
++ tick = setInterval(() => { const t = Math.floor(rec ? rec.seconds() : 0); timer.textContent = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; }, 250);
++ } else {
++ const r = rec; rec = null; clearInterval(tick);
++ dlg.classList.remove('is-recording');
++ const blob = await r.stop();
++ if (blob) {
++ const ext = /mp4/.test(blob.type) ? 'mp4' : /webm/.test(blob.type) ? 'webm' : 'video';
++ ctx.add([new File([blob], `clip-${stamp()}.${ext}`, { type: blob.type || 'video/webm' })]);
++ }
++ close();
++ }
++ }
++ });
++ dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
++ if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
++ start();
++ }
function tool(id, spec) {
if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
@@@ -702,6 +838,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
form.setAttribute('data-ovc-max-files', String(o.maxFiles || 10));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
++ const resize = o.resize != null ? Number(o.resize) || 0 : p.resize || 0;
++ if (resize) form.setAttribute('data-ovc-resize', String(resize));
const want = (o.tools || p.tools).filter((t) => TOOL[t]);
if (!want.includes('voice')) form.setAttribute('data-ovc-no-voice', '');
// A post or a reply opens with one line: the person's picture (or first letter), their name and who sees it.
@@@ -726,6 +864,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
for (const id of want) {
const t = TOOL[id];
if (t.builtin) continue;
++ if (id === 'camera' && !(navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function')) continue;
if (typeof t.run !== 'function' && t.accept === undefined) continue;
const b = el(typeof t.run === 'function' ? 'button' : 'label', `ovt-btn ovc-t-${id}`, svg(t.path || IC.slash));
b.title = t.label; b.setAttribute('aria-label', t.label);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 21ae0b7..760017a 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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': '96e59dd5193b', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': '3ddb2aa1ca88', '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() {
ddiff --git a/package-lock.json b/package-lock.json
iindex e7bc680..2dcce07 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.216.0",
++ "version": "4.217.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.216.0",
++ "version": "4.217.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 4adb1f1..b12e769 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.216.0",
++ "version": "4.217.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex c9f1522..f101b79 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -30,7 +30,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'ov-arrange.js': { name: 'ARRANGE_CORE_REV', files: ['ov-arrange-core.js'] },
-- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.js'] } };
++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js', 'ov-capture.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
ddiff --git a/test/composer-camera-chrome.test.js b/test/composer-camera-chrome.test.js
nnew file mode 100644
iindex 0000000..98d0b70
---- /dev/null
++++ b/test/composer-camera-chrome.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++// The composer's camera, retry and resize (ov-composer.js 4.217.0) in a real headless Chrome at 390 px:
++// - an agent's box has the camera; it opens a sheet over the page through the shared capture core (ov-capture.js,
++// loaded from beside the composer), says what to do when no camera opens, and closes with nothing kept;
++// - a failed upload shows Try again on its chip, and a press uploads it again;
++// - a big picture is made smaller before it uploads (an agent's 1568 px on its long side, a post's 2560).
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-camera-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (['/ov-composer.js', '/ov-window.js', '/ov-capture.js'].includes(u)) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="a"></div><div id="b"></div>
++<script src="/ov-composer.js"></script>
++<script>
++window.sent = []; window.tries = 0;
++const C = OpenVibeComposer;
++// An upload that fails the first time and works the second; it records the picture's size as it arrived.
++const upload = async (file, progress) => {
++ window.tries++;
++ if (file.type.startsWith('image/')) { const b = await createImageBitmap(file); window.sent.push([b.width, b.height]); }
++ progress(0.5);
++ if (window.tries === 1) throw new Error('The network dropped');
++ return { id: 'att_' + window.tries };
++};
++window.agent = C.mount(document.getElementById('a'), { preset: 'agent', id: 'g1', upload, onSubmit: async () => true });
++window.post = C.mount(document.getElementById('b'), { preset: 'post', id: 'p1', user: { display_name: 'goosely' }, onSubmit: async () => true });
++window.bigPicture = async (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d'); g.fillStyle = '#3b82f6'; g.fillRect(0, 0, w, h); g.fillStyle = '#fff'; g.fillRect(w / 4, h / 4, w / 2, h / 2);
++ const blob = await new Promise((r) => c.toBlob(r, 'image/jpeg', 0.95)); return new File([blob], 'big.jpg', { type: 'image/jpeg' }); };
++</script>`);
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'composer-camera-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await page.goto(base + '/', { maxMs: 5000 });
++ const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(50); } return false; };
++ assert.ok(await until(`!!window.post`), 'the composers mount');
++
++ // The camera: an agent's box and a post have it.
++ const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')))`);
++ assert.deepEqual(tools[0].slice(0, 2), ['Attach', 'Camera'], 'an agent: attach and the camera');
++ assert.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');
++ assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
++ assert.equal(await page.evaluate(`document.getElementById('p1').getAttribute('data-ovc-resize')`), '2560');
++
++ // It opens a sheet through the shared core (loaded from beside the composer). Headless Chrome has no camera, so
++ // the sheet says what to do instead of failing silently; Close takes it away.
++ await page.evaluate(`document.querySelector('#g1 .ovc-t-camera').click()`);
++ assert.ok(await until(`!!document.querySelector('dialog.ovc-cam[open]')`), 'the camera sheet opens');
++ assert.ok(await until(`!!window.OVCapture && window.OVCapture.version >= 2`), 'the shared capture core loaded from beside the composer');
++ assert.deepEqual(await page.evaluate(`[...document.querySelectorAll('.ovc-cam [data-m]')].map((b) => b.textContent)`), ['Photo', 'Video']);
++ assert.ok(await until(`(() => { const m = document.querySelector('.ovc-cam-msg'); const v = document.querySelector('.ovc-cam-v'); return (m && !m.hidden && m.textContent.length > 10) || (v && v.srcObject); })()`), 'the picture, or plain words about the camera');
++ assert.equal(await page.evaluate(`document.querySelector('.ovc-cam').getBoundingClientRect().width`), 390, 'the whole screen on a phone');
++ await page.evaluate(`document.querySelector('.ovc-cam [data-a="close"]').click()`);
++ assert.ok(await until(`!document.querySelector('dialog.ovc-cam')`), 'Close takes it away');
++ assert.equal(await page.evaluate(`agent.files().length`), 0, 'and nothing was added');
++
++ // A big picture shrinks to the agent's 1568 px before it uploads; the first upload fails, Try again sends it.
++ await page.evaluate(`(async () => { agent.ctx.add([await bigPicture(4000, 3000)]); })()`);
++ assert.ok(await until(`!!document.querySelector('#g1 .ovc-file.is-bad [data-ovc-retry]')`), 'a failed upload offers Try again');
++ assert.deepEqual(await page.evaluate(`window.sent[0]`), [1568, 1176], 'the picture went at 1568 px on its long side');
++ await page.evaluate(`document.querySelector('#g1 [data-ovc-retry]').click()`);
++ assert.ok(await until(`agent.uploads().join() === 'att_2' && !document.querySelector('#g1 .ovc-file.is-bad')`), 'Try again uploaded it');
++ assert.equal(await page.evaluate(`document.querySelector('#g1 input[type=hidden][name=attachments]').value`), 'att_2', 'its id goes with the form');
++ // A small picture is left as it is.
++ await page.evaluate(`(async () => { agent.ctx.add([await bigPicture(800, 600)]); })()`);
++ assert.ok(await until(`window.sent.length === 3`));
++ assert.deepEqual(await page.evaluate(`window.sent[2]`), [800, 600], 'a picture under the limit is not touched');
++
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
++ await page.close();
++ console.log('composer-camera-chrome: the camera through the shared core, Try again on a failed upload, pictures made smaller before they go');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/composer-v2-chrome.test.js b/test/composer-v2-chrome.test.js
iindex 9fbece7..ea270ea 100644
---- a/test/composer-v2-chrome.test.js
++++ b/test/composer-v2-chrome.test.js
@@@ -110,6 +110,10 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
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' });
++ // In the one-line chat box the bar is a line of its own: the words keep the box's width under it.
++ const lay = await page.evaluate(`(() => { const f = document.getElementById('c1'), b = f.querySelector('.ovc-bar').getBoundingClientRect(), t = f.querySelector('textarea').getBoundingClientRect(), body = f.querySelector('.ovc-body').getBoundingClientRect();
++ return { barFull: Math.abs(b.width - body.width) < 2, below: t.top >= b.bottom - 1, left: Math.abs(t.left - body.left) < 4 }; })()`);
++ assert.deepEqual(lay, { barFull: true, below: true, left: true }, 'the editing bar spans the chat box and the words sit under it');
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`);