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)
Changes
@@@ -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-12One capture core for every camera, and a chat message's actions in one place on every site.@@@ -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 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.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)",@@@ -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 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': '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);@@@ -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.@@@ -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);@@@ -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() {@@@ -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",@@@ -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": {@@@ -30,7 +30,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ovconst 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)) {nnew file mode 100644@@@ -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); });@@@ -110,6 +110,10 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'rawait 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`);
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# 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
## 4.216.0 — 2026-11-12
One capture core for every camera, and a chat message's actions in one place on every site.
One capture core for every camera, and a chat message's actions in one place on every site.
@@ -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.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
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.216.0",
+ "version": "4.217.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': '96e59dd5193b' };
+ const VIBE_REV = { 'ov-composer.js': '3ddb2aa1ca88' };
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': '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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -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': '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]}` : ''}`;
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.
@@ -151,6 +151,33 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -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: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-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 { 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 > 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 > 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
@@ -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. */
/* 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].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-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] 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-row { padding: 0; border: 0; }
.ovc-v2[data-preset=chat] .ovt-c-tools { flex: none; }
.ovc-v2[data-preset=chat] .ovt-c-tools { flex: none; }
@@ -196,7 +223,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -196,7 +223,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
const mods = [];
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
// 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>.
// 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) || '';
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -209,6 +236,34 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -209,6 +236,34 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
document.head.appendChild(sc);
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 live = new Set();
const PREFS_KEY = 'ov:composer';
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
@@ -235,6 +290,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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',
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',
pen: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
check: 'M5 12.5l4.5 4.5L19 7',
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 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; };
@@ -348,6 +406,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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>`;
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>`
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.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>`;
+ `<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-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));
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; }
@@ -376,26 +435,37 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
files.push(f);
files.push(f);
paintFile(f);
paintFile(f);
form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
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;
strip.hidden = !files.length;
syncInputs();
syncInputs();
syncSend();
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) => {
strip.addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('.ovc-file'); if (!chip) return;
const chip = e.target.closest && e.target.closest('.ovc-file'); if (!chip) return;
const f = files.find((x) => x.chip === chip); if (!f) return;
const f = files.find((x) => x.chip === chip); if (!f) return;
if (e.target.closest('[data-ovc-x]')) { remove(f); text.focus(); }
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-back]')) { const t = f.text; remove(f); ctx.insert(t); }
+ else if (e.target.closest('[data-ovc-retry]')) sendUp(f);
});
});
form.addEventListener('change', (e) => {
form.addEventListener('change', (e) => {
const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
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; }
@@ -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
// 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 = {
- 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 EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const TOOL = {
const TOOL = {
@@ -684,7 +754,73 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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
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) {
function tool(id, spec) {
if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
@@ -702,6 +838,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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-files', String(o.maxFiles || 10));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
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]);
const want = (o.tools || p.tools).filter((t) => TOOL[t]);
if (!want.includes('voice')) form.setAttribute('data-ovc-no-voice', '');
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.
// 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; }
@@ -726,6 +864,7 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
for (const id of want) {
for (const id of want) {
const t = TOOL[id];
const t = TOOL[id];
if (t.builtin) continue;
if (t.builtin) continue;
+ if (id === 'camera' && !(navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function')) continue;
if (typeof t.run !== 'function' && t.accept === undefined) 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));
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);
b.title = t.label; b.setAttribute('aria-label', t.label);
@@ -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': '96e59dd5193b', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '3ddb2aa1ca88', '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.216.0",
+ "version": "4.217.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.216.0",
+ "version": "4.217.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.216.0",
+ "version": "4.217.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": {
@@ -30,7 +30,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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'] },
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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'ov-arrange.js': { name: 'ARRANGE_CORE_REV', files: ['ov-arrange-core.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(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
for (const [file, { name, files }] of Object.entries(OWN)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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); });
@@ -110,6 +110,10 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -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); } }); })()`);
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 }; })()`);
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' });
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(`(() => { 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 page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);
await until(`window.saved.length === 1`);
await until(`window.saved.length === 1`);