openvibers/openvibe.shared Commits
Shared 4.247.0: any video in a comment (plan §Z.9 step 3) (#533)
Changes
@@@ -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` stillreads 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").@@@ -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 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.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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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.@@@ -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] .ovcconst 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] .ovcchip.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] .ovcfunction 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] .ovcfor (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] .ovcfunction 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] .ovcif (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));@@@ -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;@@@ -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",@@@ -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": {@@@ -35,7 +35,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-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)) {@@@ -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).nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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');@@@ -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); }@@@ -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, attachmentsAllowedconst 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');
@@ -1,5 +1,37 @@
@@ -1,5 +1,37 @@
# Changelog
# 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
## 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
**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").
@@ -17,6 +49,7 @@ than where it should").
`/api/frame/items/definitions`; every site allows openvibe.network). A page that sets `OpenVibeItemsConfig.api` still
`/api/frame/items/definitions`; every site allows openvibe.network). A page that sets `OpenVibeItemsConfig.api` still
reads Inventory directly.
reads Inventory directly.
+
## 4.245.0 — 2026-11-13
## 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").
**The menu knows your rank** (the owner, 11-13: navigation "user aware and session aware and … aware of rank").
@@ -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.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
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.246.0",
+ "version": "4.247.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)",
@@ -114,7 +114,7 @@
@@ -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
// 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': '5bd54df16c8f' };
+ const VIBE_REV = { 'ov-composer.js': '9b2a27f70f5f' };
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', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,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': '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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -624,7 +624,7 @@
@@ -624,7 +624,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': '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]}` : ''}`;
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.
@@ -25,7 +25,7 @@
@@ -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
* 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.
* 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,
* 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,
* 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
* 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
@@ -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`);
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).
// ── 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 prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {});
let synced = false, rev = null;
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
@@ -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 send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const inputs = () => [...form.querySelectorAll('input[type="file"]')];
const mine = () => mods.filter((m) => !m.match || m.match(form));
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).
// 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 noFiles = form.hasAttribute('data-ovc-no-files');
const canFile = () => !noFiles && !!(inputs().length || uploader());
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
@@ -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.classList.toggle('is-up', f.progress != null && f.progress < 1 && !f.error);
chip.style.setProperty('--p', String(f.progress == null ? 1 : f.progress));
chip.style.setProperty('--p', String(f.progress == null ? 1 : f.progress));
const ext = ((f.file.name || '').split('.').pop() || 'file').slice(0, 4).toUpperCase();
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>`
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>` : '')
+ (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>` : '')
+ (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>`;
+ `<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));
+ // 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'}`);
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);
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
@@ -791,6 +837,7 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
function remove(f) {
function remove(f) {
const i = files.indexOf(f); if (i < 0) return;
const i = files.indexOf(f); if (i < 0) return;
files.splice(i, 1);
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.url) URL.revokeObjectURL(f.url);
if (f.hidden) f.hidden.remove();
if (f.hidden) f.hidden.remove();
form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
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
@@ -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 || [])]) {
for (const file of [...(list || [])]) {
if (files.length >= maxFiles) { say(`Up to ${maxFiles} files at a time.`); break; }
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 (!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 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 };
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);
files.push(f);
@@ -827,13 +877,14 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -827,13 +877,14 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
function sendUp(f) {
function sendUp(f) {
const up = uploader(); if (!up) return;
const up = uploader(); if (!up) return;
f.error = null; f.progress = 0; paintFile(f); syncSend();
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) => {
.then((r) => {
if (!files.includes(f)) return;
if (!files.includes(f)) return;
f.progress = 1; f.id = r && r.id;
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); }
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();
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) => {
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;
@@ -1224,6 +1275,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -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,
// 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
// user: { display_name | username, avatar_url }, tools: ['photo', 'file', 'attach', 'emoji', 'voice'] (the
// preset's own when absent), accept, maxFiles, maxMb, required (true), noDraft,
// 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
// 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,
// ('globe' | 'spark' | 'cpu' | 'gauge'), options, value, onChange }] for any more (an agent's Model and Reasoning,
// as the agents portal's box has),
// 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
@@ -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;
if (o.id) form.id = o.id;
form.setAttribute('data-ovc', '');
form.setAttribute('data-ovc', '');
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 || (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.noDraft) form.setAttribute('data-ovc-no-draft', '');
if (o.noPlus) form.setAttribute('data-ovc-no-more', '');
if (o.noPlus) form.setAttribute('data-ovc-no-more', '');
if (o.plus && typeof o.plus === 'object') form.dataset.ovcPlus = JSON.stringify(cleanPlus(o.plus));
if (o.plus && typeof o.plus === 'object') form.dataset.ovcPlus = JSON.stringify(cleanPlus(o.plus));
@@ -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': '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) || '';
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() {
@@ -37,6 +37,24 @@
@@ -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'] : '');
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);
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) {
function loadComposer(after) {
if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }
if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }
if (after) loadComposer.waiting.push(after);
if (after) loadComposer.waiting.push(after);
@@ -261,6 +279,7 @@
@@ -261,6 +279,7 @@
}
}
function start() {
function start() {
loadCombo();
loadCombo();
+ loadPlayer();
qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
if (box.dataset.ovtReady) return;
if (box.dataset.ovtReady) return;
box.dataset.ovtReady = '1';
box.dataset.ovtReady = '1';
@@ -353,6 +372,7 @@
@@ -353,6 +372,7 @@
var sendBtn = q(f, '[data-ovt-send]');
var sendBtn = q(f, '[data-ovt-send]');
var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';
var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';
var preset = f.getAttribute('data-ovt-preset') || 'comment';
var preset = f.getAttribute('data-ovt-preset') || 'comment';
+ var media = f.hasAttribute('data-ovc-media');
var host = document.createElement('div');
var host = document.createElement('div');
f.replaceWith(host);
f.replaceWith(host);
var c = null;
var c = null;
@@ -360,7 +380,8 @@
@@ -360,7 +380,8 @@
c = root.OpenVibeComposer.mount(host, {
c = root.OpenVibeComposer.mount(host, {
preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',
preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',
maxLength: Number(oldText.getAttribute('maxlength')) || 2000, submitLabel: label,
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; }
} catch (e) { c = null; }
if (!c) { host.replaceWith(f); return null; }
if (!c) { host.replaceWith(f); return null; }
@@ -371,6 +392,7 @@
@@ -371,6 +392,7 @@
qa(c.form, 'input[type=file]').forEach(function (i) { i.name = field; if (file.accept) i.accept = file.accept; });
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); });
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 (oldText.value) c.text.value = oldText.value;
if (note && note.textContent) c.note.textContent = note.textContent;
if (note && note.textContent) c.note.textContent = note.textContent;
c.form.__ovtUpgraded = true;
c.form.__ovtUpgraded = true;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.246.0",
+ "version": "4.247.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.246.0",
+ "version": "4.247.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.246.0",
+ "version": "4.247.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": {
@@ -35,7 +35,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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'] },
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-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(', ')} };`;
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)) {
@@ -491,6 +491,12 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -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-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-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-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-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 { padding: 4px 8px 10px; }
.ovs-displayrow .ovnav-display { margin-top: 4px; }
.ovs-displayrow .ovnav-display { margin-top: 4px; }
@@ -956,7 +962,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -956,7 +962,7 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
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') {
if (g.as === 'notifications') {
const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', '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);
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; }
@@ -1010,6 +1016,21 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
listEl.appendChild(li);
listEl.appendChild(li);
return;
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') {
if (g.as === 'display') {
const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', '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; }
try { ctx.display(li); } catch { return; }
@@ -2051,6 +2072,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -2051,6 +2072,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
const H = homeLib();
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.
// 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: [] });
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) {
if (H) {
groups.push({ as: 'home', items: [], section: 'home' });
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).
// Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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');
@@ -16,6 +16,14 @@
@@ -16,6 +16,14 @@
.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }
.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--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }
.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }
.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 { 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 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); }
.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 16%, transparent); }
@@ -303,9 +303,39 @@ ${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')
@@ -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
// 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.
// and /c pages): the same markup on every site, from the comment service's answer. Draw with thread-inline.css.
const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
const 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 USERNAME = /^[A-Za-z0-9_]{1,32}$/;
const ID = /^\d{1,15}$/;
const ID = /^\d{1,15}$/;
const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };
const 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 author = (c) => c.author || c;
const username = (c) => author(c).username || c.username || '';
const username = (c) => author(c).username || c.username || '';
const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
const 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') {
@@ -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)
* 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
* 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)
* 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
* draft: the message kept in the box after a failed post
* sort, replyId, expandedId, expandedReplies, error: as the page received them
* 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 comments = Array.isArray(answer && answer.comments) ? answer.comments : [];
const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);
const 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
@@ -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 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,
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 } : {},
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;
}) : signIn;
const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
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 '';
if (!MEDIA_ID.test(String(a.id || ''))) return '';
const url = `https://openvibe.media/o/${a.id}`;
const url = `https://openvibe.media/o/${a.id}`;
const title = esc(a.name || 'Attached file');
const title = esc(a.name || 'Attached file');