openvibers/openvibe.shared Commits

Shared 4.247.0: any video in a comment (plan §Z.9 step 3) (#533)

OP OpenVibers committed 7 h ago · 3fb219a4c7 · 17 files, +459 −23

* Shared 4.247.0: any video in a comment (plan §Z.9 step 3)

* Composer: mount({ media: true }) sends any box's files to Media, at Media's limits per kind, cancel on remove

* Composer: a picked video's chip shows its first frame and its length

* sibling revs: dock.js and navbar.js follow dock-vibe.js

* Menu: a guest sees what signing in adds, with the bar's own sign-in link (plan §Z.2)

* sibling revs after the rebase onto 4.246.0

* Tests: wait for a state, never a fixed sleep before a check

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a3bee9f..5455279 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,37 @@
# Changelog
++## 4.247.0 — 2026-11-13
++
++**Any video in a comment** (the owner, 11-13: "full support for our composer to support all video file types"; plan
++§Z.9 step 3).
++
++- **The comment box takes any video.** `thread.renderThread({ mediaAttachments: true })` draws a box that accepts any
++ video, picture, sound, PDF or text (MP4, MOV, MKV, AVI, WebM, 3GP and the rest; HEIC and AVIF pictures) and says
++ Media's limits: pictures 20 MB, videos 300 MB, sound 50 MB, other files 25 MB. Each file goes to Media's composer
++ attachments as it is picked (`POST https://openvibe.media/api/v1/attachments`, the viewer's own token; the chip shows
++ the progress), and the form carries the ids in `attachments`, never the bytes. Media sniffs what the file really is
++ and makes a web copy of a video no browser plays. Only on a post route that reads `attachments` (Community's, from
++ #439); a page without a viewer token keeps the plain upload. Off by default: nothing changes on a page that does not
++ ask.
++- **Any box can do it: `OpenVibeComposer.mount(host, { media: true })`** (composer v3's file queue, first module), or
++ `data-ovc-media` on a form. The core composer sends each file to Media as it is picked, with Media's limits per kind
++ from one table (pictures 20 MB, videos 300 MB, sound 50 MB, other files 25 MB; the type or the extension guides it, as
++ `.mkv` often has no type in a browser), says an oversize file's limit in words, and cancels an upload when its chip is
++ removed (a mod's `upload(file, onProgress, signal)` now gets an AbortSignal). A mod's own uploader still comes first.
++- **A guest sees what signing in adds** (plan §Z.2). The menu's You opens, for someone signed out, with one card: "Sign
++ in to make OpenVibe yours", four lines (your home screen on every site; your feed, messages and notifications; your
++ agents and the AI that comes with your account; your wallet and what you own) and a Sign in button whose link is the
++ bar's own, so it goes to this host's sign-in and comes back. A signed-in person never sees it
++ (`test/menu-guest-chrome.test.js`).
++- **A comment's video plays.** An attachment from Media (`att_…`) draws as the network's player (`video[data-ov-player]`,
++ Media's web copy, its first frame as the poster, its own shape and length); one whose copy is still being made shows
++ its first frame and a link to the file; a picture shows, a sound gets a bar. Nothing off `openvibe.media/a/` is drawn.
++ `ov-thread.js` loads `player.js` and `player.css` when a thread has a video (content-hashed in `THREAD_REV`).
++- Checks: `test/thread-media-attachments.test.js` (the box, every kind, URLs off Media refused) and
++ `test/comment-video-chrome.test.js` (a picked `.mkv` goes to Media with the viewer's token, the form sends its id and
++ no bytes, a 21 MB picture is refused in words, removing a chip mid-upload cancels it, no token keeps the plain upload,
++ the video plays in the player at 390 px).
++
## 4.246.0 — 2026-11-13
**The menu's close goes home** (the owner, 11-13: "the close button … goes into the users avatar drop-down spot rather
@@@ -17,6 +49,7 @@ than where it should").
`/api/frame/items/definitions`; every site allows openvibe.network). A page that sets `OpenVibeItemsConfig.api` still
reads Inventory directly.
++
## 4.245.0 — 2026-11-13
**The menu knows your rank** (the owner, 11-13: navigation "user aware and session aware and … aware of rank").
ddiff --git a/README.md b/README.md
iindex c7347da..0aa16d1 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.246.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.247.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 0dd60a0..fe565b0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.246.0",
++ "version": "4.247.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 3e1c8e4..a82959b 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,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': '5bd54df16c8f' };
++ const VIBE_REV = { 'ov-composer.js': '9b2a27f70f5f' };
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 a427918..6885db2 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,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': 'e41403255108', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': 'e1c7c07dca1f', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/navbar.js b/navbar.js
iindex 81b3353..fa0e1b1 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -624,7 +624,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'd301957bc5f3', 'dock.js': '8f26f280607f', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '5bd54df16c8f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': '0a397ea380cc', 'ov-share.css': 'd609a71f611a', 'search.js': 'dc2028f84893', 'dock.js': '0a657bac86b2', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'f2e7c61e0934', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': '9b2a27f70f5f', 'items.js': 'a38f01f90c40', 'items.css': 'e917ec14cd4d' };
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 95e5a33..510071e 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -25,7 +25,7 @@
* A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the
* ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
-- * data-ovc-max-mb, data-ovc-no-draft.
++ * data-ovc-max-mb, data-ovc-no-draft, data-ovc-media (4.247.0: the files go to Media's composer attachments).
*
* 4.241.0, the +: one sheet of what you can add. Big tiles first (Camera, Photos, Files), then rows (Paste, Screenshot,
* the phone's own camera app, Share to an app, Timer, GitHub, the mods' tools, what a narrow row hides). A phone gets
@@@ -606,6 +606,47 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
};
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`);
++ // 4.247.0 (plan §Z.9, composer v3's file queue): Media's composer attachments, where a box's files go when it says
++ // data-ovc-media (mount({ media: true })). One table of limits, Media's own (OpenVibe.Media docs/attachments.md);
++ // the bytes decide the kind there, the type or the extension guides it here. Each file goes as it is picked, with
++ // the viewer's own token, its chip shows the progress, × cancels it, and the form carries its id.
++ const MEDIA_URL = 'https://openvibe.media/api/v1/attachments';
++ const MEDIA_MB = { image: 20, video: 300, audio: 50, other: 25 };
++ const MEDIA_WORD = { image: 'Pictures', video: 'Videos', audio: 'Sound files', other: 'Files' };
++ const EXT = { video: /\.(mp4|m4v|mov|mkv|avi|webm|ogv|wmv|flv|mpe?g|3gp|3g2|ts|mts|m2ts|vob)$/i, audio: /\.(mp3|m4a|aac|wav|flac|oga|ogg|opus|wma|aiff?)$/i, image: /\.(jpe?g|png|gif|webp|bmp|heic|heif|avif|tiff?)$/i };
++ function mediaKind(file) {
++ const t = String((file && file.type) || '').toLowerCase(), n = String((file && file.name) || '');
++ const top = t.split('/')[0];
++ if (top === 'image' || top === 'video' || top === 'audio') return top;
++ for (const k of ['video', 'audio', 'image']) if (EXT[k].test(n)) return k;
++ return 'other';
++ }
++ function viewerToken() {
++ try { const N = root.OpenVibeNavbar; return N && typeof N.viewerToken === 'function' ? String(N.viewerToken() || '') : ''; } catch { return ''; }
++ }
++ function mediaUpload(file, onProgress, signal) {
++ return new Promise((resolve, reject) => {
++ const tok = viewerToken();
++ if (!tok) { reject(new Error('Sign in again to attach files.')); return; }
++ const fd = new FormData();
++ fd.append('file', file, file.name || 'file');
++ const x = new XMLHttpRequest();
++ x.open('POST', MEDIA_URL);
++ x.setRequestHeader('Authorization', `Bearer ${tok}`);
++ x.setRequestHeader('Accept', 'application/json');
++ if (x.upload && onProgress) x.upload.onprogress = (e) => { if (e.lengthComputable && e.total) onProgress(e.loaded / e.total); };
++ x.onload = () => {
++ let j = {};
++ try { j = JSON.parse(x.responseText || '{}'); } catch { j = {}; }
++ if (x.status >= 200 && x.status < 300 && j && /^att_/.test(String(j.id || ''))) { resolve({ id: j.id, attachment: j }); return; }
++ reject(new Error((j && (j.detail || j.title)) || (x.status === 413 ? 'That file is too large to attach.' : x.status === 415 ? 'That kind of file cannot be attached.' : 'The file did not upload.')));
++ };
++ x.onerror = () => reject(new Error('The file did not upload. Check the connection and try again.'));
++ if (signal) signal.addEventListener('abort', () => { try { x.abort(); } catch { /* gone */ } reject(Object.assign(new Error('Cancelled'), { cancelled: true })); }, { once: true });
++ x.send(fd);
++ });
++ }
++
// ── Your settings: this browser's copy, and your account's when the Network keeps them (the same on every site).
let prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {});
let synced = false, rev = null;
@@@ -683,7 +724,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const mine = () => mods.filter((m) => !m.match || m.match(form));
-- const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : null; };
++ const media = () => form.hasAttribute('data-ovc-media') && !!viewerToken();
++ const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : (media() ? mediaUpload : null); };
// data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).
const noFiles = form.hasAttribute('data-ovc-no-files');
const canFile = () => !noFiles && !!(inputs().length || uploader());
@@@ -740,14 +782,18 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
chip.classList.toggle('is-up', f.progress != null && f.progress < 1 && !f.error);
chip.style.setProperty('--p', String(f.progress == null ? 1 : f.progress));
const ext = ((f.file.name || '').split('.').pop() || 'file').slice(0, 4).toUpperCase();
-- 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}#t=0.1" muted playsinline 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>` : '')
+ (kind === 'image' && !f.pasted && captionOn() ? `<button type="button" class="ovc-file-ai${f.captioning ? ' is-busy' : ''}" data-ovc-caption aria-label="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}" title="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}"${f.captioning ? ' aria-busy="true"' : ''}>${svg(IC.spark)}</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));
++ // 4.247.0 (§Z.9): a video says its length beside its size once the browser has read it.
++ const length = f.duration > 0 ? `${Math.floor(f.duration / 60)}:${String(Math.round(f.duration % 60)).padStart(2, '0')} · ` : '';
++ 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)}%` : length + size(f.file.size));
++ const vid = !f.duration && chip.querySelector('video.ovc-file-th');
++ if (vid) vid.addEventListener('loadedmetadata', () => { if (Number.isFinite(vid.duration) && vid.duration > 0 && files.includes(f)) { f.duration = vid.duration; paintFile(f); } }, { once: true });
chip.querySelector('[data-ovc-x]').setAttribute('aria-label', `Remove ${f.pasted ? 'the pasted text' : f.file.name || 'the file'}`);
if (!chip.isConnected) strip.appendChild(chip);
}
@@@ -791,6 +837,7 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
function remove(f) {
const i = files.indexOf(f); if (i < 0) return;
files.splice(i, 1);
++ if (f.abort && f.progress != null && f.progress < 1) f.abort.abort();
if (f.url) URL.revokeObjectURL(f.url);
if (f.hidden) f.hidden.remove();
form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
@@@ -804,7 +851,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
for (const file of [...(list || [])]) {
if (files.length >= maxFiles) { say(`Up to ${maxFiles} files at a time.`); break; }
if (!accepts(file)) { say(`${file.name || 'That file'}: this box does not take that kind of file.`); continue; }
-- if (file.size > maxBytes) { say(`${file.name || 'That file'} is ${size(file.size)}: the limit is ${size(maxBytes)}.`); continue; }
++ if (media()) {
++ const k = mediaKind(file), cap = MEDIA_MB[k] * 1048576;
++ if (file.size > cap) { say(`${file.name || 'That file'} is ${size(file.size)}. ${MEDIA_WORD[k]} can be up to ${MEDIA_MB[k]} MB.`); continue; }
++ } else if (file.size > maxBytes) { say(`${file.name || 'That file'} is ${size(file.size)}: the limit is ${size(maxBytes)}.`); continue; }
const kind = (file.type || '').split('/')[0];
const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
files.push(f);
@@@ -827,13 +877,14 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
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(); }))
++ f.abort = typeof AbortController === 'function' ? new AbortController() : null;
++ Promise.resolve(up(f.file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }, f.abort ? f.abort.signal : undefined))
.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(); });
++ }, (e) => { if (!files.includes(f) || (e && e.cancelled)) 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;
@@@ -1224,6 +1275,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
// options: { preset: 'post' | 'chat' | 'agent' | 'comment', id, name ('text'), placeholder, label, maxLength,
// user: { display_name | username, avatar_url }, tools: ['photo', 'file', 'attach', 'emoji', 'voice'] (the
// preset's own when absent), accept, maxFiles, maxMb, required (true), noDraft,
++ // media (4.247.0: true sends each file to Media's composer attachments as it is picked, at Media's limits per kind,
++ // and the form carries the ids in `attachments`, or idsName; a page without a viewer token keeps the plain upload),
// audience / mode: { name, label, options: [[value, label]], value } (a chip each); chips: [{ name, label, icon
// ('globe' | 'spark' | 'cpu' | 'gauge'), options, value, onChange }] for any more (an agent's Model and Reasoning,
// as the agents portal's box has),
@@@ -1524,7 +1577,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
if (o.id) form.id = o.id;
form.setAttribute('data-ovc', '');
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 || (o.media ? 300 : 100)));
++ if (o.media) { form.setAttribute('data-ovc-media', '1'); form.setAttribute('data-ovc-ids', o.idsName || 'attachments'); }
if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
if (o.noPlus) form.setAttribute('data-ovc-no-more', '');
if (o.plus && typeof o.plus === 'object') form.dataset.ovcPlus = JSON.stringify(cleanPlus(o.plus));
ddiff --git a/ov-thread.js b/ov-thread.js
iindex a2863ff..1b14d9e 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': '5bd54df16c8f', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': '9b2a27f70f5f', 'ov-combo.js': '8001a3b19c2f', 'player.js': '8f4b6892e572', 'player.css': '699a7c4927d5' };
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() {
@@@ -37,6 +37,24 @@
s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
document.head.appendChild(s);
}
++ // 4.247.0 (§Z.9): a video in a comment plays in the network's one player (player.js on video[data-ov-player]).
++ function loadPlayer() {
++ if (root.OpenVibePlayer) { qa(document, 'video[data-ov-player]').forEach(function (v) { try { root.OpenVibePlayer.enhance(v); } catch (e) { /* the browser's own controls stay */ } }); return; }
++ if (loadPlayer.on || !document.querySelector('[data-ovc-thread] video[data-ov-player]')) return;
++ loadPlayer.on = true;
++ var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
++ var base = m ? m[1] : 'https://openvibe.network/shared/';
++ if (!document.querySelector('link[href*="player.css"]')) {
++ var l = document.createElement('link');
++ l.rel = 'stylesheet';
++ l.href = base + 'player.css' + (THREAD_REV['player.css'] ? '?v=' + THREAD_REV['player.css'] : '');
++ document.head.appendChild(l);
++ }
++ var s = document.createElement('script');
++ s.async = true;
++ s.src = base + 'player.js' + (THREAD_REV['player.js'] ? '?v=' + THREAD_REV['player.js'] : '');
++ document.head.appendChild(s);
++ }
function loadComposer(after) {
if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }
if (after) loadComposer.waiting.push(after);
@@@ -261,6 +279,7 @@
}
function start() {
loadCombo();
++ loadPlayer();
qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
if (box.dataset.ovtReady) return;
box.dataset.ovtReady = '1';
@@@ -353,6 +372,7 @@
var sendBtn = q(f, '[data-ovt-send]');
var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';
var preset = f.getAttribute('data-ovt-preset') || 'comment';
++ var media = f.hasAttribute('data-ovc-media');
var host = document.createElement('div');
f.replaceWith(host);
var c = null;
@@@ -360,7 +380,8 @@
c = root.OpenVibeComposer.mount(host, {
preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',
maxLength: Number(oldText.getAttribute('maxlength')) || 2000, submitLabel: label,
-- tools: file ? ['photo', 'emoji'] : ['emoji'], maxFiles: Number(f.getAttribute('data-ovc-max-files')) || 10,
++ tools: file ? (media ? ['photo', 'file', 'emoji'] : ['photo', 'emoji']) : ['emoji'], maxFiles: Number(f.getAttribute('data-ovc-max-files')) || 10,
++ media: media, maxMb: media ? 300 : undefined,
});
} catch (e) { c = null; }
if (!c) { host.replaceWith(f); return null; }
@@@ -371,6 +392,7 @@
qa(c.form, 'input[type=file]').forEach(function (i) { i.name = field; if (file.accept) i.accept = file.accept; });
}
hidden.forEach(function (h) { var i = document.createElement('input'); i.type = 'hidden'; i.name = h[0]; i.value = h[1]; c.form.appendChild(i); });
++ // 4.247.0 (§Z.9): mount({ media }) sends the box's files to Media as they are picked; the form carries their ids.
if (oldText.value) c.text.value = oldText.value;
if (note && note.textContent) c.note.textContent = note.textContent;
c.form.__ovtUpgraded = true;
ddiff --git a/package-lock.json b/package-lock.json
iindex 05cc840..6d0ffb6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.246.0",
++ "version": "4.247.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.246.0",
++ "version": "4.247.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 478289f..8d9f4c1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.246.0",
++ "version": "4.247.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 27cdbbf..fb9a593 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -35,7 +35,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-searchbox.js': { name: 'SEARCHBOX_REV', files: ['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'] } };
++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js', 'player.js', 'player.css'] }, 'ov-searchbox.js': { name: 'SEARCHBOX_REV', files: ['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/search.js b/search.js
iindex 50244be..fead88e 100644
---- a/search.js
++++ b/search.js
@@@ -491,6 +491,12 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.ovs-card { padding: 2px 6px 6px; }
++.ovs-guest { display: grid; gap: 8px; }
++.ovs-guest b { font-size: 15px; font-weight: 650; }
++.ovs-guest ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; color: var(--text-secondary, #a8b3c4); font-size: 13.5px; line-height: 1.4; }
++.ovs-guest li::before { content: ''; display: inline-block; width: 6px; height: 6px; margin: 0 8px 1px 2px; border-radius: 50%; background: var(--accent, #3b82f6); }
++.ovs-guest a.ovs-guest-go { justify-self: start; display: inline-flex; align-items: center; min-height: 44px; padding: 0 18px; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 650; text-decoration: none; }
++.ovs-guest a.ovs-guest-go:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovs-cardbody { border-radius: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); padding: 12px 14px; }
.ovs-displayrow { padding: 4px 8px 10px; }
.ovs-displayrow .ovnav-display { margin-top: 4px; }
@@@ -956,7 +962,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
items = [];
listEl.textContent = '';
if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
-- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'notifications'].includes(g.as)).forEach((g) => {
++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'signin', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'notifications'].includes(g.as)).forEach((g) => {
if (g.as === 'notifications') {
const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'notifications');
const host = document.createElement('div'); host.className = 'ovs-notifications-host'; li.appendChild(host);
@@@ -1010,6 +1016,21 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
listEl.appendChild(li);
return;
}
++ if (g.as === 'signin') {
++ const bar = document.getElementById('openvibe-login-btn');
++ const href = (bar && bar.href) || `https://openvibe.network/login?return=${encodeURIComponent(location.href)}`;
++ const li = document.createElement('li'); li.className = 'ovs-card'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'signin');
++ const body = document.createElement('div'); body.className = 'ovs-cardbody ovs-guest';
++ const head = document.createElement('b'); head.textContent = 'Sign in to make OpenVibe yours';
++ const list = document.createElement('ul');
++ for (const line of ['Your home screen, arranged your way, on every site', 'Your feed, messages and notifications', 'Your agents, and the AI that comes with your account', 'Your wallet, and what you own']) {
++ const item = document.createElement('li'); item.textContent = line; list.appendChild(item);
++ }
++ const go = document.createElement('a'); go.className = 'ovs-guest-go'; go.href = href; go.textContent = 'Sign in';
++ go.addEventListener('click', () => close());
++ body.append(head, list, go); li.appendChild(body); listEl.appendChild(li);
++ return;
++ }
if (g.as === 'display') {
const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');
try { ctx.display(li); } catch { return; }
@@@ -2051,6 +2072,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
const H = homeLib();
// With the home screen, Notifications is one of You's tabs (4.223.0); without it, the section stays at the top.
if (root.OpenVibeNotifications && ctx.token && !H) groups.push({ title: 'Notifications', as: 'notifications', items: [] });
++ // §Z.2 (4.247.0): a guest sees first what signing in adds, with the bar's own Sign in link.
++ if (!(ctx.token && ctx.user && !ctx.user.is_anon)) groups.push({ as: 'signin', items: [], section: 'signin' });
if (H) {
groups.push({ as: 'home', items: [], section: 'home' });
// Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
ddiff --git a/test/comment-video-chrome.test.js b/test/comment-video-chrome.test.js
nnew file mode 100644
iindex 0000000..3d0abfa
---- /dev/null
++++ b/test/comment-video-chrome.test.js
@@@ -0,0 +1,129 @@
++'use strict';
++// A video in a comment, end to end in headless Chrome (4.247.0, plan §Z.9): the box of a thread drawn with
++// mediaAttachments sends a picked .mkv to Media's composer attachments with the viewer's own token, as it is picked;
++// the form then carries the id in `attachments` and no file bytes; Media's limit for the kind is said in words; removing
++// a chip while it uploads cancels the upload; a box without a viewer token keeps the plain upload; and a comment's
++// video plays in the network's player (player.js loaded by ov-thread.js). Skipped without Chrome.
++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');
++const thread = require('../thread');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('comment-video-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const ATT = 'att_01JZ0M0Q8F3K2V7YB6N4T5R9WQ';
++const at = (p) => `https://openvibe.media/a/${ATT}/${p}`;
++const answer = {
++ thread: { id: 'cth_1', access_id: 'cth_1', comment_count: 1 },
++ comments: [{ id: 3, message: 'My clip', created_at: '2026-11-13T00:00:00Z', author: { username: 'kim' },
++ attachments: [{ id: ATT, kind: 'video', name: 'clip.mkv', url: at('clip.mkv'), poster: at('thumb.jpg'), playback: { url: at('playback.mp4'), mime: 'video/mp4', ready: true }, duration_s: 12, width: 640, height: 360 }] }],
++};
++const HTML = thread.renderThread({ answer, base: '/p', post: '/p/c', remove: '/p/r', signedIn: true, attachmentsAllowed: true, mediaAttachments: true });
++// The page stands Media in: XMLHttpRequest to openvibe.media answers an attachment, and records what it was sent.
++const FAKE_XHR = `<script>
++window.__sent = [];
++window.OpenVibeNavbar = { viewerToken: function () { return window.__token || ''; } };
++(function () {
++ var Real = window.XMLHttpRequest;
++ function Fake() { this.upload = {}; this.headers = {}; this.status = 0; this.responseText = ''; }
++ Fake.prototype.abort = function () { this.aborted = true; window.__aborted = (window.__aborted || 0) + 1; };
++ Fake.prototype.open = function (m, u) { this.method = m; this.url = u; };
++ Fake.prototype.setRequestHeader = function (k, v) { this.headers[k.toLowerCase()] = v; };
++ Fake.prototype.send = function (body) {
++ var self = this, file = body && body.get ? body.get('file') : null;
++ window.__sent.push({ method: self.method, url: self.url, auth: self.headers.authorization || '', name: file && file.name, size: file && file.size });
++ setTimeout(function () {
++ if (self.aborted) return;
++ if (self.upload.onprogress) self.upload.onprogress({ lengthComputable: true, loaded: 5, total: 10 });
++ self.status = 201; self.responseText = JSON.stringify({ id: '${ATT}', kind: 'video', status: 'processing' });
++ self.onload && self.onload();
++ }, window.__slow ? 3000 : 30);
++ };
++ window.XMLHttpRequest = function () { return new Fake(); };
++ window.XMLHttpRequest.Real = Real;
++})();
++</script>`;
++let posted = null;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (req.method === 'POST' && u.pathname === '/p/c') {
++ const chunks = [];
++ req.on('data', (d) => chunks.push(d));
++ req.on('end', () => {
++ const body = Buffer.concat(chunks).toString('latin1');
++ posted = {
++ attachments: [...body.matchAll(/name="attachments"\r?\n\r?\n([^\r]*)/g)].map((m) => m[1]),
++ fileBytes: /filename="clip\.mkv"/.test(body),
++ message: (body.match(/name="message"\r?\n\r?\n([^\r]*)/) || [])[1] || '',
++ };
++ res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>');
++ });
++ return;
++ }
++ if (u.pathname === '/' || u.pathname === '/anon') {
++ res.writeHead(200, { 'content-type': 'text/html' });
++ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Clip</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/thread.css"><link rel="stylesheet" href="/thread-inline.css">${FAKE_XHR}<script>window.__token = ${u.pathname === '/' ? "'viewer-token'" : "''"};</script></head><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div data-ovt data-ovc-thread>${HTML}</div><script src="/ov-composer.js"></script><script src="/ov-thread.js"></script></body></html>`);
++ }
++ const file = u.pathname.slice(1);
++ if (['thread.css', 'thread-inline.css', 'ov-composer.js', 'ov-thread.js', 'player.js', 'player.css'].includes(file)) {
++ res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css' : 'application/javascript' });
++ return res.end(fs.readFileSync(path.join(root, file)));
++ }
++ res.writeHead(404); res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'comment-video-')) });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ let page;
++ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
++ const pick = `(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); const c = f.__ovc && (f.__ovc.ctx || f.__ovc); c.add([new File([new Uint8Array(4096)], 'clip.mkv', { type: '' })]); return true; })()`;
++ try {
++ page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await page.goto(base + '/', { maxMs: 6000 });
++ assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"][data-ovc-media]')`), 'the comment box is the shared composer, marked for Media');
++ // The player: ov-thread.js loads it for the comment's video.
++ assert.ok(await until(`!!window.OpenVibePlayer && !!document.querySelector('.ov-player video[data-ov-player]')`), 'the comment\'s video is in the network\'s player');
++ assert.equal(await page.evaluate(`document.querySelector('video[data-ov-player]').getAttribute('src')`), at('playback.mp4'));
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'the video never widens the page');
++ // A picked .mkv (no type from the browser: the extension decides) goes to Media at once, with the viewer's token.
++ await page.evaluate(pick);
++ assert.ok(await until(`window.__sent.length === 1`), 'the file went to Media as it was picked');
++ const sent = await page.evaluate(`window.__sent[0]`);
++ assert.deepEqual(sent, { method: 'POST', url: 'https://openvibe.media/api/v1/attachments', auth: 'Bearer viewer-token', name: 'clip.mkv', size: 4096 });
++ assert.ok(await until(`[...document.querySelectorAll('form.ovc-v2 input[name=attachments]')].map((i) => i.value).join() === '${ATT}'`), 'the form carries the id');
++ // Media's limit for the kind, in words: a 21 MB picture is over the 20 MB a picture may be.
++ await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); const c = f.__ovc && (f.__ovc.ctx || f.__ovc); c.add([new File([new Uint8Array(21 * 1048576)], 'big.jpg', { type: 'image/jpeg' })]); })()`);
++ assert.ok(await until(`/Pictures can be up to 20 MB/.test(document.querySelector('form.ovc-v2 [data-ovt-note]').textContent)`), 'the limit is said');
++ assert.equal(await page.evaluate(`window.__sent.length`), 1, 'an oversize file never leaves');
++ // Removing a chip while it uploads cancels the upload.
++ await page.evaluate(`(() => { window.__slow = true; const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); const c = f.__ovc && (f.__ovc.ctx || f.__ovc); c.add([new File([new Uint8Array(1024)], 'slow.mov', { type: 'video/quicktime' })]); })()`);
++ assert.ok(await until(`window.__sent.length === 2`));
++ await page.evaluate(`[...document.querySelectorAll('form.ovc-v2 .ovc-file')].pop().querySelector('[data-ovc-x]').click()`);
++ assert.ok(await until(`window.__aborted === 1`), 'the upload is cancelled');
++ assert.equal(await page.evaluate(`document.querySelectorAll('form.ovc-v2 input[name=attachments]').length`), 1, 'only the first file\'s id stays');
++ await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Here it is'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
++ await until(`!!document.getElementById('posted')`);
++ assert.ok(posted, 'the form posted to the page\'s action');
++ assert.deepEqual(posted, { attachments: [ATT], fileBytes: false, message: 'Here it is' }, 'the id, the words, and no bytes');
++ await page.close();
++
++ // No viewer token: nothing goes to Media; the file stays in the form, the plain upload.
++ page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await page.goto(base + '/anon', { maxMs: 6000 });
++ assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`));
++ await page.evaluate(pick);
++ // The file lands in the form first (the plain upload), and by then no upload was started.
++ assert.ok(await until(`[...document.querySelectorAll('form.ovc-v2 input[type=file]')].reduce((n, i) => n + i.files.length, 0) === 1`), 'the file rides the form');
++ assert.equal(await page.evaluate(`window.__sent.length`), 0, 'no token, no call to Media');
++ await page.close();
++ console.log('comment-video-chrome: a picked video goes to Media with the viewer\'s token, the form sends its id, and the comment plays it');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/menu-guest-chrome.test.js b/test/menu-guest-chrome.test.js
nnew file mode 100644
iindex 0000000..f8a0acf
---- /dev/null
++++ b/test/menu-guest-chrome.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++// A guest sees what signing in adds (plan §Z.2, 4.247.0): the menu's You opens with one card that says it in four
++// lines and a Sign in button whose link is the bar's own (it knows this host's sign-in), at 390 px with no sideways
++// scroll; a signed-in person never sees it. A real headless Chrome; skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const ROOT = path.join(__dirname, '..');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('menu-guest-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };
++const PAGE = (user) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guest</title><link rel="icon" href="data:,">
++<script>
++ const realFetch = window.fetch;
++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));
++</script>
++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: ${JSON.stringify(user)}, token: ${user ? "'tok'" : 'null'}, loginUrl: '/auth/login?next={url}', links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>
++</body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ const m = /^\/shared\/([a-z0-9-]+\.(js|css))$/.exec(u.pathname);
++ if (m) {
++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
++ res.writeHead(200, { 'content-type': m[2] === 'css' ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
++ }
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
++ return res.end(PAGE(u.pathname === '/in' ? { id: 1, username: 'kim', display_name: 'Kim' } : null));
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-guest-')) });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
++ const openYou = async (p) => {
++ assert.ok(await until(p, `!!(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn'))`), 'the menu button is there');
++ await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);
++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`), 'the menu opened');
++ await p.evaluate(`(() => { const t = document.querySelector('.ovs [data-tab="you"]'); if (t) t.click(); return true; })()`);
++ assert.ok(await until(p, `!!document.querySelector('.ovs [data-sec]')`), 'You has drawn its sections');
++ };
++ try {
++ let p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await p.goto(`${base}/`, { maxMs: 6000 });
++ await openYou(p);
++ const seen = await until(p, `!!document.querySelector('.ovs [data-sec="signin"] .ovs-guest')`);
++ if (!seen) console.log('DEBUG', await p.evaluate(`JSON.stringify({ tabs: [...document.querySelectorAll('.ovs [data-tab]')].map((t) => t.getAttribute('data-tab') + (t.getAttribute('aria-selected') === 'true' ? '*' : '')), secs: [...document.querySelectorAll('.ovs [data-sec]')].map((x) => x.getAttribute('data-sec')), login: !!document.getElementById('openvibe-login-btn') })`));
++ assert.ok(seen, 'a guest sees the card');
++ const card = await p.evaluate(`(() => { const c = document.querySelector('.ovs [data-sec="signin"] .ovs-guest'); return { head: c.querySelector('b').textContent, lines: [...c.querySelectorAll('li')].length, go: c.querySelector('a.ovs-guest-go').getAttribute('href'), bar: (document.getElementById('openvibe-login-btn') || {}).href || '' }; })()`);
++ assert.strictEqual(card.head, 'Sign in to make OpenVibe yours');
++ assert.strictEqual(card.lines, 4);
++ assert.ok(card.bar && new URL(card.go, base).href === card.bar, `the card's link is the bar's own (${card.go} vs ${card.bar})`);
++ assert.ok(/\/auth\/login\?next=/.test(card.go), 'this host\'s sign-in, with the way back');
++ assert.strictEqual(await p.evaluate(`document.documentElement.scrollWidth`), 390, 'no sideways scroll');
++ const tall = await p.evaluate(`document.querySelector('.ovs a.ovs-guest-go').getBoundingClientRect().height`);
++ assert.ok(tall >= 44, `the button is a finger tall (${tall})`);
++ await p.close();
++
++ p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ await p.goto(`${base}/in`, { maxMs: 6000 });
++ await openYou(p);
++ assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs [data-sec="signin"]')`), false, 'a signed-in person never sees it');
++ await p.close();
++ console.log('menu-guest-chrome: a guest sees what signing in adds, with the bar\'s own sign-in link');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/thread-media-attachments.test.js b/test/thread-media-attachments.test.js
nnew file mode 100644
iindex 0000000..7bfa96f
---- /dev/null
++++ b/test/thread-media-attachments.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++// A comment's Media composer attachments (4.247.0, plan §Z.9): with mediaAttachments the box takes any video, picture,
++// sound, PDF or text at Media's limits and says so; without it nothing changes. A video plays (Media's web copy, the
++// network's player), one still being made is a link to the file with its first frame, a picture and a sound show, and
++// nothing off openvibe.media is ever drawn.
++const assert = require('node:assert/strict');
++const thread = require('../thread');
++
++const ID = 'att_01JZ0M0Q8F3K2V7YB6N4T5R9WQ';
++const at = (path) => `https://openvibe.media/a/${ID}/${path}`;
++const answerWith = (attachments) => ({
++ thread: { id: 'cth_1', access_id: 'cth_1', comment_count: 1 },
++ comments: [{ id: 7, message: 'Look', created_at: '2026-11-13T00:00:00Z', author: { username: 'kim' }, attachments }],
++});
++const draw = (attachments, extra = {}) => thread.renderThread({ answer: answerWith(attachments), base: '/x', post: '/x/c', remove: '/x/r', signedIn: true, ...extra });
++
++// The box.
++const media = draw([], { mediaAttachments: true });
++assert.match(media, /data-ovc-media="1"/);
++assert.match(media, /data-ovc-max-files="4"/);
++assert.match(media, /accept="image\/\*,video\/\*,audio\/\*,application\/pdf,text\/plain,\.mkv,\.avi,\.mov/);
++assert.match(media, /videos up to 300 MB \(MP4, MOV, MKV, AVI, WebM and the rest\)/);
++const plain = draw([]);
++assert.doesNotMatch(plain, /data-ovc-media/);
++assert.match(plain, /accept="image\/jpeg,image\/png,image\/gif,image\/webp,application\/pdf,text\/plain"/);
++assert.match(plain, /16 MB each/);
++assert.doesNotMatch(draw([], { mediaAttachments: true, attachmentsAllowed: false }), /data-ovc-media/, 'no files, no Media');
++
++// A video with its web copy: the network's player, the poster, its shape and length.
++const video = draw([{ id: ID, kind: 'video', name: '7224.mp4', media_type: 'video/x-matroska', url: at('7224.mkv'), poster: at('thumb.jpg'),
++ playback: { url: at('playback.mp4'), mime: 'video/mp4', ready: true }, duration_s: 73.4, width: 1920, height: 1080 }]);
++assert.match(video, new RegExp(`<video data-ov-player controls preload="none" playsinline src="${at('playback.mp4').replace(/[.]/g, '\\.')}" poster="${at('thumb.jpg').replace(/[.]/g, '\\.')}" width="1920" height="1080"`));
++assert.match(video, /<figcaption>7224\.mp4 · 1:13<\/figcaption>/);
++
++// A video whose copy is still being made: its first frame and a link to the file.
++const preparing = draw([{ id: ID, kind: 'video', url: at('a.avi'), poster: at('thumb.jpg'), playback: { url: at('playback.mp4'), ready: false } }]);
++assert.match(preparing, /class="ovc-attach ovc-attach--video is-preparing" href="https:\/\/openvibe\.media\/a\//);
++assert.doesNotMatch(preparing, /<video/);
++
++// A picture and a sound; a file.
++assert.match(draw([{ id: ID, kind: 'image', name: 'p.heic', url: at('p.heic'), poster: at('thumb.jpg') }]), /<img src="https:\/\/openvibe\.media\/a\/att_[^"]+\/thumb\.jpg" alt="p\.heic"/);
++assert.match(draw([{ id: ID, kind: 'audio', url: at('s.ogg') }]), /<audio controls preload="none" src="https:\/\/openvibe\.media\/a\//);
++assert.match(draw([{ id: ID, kind: 'file', name: 'notes.pdf', url: at('notes.pdf') }]), /ovc-attach--file" href="https:\/\/openvibe\.media\/a\/[^"]+"[^>]*>notes\.pdf</);
++
++// Never a URL off openvibe.media, never a script.
++assert.doesNotMatch(draw([{ id: ID, kind: 'video', url: 'https://evil.example/a.mp4', playback: { url: 'https://evil.example/a.mp4', ready: true } }]), /evil|<video/);
++assert.doesNotMatch(draw([{ id: ID, kind: 'video', url: at('a.mp4'), playback: { url: 'javascript:alert(1)', ready: true } }]), /javascript:|<video/);
++assert.doesNotMatch(draw([{ id: ID, kind: 'image', name: '"><script>x</script>', url: at('p.png') }]), /<script>x/);
++assert.doesNotMatch(draw([{ id: 'att_bad', kind: 'image', url: at('p.png') }]), /openvibe\.media\/a\//, 'an id that is not one is dropped');
++
++// The old Media objects draw as before.
++assert.match(draw([{ id: 'med_01JZ0M0Q8F3K2V7YB6N4T5R9WQ', media_type: 'image/png', name: 'x.png' }]), /https:\/\/openvibe\.media\/o\/med_/);
++
++console.log('thread-media-attachments: any video plays in a comment; the box takes it at Media\'s limits');
ddiff --git a/thread-inline.css b/thread-inline.css
iindex 33fe862..248cfbe 100644
---- a/thread-inline.css
++++ b/thread-inline.css
@@@ -16,6 +16,14 @@
.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }
.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }
.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }
++/* 4.247.0 (§Z.9): a video plays in the comment, at its own shape, never wider than the thread; a sound gets a bar. */
++.ovc-attach--video, .ovc-attach--audio { display: block; margin: 8px 0; max-width: min(100%, 560px); }
++.ovc-attach--video video { display: block; width: 100%; height: auto; max-height: 70vh; border-radius: 12px; background: #000; }
++.ovc-attach--audio audio { display: block; width: 100%; }
++.ovc-attach--video figcaption, .ovc-attach--audio figcaption { margin-top: 4px; font-size: 12.5px; opacity: .75; overflow-wrap: anywhere; }
++.ovc-attach--video.is-preparing { display: inline-grid; gap: 6px; padding: 8px; border: 1px dashed currentColor; border-radius: 12px; text-decoration: none; }
++.ovc-attach--video.is-preparing img { max-width: 240px; max-height: 135px; border-radius: 8px; }
++.ovc-attach--video.is-preparing span { font-size: 13px; }
.ovc-order { display: inline-flex; gap: 2px; padding: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 8%, transparent); }
.ovc-order a { min-height: 36px; padding: 4px 14px; border: 0; border-radius: 999px; font-size: .875rem; }
.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 16%, transparent); }
ddiff --git a/thread.js b/thread.js
iindex e409b2e..4b6194f 100644
---- a/thread.js
++++ b/thread.js
@@@ -303,9 +303,39 @@ ${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')
// The inline Community thread a page shows under a thing (MediaHub's Video and Pics item pages, Community's paste
// and /c pages): the same markup on every site, from the comment service's answer. Draw with thread-inline.css.
const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++// 4.247.0 (plan §Z.9): a comment's file uploaded through Media's composer attachments (composer.attachment@1): a video
++// plays (Media's web copy when the original is not one a browser plays), a picture shows, a sound plays.
++const ATT_ID = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ATT_URL = /^https:\/\/openvibe\.media\/a\/att_[0-9A-HJKMNP-TV-Z]{26}\/[^?#]{1,300}$/;
++// What a comment box with mediaAttachments takes: Media sniffs the bytes, so the list only guides the picker.
++const MEDIA_ACCEPT = 'image/*,video/*,audio/*,application/pdf,text/plain,.mkv,.avi,.mov,.m4v,.3gp,.3g2,.webm,.ogv,.wmv,.flv,.mpg,.mpeg,.ts,.mts,.m2ts,.vob,.heic,.heif,.avif';
++const MEDIA_NOTE = 'Up to four files: pictures up to 20 MB, videos up to 300 MB (MP4, MOV, MKV, AVI, WebM and the rest), sound up to 50 MB, PDFs and text up to 25 MB.';
const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
const ID = /^\d{1,15}$/;
const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };
++/** One Media composer attachment on a comment: a player, a picture, a sound or a file link (never a URL off openvibe.media). */
++function mediaAttachment(a) {
++ const url = ATT_URL.test(String(a.url || '')) ? String(a.url) : null;
++ if (!url) return '';
++ const poster = ATT_URL.test(String(a.poster || '')) ? String(a.poster) : null;
++ const type = String(a.media_type || a.mime || '');
++ const kind = a.kind || (type.startsWith('video/') ? 'video' : type.startsWith('image/') ? 'image' : type.startsWith('audio/') ? 'audio' : 'file');
++ const title = esc(a.name || (kind === 'video' ? 'Video' : kind === 'image' ? 'Picture' : kind === 'audio' ? 'Sound' : 'Attached file'));
++ const w = Number(a.width || a.w) || 0, h = Number(a.height || a.h) || 0;
++ const size = w > 0 && h > 0 && w <= 10000 && h <= 10000 ? ` width="${Math.round(w)}" height="${Math.round(h)}"` : '';
++ if (kind === 'video') {
++ const pb = a.playback && a.playback.ready && ATT_URL.test(String(a.playback.url || '')) ? String(a.playback.url) : null;
++ const secs = Number(a.duration_s);
++ const length = Number.isFinite(secs) && secs > 0 ? `${Math.floor(secs / 60)}:${String(Math.round(secs % 60)).padStart(2, '0')}` : '';
++ if (!pb) {
++ return `<a class="ovc-attach ovc-attach--video is-preparing" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${poster ? `<img src="${esc(poster)}" alt="" loading="lazy"${size}>` : ''}<span>Getting the video ready to play here. Open the file</span></a>`;
++ }
++ return `<figure class="ovc-attach ovc-attach--video"><video data-ov-player controls preload="none" playsinline src="${esc(pb)}"${poster ? ` poster="${esc(poster)}"` : ''}${size} aria-label="${title}"></video>${length ? `<figcaption>${title} · ${length}</figcaption>` : ''}</figure>`;
++ }
++ if (kind === 'image') return `<a class="ovc-attach ovc-attach--image" href="${esc(url)}" target="_blank" rel="noopener noreferrer"><img src="${esc(poster || url)}" alt="${title}" loading="lazy" width="120"></a>`;
++ if (kind === 'audio') return `<figure class="ovc-attach ovc-attach--audio"><audio controls preload="none" src="${esc(url)}" aria-label="${title}"></audio><figcaption>${title}</figcaption></figure>`;
++ return `<a class="ovc-attach ovc-attach--file" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${title}</a>`;
++}
const author = (c) => c.author || c;
const username = (c) => author(c).username || c.username || '';
const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
@@@ -351,10 +381,13 @@ function query(base, sort, extra = {}, anchor = 'comments') {
* has commented on; base: the page's own address (sorting, paging and reply links come back here)
* post, remove: where the composer and Delete post; signedIn: this person may write
* attachmentsAllowed: the composer offers files (Community's paste page and Pics say yes; elsewhere no)
++ * mediaAttachments: 4.247.0 (§Z.9), the files go to Media's composer attachments as they are picked (any video,
++ * picture, sound, PDF or text, at Media's limits) and the form carries their ids in `attachments`; only for a post
++ * route that reads that field (Community's, from #439). Without a viewer token the box keeps the plain upload.
* draft: the message kept in the box after a failed post
* sort, replyId, expandedId, expandedReplies, error: as the page received them
*/
--function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {
++function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed = true, mediaAttachments = false, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null, draft = '' }) {
const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];
const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);
@@@ -365,10 +398,12 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
const signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
const composer_html = (parent = null) => signedIn ? composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,
sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
-- attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment',
-- note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',
++ attach: attachmentsAllowed ? { name: 'files', accept: mediaAttachments ? MEDIA_ACCEPT : 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment',
++ note: attachmentsAllowed ? (mediaAttachments ? MEDIA_NOTE : 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.') : '',
++ attrs: attachmentsAllowed && mediaAttachments ? { 'data-ovc-media': '1', 'data-ovc-max-files': '4' } : {},
}) : signIn;
const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
++ if (ATT_ID.test(String(a.id || ''))) return mediaAttachment(a);
if (!MEDIA_ID.test(String(a.id || ''))) return '';
const url = `https://openvibe.media/o/${a.id}`;
const title = esc(a.name || 'Attached file');