openvibers/openvibe.shared Commits
4.144.0: send it on OpenVibe — the share sheet's OpenVibe Chat and OpenVibeDock.share (#332)
Changes
@@@ -1,5 +1,17 @@# Changelog++## 4.144.0 — 2026-11-08++++- **Send it on OpenVibe.** The share sheet (ov-share.js) offers **OpenVibe Chat** first to a signed-in person (the Dock++ is on the page): the sheet closes and the Dock opens on **Send to…**. The person picks one of their conversations,++ or writes to someone new by their OpenVibe name, and the title and link wait in that conversation's box, unsent,++ until they press Send. Every site's Share button gets it with no change of its own: a party invite, a video, a++ paste or a post goes to a friend on OpenVibe in two taps.++- `OpenVibeDock.share({ text, title })` (dock.js; Send to… itself is `dock-share.js`, a sibling loaded the first time++ something is sent, so the Dock on every signed-in page grows by 0.3 KB) is the same move for any page: Chats opens on Send to…; Cancel++ goes back to the list. A name nobody has says so (and the nearest name it found). Starting a conversation by name++ reads OpenVibe.Chat's `/api/dm/users/search` and `POST /api/dm/conversations` with the person's own token.++## 4.143.0 — 2026-11-07- Add `ov-sync.js` (seven domains plan B4): `OVSync.attach(mediaEl, { room, socket })` follows a `common.sync-clock@1` timeline over Chat's room socket. It measures the server clock with five `time` samples (best round trip, refreshed every 60 s), computes the room's position from `position_ms + (serverNow - at_ms) * rate`, holds the element within ±80 ms (a 0.95–1.05 playback-rate nudge between 80 and 400 ms, a seek past 400 ms), pauses and plays with the room, and puts a non-holder's own seek, play, pause or rate change back with a short notice while a holder's change becomes a `sync`. No dependencies; served as `/shared/ov-sync.js` and imported as `openvibe-shared/ov-sync`.@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.143.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.144.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,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.143.0",++ "version": "4.144.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)","contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.94.0","features": [++ "send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)","one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)","the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)","the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",nnew file mode 100644@@@ -0,0 +1,91 @@++/**++ * openvibe-shared/dock-share.js: the Dock's "Send to…" (4.144.0). dock.js loads it the first time something is sent++ * from a page (OpenVibeDock.share, the share sheet's OpenVibe Chat), so the Dock that every signed-in page carries++ * stays small.++ *++ * OpenVibeDockShare.bar({ draft, h, chatCall, open, cancel, host }) → the bar, drawn above the person's conversations++ * OpenVibeDockShare.ready({ draft, h, panel, form, box, fit }) the text in a conversation's box, unsent, said so++ *++ * The bar says what is being sent and lets the person write to someone new by their OpenVibe name: Chat's++ * /api/dm/users/search finds them, POST /api/dm/conversations starts the conversation, both with the person's own++ * token (dock.js's chatCall). Nothing is sent from here: the message waits in the conversation's box until the person++ * presses Send there.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockShare) return;++++ const CSS = `++.ovdk-share { flex: none; display: grid; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }++.ovdk-share-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }++.ovdk-share-h b { font-size: 14px; color: var(--text-primary, #ececf1); }++.ovdk-share-x { min-height: 32px; padding: 0 10px; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }++.ovdk-share-x:hover { color: var(--text-primary, #ececf1); }++.ovdk-share-what { margin: 0; font-size: 13px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovdk-to { display: flex; gap: 8px; }++.ovdk-to input { flex: 1; min-width: 0; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; }++.ovdk-to input:focus { outline: none; border-color: var(--accent, #3b82f6); }++.ovdk-to-go { flex: none; min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 700; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++.ovdk-to-go:disabled { opacity: .55; cursor: default; }++.ovdk-share .ovdk-err { margin: 0; }++.ovdk-ready { flex: none; margin: 6px 14px 0; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }++`;++ function style(host) {++ const st = (host && host.ownerDocument && host.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles');++ if (st && !st.textContent.includes('.ovdk-share {')) st.textContent += CSS;++ }++++ function bar(c) {++ const { draft, h, chatCall, open, cancel, host } = c;++ style(host);++ let busy = false;++ const el = h('div', 'ovdk-share');++ const top = h('div', 'ovdk-share-h');++ const x = h('button', 'ovdk-share-x', 'Cancel'); x.type = 'button';++ x.addEventListener('click', () => cancel());++ top.append(h('b', '', 'Send to…'), x);++ const what = h('p', 'ovdk-share-what', draft.title || draft.text); what.title = draft.text;++ const to = h('form', 'ovdk-to');++ const name = h('input'); name.type = 'text'; name.maxLength = 40; name.autocomplete = 'off'; name.spellcheck = false;++ name.placeholder = 'Someone new: their OpenVibe name'; name.setAttribute('aria-label', 'Their OpenVibe name');++ const next = h('button', 'ovdk-to-go', 'Next'); next.type = 'submit';++ const err = h('p', 'ovdk-err'); err.hidden = true;++ to.append(name, next);++ to.addEventListener('submit', async (e) => {++ e.preventDefault();++ const want = name.value.trim().replace(/^@/, '');++ if (want.length < 2 || busy) return;++ busy = true; next.disabled = true; err.hidden = true;++ try {++ const found = (await chatCall(`/api/dm/users/search?q=${encodeURIComponent(want)}`)).users || [];++ const exact = found.find((u) => String(u.username || '').toLowerCase() === want.toLowerCase());++ if (!exact) {++ err.textContent = found.length ? `Nobody is called @${want}. Did you mean @${found[0].username}?` : `Nobody on OpenVibe is called @${want}.`;++ err.hidden = false;++ return;++ }++ const made = await chatCall('/api/dm/conversations', { method: 'POST', body: JSON.stringify({ user_ids: [exact.id] }) });++ const id = (made.conversation && made.conversation.id) || made.id;++ if (!id) throw new Error('no conversation');++ await open(id);++ } catch (z) {++ err.textContent = z.status === 429 ? 'Too many new conversations just now. Try again in a little while.' : z.status === 403 ? (z.message || 'You can’t start a conversation with them.') : 'That did not work. Try again.';++ err.hidden = false;++ } finally { busy = false; next.disabled = false; }++ });++ el.append(top, what, to, err);++ return el;++ }++++ /** The conversation the person picked: the text in its box, and a line saying nothing is sent until they send it. */++ function ready({ draft, h, panel, form, box, fit }) {++ box.value = draft;++ const line = h('p', 'ovdk-ready', 'Ready to send: change it if you like, then press Send.');++ panel.insertBefore(line, form);++ box.addEventListener('input', () => line.remove(), { once: true });++ form.addEventListener('submit', () => line.remove(), { once: true });++ setTimeout(fit, 0);++ }++++ root.OpenVibeDockShare = { bar, ready };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -4,6 +4,7 @@// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)// OpenVibeDock.open({ tab }) · close() · isOpen()++// OpenVibeDock.share({ text, title }) · canSend() Send to… (4.144.0, dock-share.js): the text waits in a box, unsent//// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and@@@ -99,7 +100,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': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824' };++ const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -455,7 +456,7 @@vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });return;}-- if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }++ if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }// The Agent panel reads the step.if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }mounts[id] = { refresh: null };@@@ -502,7 +503,7 @@// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.-- const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };++ const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null };const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');const me = () => String(o.me || '');const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;@@@ -549,9 +550,12 @@chats.open = null;const panel = chats.panel; if (!panel) return;if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }++ const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }++ if (sharing) await sharing;if (chats.open || tab !== 'chats') return;panel.textContent = '';++ if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));const scroll = h('div', 'ovdk-scroll');if (!chats.list.length) {const none = h('div', 'ovdk-none');@@@ -592,6 +596,8 @@clearTimeout(chats.timer);const panel = chats.panel; if (!panel) return;chats.open = String(id); chats.msgs = []; chats.last = 0;++ const draft = chats.draft && canWriteChat() ? chats.draft.text : '';++ chats.draft = null;if (remember) save({ chat: chats.open });const conv = (chats.list || []).find((c) => String(c.id) === chats.open);panel.textContent = '';@@@ -614,6 +620,7 @@panel.append(form);const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };box.addEventListener('input', fit);++ if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });form.addEventListener('submit', async (e) => {e.preventDefault();@@@ -635,7 +642,7 @@list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(list, scroll, true);-- if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */async function pull(list, scroll, first) {@@@ -832,6 +839,10 @@// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }function isOpen() { return !!(el && !el.hidden); }++ function share({ text = '', title = '' } = {}) {++ const t = String(text || '').trim().slice(0, 4000);++ if (t) { chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null; open({ tab: 'chats' }); }++ }function open(opts = {}) {o = { ...o, ...opts };if (opts.doc) doc = opts.doc;@@@ -881,5 +892,5 @@if (isOpen()) layout();}-- root.OpenVibeDock = { mount, open, close, isOpen, update, ask, _state: () => ({ doc, tab, pending }) };++ root.OpenVibeDock = { mount, open, close, isOpen, update, ask, share, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };})(typeof window !== 'undefined' ? window : globalThis);@@@ -25,6 +25,7 @@ const BROWSER = Object.freeze(['presence.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens++ 'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel'account-switcher.js',@@@ -513,7 +513,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'e13066db0aac', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': '1db802150958', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -271,6 +271,7 @@qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>',embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>',more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',++ ovchat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',};function tileIcon(id, slug) {const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' });@@@ -350,7 +351,17 @@if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }];return [id, label, slug, fn(href, st.o.title || '', st.o), null];});-- const ordered = [...tiles.slice(0, SHOWN - extra.length), ...extra, ...tiles.slice(SHOWN - extra.length)];++ // OpenVibe Chat (4.144.0): to someone on OpenVibe, first, for a signed-in person (the Dock is theirs). The sheet++ // closes and the Dock opens on "Send to…"; nothing is sent until they press Send in the conversation.++ const dock = root.OpenVibeDock && typeof root.OpenVibeDock.share === 'function'++ && (typeof root.OpenVibeDock.canSend !== 'function' || root.OpenVibeDock.canSend()) ? root.OpenVibeDock : null;++ const first = dock ? [['ovchat', 'OpenVibe Chat', null, null, () => {++ sent(st, 'ovchat');++ close(st, true);++ dock.share({ text: o.title ? `${o.title} ${href}` : href, title: o.title || '' });++ }]] : [];++ const room = SHOWN - extra.length - first.length;++ const ordered = [...first, ...tiles.slice(0, room), ...extra, ...tiles.slice(room)];const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' });const show = st.allTargets ? ordered : ordered.slice(0, SHOWN);show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i)));@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.143.0",++ "version": "4.144.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -50,6 +50,7 @@"./presence": "./presence.js","./dock-vibe": "./dock-vibe.js","./dock-timer": "./dock-timer.js",++ "./dock-share": "./dock-share.js","./dock-agent": "./dock-agent.js","./dock-timeline": "./dock-timeline.js","./notification-ui": "./notification-ui.js",@@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css','search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.--const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js'] },++const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;@@@ -53,7 +53,7 @@ const NOTES = [// The stand-in's state for the scenario under way.let scenario = {};--const puts = [], decided = [], said = [], reads = [];++const puts = [], decided = [], said = [], reads = [], started = [];// K-D3: the agent view Codes' API answers (dock-agent.js) and the controls the Dock pressed.let agentView = null;const controls = [];@@@ -110,6 +110,8 @@ const server = http.createServer((req, res) => {if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).if (u.pathname === '/api/dm/unread') return json(200, { unread: 3 });++ if (u.pathname === '/api/dm/users/search') return json(200, { users: [{ id: 4, username: 'mia', display_name: 'Mia' }, { id: 5, username: 'miandra', display_name: 'Miandra' }] });++ if (u.pathname === '/api/dm/conversations' && req.method === 'POST') { started.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').user_ids); return json(201, { conversation: { id: 13 } }); }if (u.pathname === '/api/dm/conversations') {const ada = { id: 1, username: 'ada', display_name: 'Ada' }, kai = { id: 2, username: 'kai', display_name: 'Kai Moreno' }, jo = { id: 3, username: 'jo', display_name: 'Jo' };return json(200, { conversations: [@@@ -288,6 +290,44 @@ async function drag(p, from, to) {await p.close();}++ // ── Send to a chat (4.144.0): a conversation or a name; the message waits in the box until it is sent ──++ {++ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };++ said.length = 0; started.length = 0;++ const p = await fresh(390, 780);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, PILL, 'the pill');++ const TEXT = 'Friday film https://openvibe.party/p/friday?k=abc';++ const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);++ await share('Friday film');++ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title');++ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');++ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");++ await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send');++ await wait(300);++ assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');++ // Someone new, by their name: a name nobody has says so; the right one starts the conversation.++ await share('Friday film');++ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');++ await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");++ await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');++ assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/);++ await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");++ await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');++ assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');++ assert.deepStrictEqual(said, [], 'still nothing sent');++ // Cancel: back to the list, nothing waiting.++ await share();++ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), TEXT, 'with no title, the text itself');++ await p.evaluate("document.querySelector('.ovdk-share-x').click()");++ await until(p, "!document.querySelector('.ovdk .ovdk-share') && document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'the list without the bar');++ await p.close();++ }++// ── The agent view (K-D3): the person's own desk stream, its curtain, End, and the still when it ends ──{scenario = { counts: { running: 1, waiting: 0, unread: 0 } };@@@ -17,10 +17,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).// 4.120.0 (2026-11-06, K-D3) adds the agent view's container and its lazy loader: +222 bytes (15435). The view itself// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.++// Send to… (4.144.0: a conversation or a new name for what the share sheet sends) is dock-share.js, loaded the first++// time something is sent, not on every page.// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer// runs or its menu opens; the header's tab rail and narrow-width rules came with it.--const DOCK_BROTLI_BUDGET = 16384; // 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];++const DOCK_BROTLI_BUDGET = 16640; // 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js'];// ── the budget ──────────────────────────────────────────────{@@@ -206,6 +206,8 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');++ // No Dock on this page (signed out): no OpenVibe Chat tile.++ assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock');const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');@@@ -224,6 +226,25 @@ const server = http.createServer((req, res) => {assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');await p.evaluate(`${q('close')}.click()`);await wait(320);++ // Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title++ // and the link, and nothing is sent from here.++ await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");++ await p.evaluate("document.getElementById('send').click()");++ await wait(300);++ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place');++ assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat');++ await p.evaluate(`${q('to-ovchat')}.click()`);++ await wait(320);++ assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link');++ assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes');++ // A Dock that may not send (a read-only Dock token): no OpenVibe Chat.++ await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");++ await p.evaluate("document.getElementById('send').click()");++ await wait(300);++ assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send');++ await p.evaluate(`${q('close')}.click()`);++ await wait(320);++ await p.evaluate("delete window.OpenVibeDock");// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.await p.evaluate("document.getElementById('theirs').click()");await wait(300);
@@ -1,5 +1,17 @@
@@ -1,5 +1,17 @@
# Changelog
# Changelog
+## 4.144.0 — 2026-11-08
+
+- **Send it on OpenVibe.** The share sheet (ov-share.js) offers **OpenVibe Chat** first to a signed-in person (the Dock
+ is on the page): the sheet closes and the Dock opens on **Send to…**. The person picks one of their conversations,
+ or writes to someone new by their OpenVibe name, and the title and link wait in that conversation's box, unsent,
+ until they press Send. Every site's Share button gets it with no change of its own: a party invite, a video, a
+ paste or a post goes to a friend on OpenVibe in two taps.
+- `OpenVibeDock.share({ text, title })` (dock.js; Send to… itself is `dock-share.js`, a sibling loaded the first time
+ something is sent, so the Dock on every signed-in page grows by 0.3 KB) is the same move for any page: Chats opens on Send to…; Cancel
+ goes back to the list. A name nobody has says so (and the nearest name it found). Starting a conversation by name
+ reads OpenVibe.Chat's `/api/dm/users/search` and `POST /api/dm/conversations` with the person's own token.
+
## 4.143.0 — 2026-11-07
## 4.143.0 — 2026-11-07
- Add `ov-sync.js` (seven domains plan B4): `OVSync.attach(mediaEl, { room, socket })` follows a `common.sync-clock@1` timeline over Chat's room socket. It measures the server clock with five `time` samples (best round trip, refreshed every 60 s), computes the room's position from `position_ms + (serverNow - at_ms) * rate`, holds the element within ±80 ms (a 0.95–1.05 playback-rate nudge between 80 and 400 ms, a seek past 400 ms), pauses and plays with the room, and puts a non-holder's own seek, play, pause or rate change back with a short notice while a holder's change becomes a `sync`. No dependencies; served as `/shared/ov-sync.js` and imported as `openvibe-shared/ov-sync`.
- Add `ov-sync.js` (seven domains plan B4): `OVSync.attach(mediaEl, { room, socket })` follows a `common.sync-clock@1` timeline over Chat's room socket. It measures the server clock with five `time` samples (best round trip, refreshed every 60 s), computes the room's position from `position_ms + (serverNow - at_ms) * rate`, holds the element within ±80 ms (a 0.95–1.05 playback-rate nudge between 80 and 400 ms, a seek past 400 ms), pauses and plays with the room, and puts a non-holder's own seek, play, pause or rate change back with a short notice while a holder's change becomes a `sync`. No dependencies; served as `/shared/ov-sync.js` and imported as `openvibe-shared/ov-sync`.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.143.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.144.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,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.143.0",
+ "version": "4.144.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)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"features": [
"features": [
+ "send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
"the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
"the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
"the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",
"the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+/**
+ * openvibe-shared/dock-share.js: the Dock's "Send to…" (4.144.0). dock.js loads it the first time something is sent
+ * from a page (OpenVibeDock.share, the share sheet's OpenVibe Chat), so the Dock that every signed-in page carries
+ * stays small.
+ *
+ * OpenVibeDockShare.bar({ draft, h, chatCall, open, cancel, host }) → the bar, drawn above the person's conversations
+ * OpenVibeDockShare.ready({ draft, h, panel, form, box, fit }) the text in a conversation's box, unsent, said so
+ *
+ * The bar says what is being sent and lets the person write to someone new by their OpenVibe name: Chat's
+ * /api/dm/users/search finds them, POST /api/dm/conversations starts the conversation, both with the person's own
+ * token (dock.js's chatCall). Nothing is sent from here: the message waits in the conversation's box until the person
+ * presses Send there.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockShare) return;
+
+ const CSS = `
+.ovdk-share { flex: none; display: grid; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
+.ovdk-share-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.ovdk-share-h b { font-size: 14px; color: var(--text-primary, #ececf1); }
+.ovdk-share-x { min-height: 32px; padding: 0 10px; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
+.ovdk-share-x:hover { color: var(--text-primary, #ececf1); }
+.ovdk-share-what { margin: 0; font-size: 13px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovdk-to { display: flex; gap: 8px; }
+.ovdk-to input { flex: 1; min-width: 0; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; }
+.ovdk-to input:focus { outline: none; border-color: var(--accent, #3b82f6); }
+.ovdk-to-go { flex: none; min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 700; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+.ovdk-to-go:disabled { opacity: .55; cursor: default; }
+.ovdk-share .ovdk-err { margin: 0; }
+.ovdk-ready { flex: none; margin: 6px 14px 0; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
+`;
+ function style(host) {
+ const st = (host && host.ownerDocument && host.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles');
+ if (st && !st.textContent.includes('.ovdk-share {')) st.textContent += CSS;
+ }
+
+ function bar(c) {
+ const { draft, h, chatCall, open, cancel, host } = c;
+ style(host);
+ let busy = false;
+ const el = h('div', 'ovdk-share');
+ const top = h('div', 'ovdk-share-h');
+ const x = h('button', 'ovdk-share-x', 'Cancel'); x.type = 'button';
+ x.addEventListener('click', () => cancel());
+ top.append(h('b', '', 'Send to…'), x);
+ const what = h('p', 'ovdk-share-what', draft.title || draft.text); what.title = draft.text;
+ const to = h('form', 'ovdk-to');
+ const name = h('input'); name.type = 'text'; name.maxLength = 40; name.autocomplete = 'off'; name.spellcheck = false;
+ name.placeholder = 'Someone new: their OpenVibe name'; name.setAttribute('aria-label', 'Their OpenVibe name');
+ const next = h('button', 'ovdk-to-go', 'Next'); next.type = 'submit';
+ const err = h('p', 'ovdk-err'); err.hidden = true;
+ to.append(name, next);
+ to.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const want = name.value.trim().replace(/^@/, '');
+ if (want.length < 2 || busy) return;
+ busy = true; next.disabled = true; err.hidden = true;
+ try {
+ const found = (await chatCall(`/api/dm/users/search?q=${encodeURIComponent(want)}`)).users || [];
+ const exact = found.find((u) => String(u.username || '').toLowerCase() === want.toLowerCase());
+ if (!exact) {
+ err.textContent = found.length ? `Nobody is called @${want}. Did you mean @${found[0].username}?` : `Nobody on OpenVibe is called @${want}.`;
+ err.hidden = false;
+ return;
+ }
+ const made = await chatCall('/api/dm/conversations', { method: 'POST', body: JSON.stringify({ user_ids: [exact.id] }) });
+ const id = (made.conversation && made.conversation.id) || made.id;
+ if (!id) throw new Error('no conversation');
+ await open(id);
+ } catch (z) {
+ err.textContent = z.status === 429 ? 'Too many new conversations just now. Try again in a little while.' : z.status === 403 ? (z.message || 'You can’t start a conversation with them.') : 'That did not work. Try again.';
+ err.hidden = false;
+ } finally { busy = false; next.disabled = false; }
+ });
+ el.append(top, what, to, err);
+ return el;
+ }
+
+ /** The conversation the person picked: the text in its box, and a line saying nothing is sent until they send it. */
+ function ready({ draft, h, panel, form, box, fit }) {
+ box.value = draft;
+ const line = h('p', 'ovdk-ready', 'Ready to send: change it if you like, then press Send.');
+ panel.insertBefore(line, form);
+ box.addEventListener('input', () => line.remove(), { once: true });
+ form.addEventListener('submit', () => line.remove(), { once: true });
+ setTimeout(fit, 0);
+ }
+
+ root.OpenVibeDockShare = { bar, ready };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:
// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:
// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)
// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)
// OpenVibeDock.open({ tab }) · close() · isOpen()
// OpenVibeDock.open({ tab }) · close() · isOpen()
+// OpenVibeDock.share({ text, title }) · canSend() Send to… (4.144.0, dock-share.js): the text waits in a box, unsent
//
//
// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the
// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the
// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and
// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and
@@ -99,7 +100,7 @@
@@ -99,7 +100,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': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824' };
+ const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef' };
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);
@@ -455,7 +456,7 @@
@@ -455,7 +456,7 @@
vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
return;
}
}
- if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
+ if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
// The Agent panel reads the step.
// The Agent panel reads the step.
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
@@ -502,7 +503,7 @@
@@ -502,7 +503,7 @@
// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are
// The person's conversations and DMs, from OpenVibe.Chat's own API with their Network token (Chat checks they are
// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls
// in each one). Everything people wrote is set as text, never markup; only https pictures are shown. A thread polls
// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.
// for new messages every 5 s while it is open in the Dock, and is marked read as it is shown.
- const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
+ const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false, draft: null };
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const me = () => String(o.me || '');
const me = () => String(o.me || '');
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
@@ -549,9 +550,12 @@
@@ -549,9 +550,12 @@
chats.open = null;
chats.open = null;
const panel = chats.panel; if (!panel) return;
const panel = chats.panel; if (!panel) return;
if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
+ const sharing = chats.draft && canWriteChat() ? load('dock-share.js', () => !!(root.OpenVibeDockShare && root.OpenVibeDockShare.bar)) : null;
try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
try { chats.list = (await chatCall('/api/dm/conversations')).conversations || []; } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
+ if (sharing) await sharing;
if (chats.open || tab !== 'chats') return;
if (chats.open || tab !== 'chats') return;
panel.textContent = '';
panel.textContent = '';
+ if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
const scroll = h('div', 'ovdk-scroll');
const scroll = h('div', 'ovdk-scroll');
if (!chats.list.length) {
if (!chats.list.length) {
const none = h('div', 'ovdk-none');
const none = h('div', 'ovdk-none');
@@ -592,6 +596,8 @@
@@ -592,6 +596,8 @@
clearTimeout(chats.timer);
clearTimeout(chats.timer);
const panel = chats.panel; if (!panel) return;
const panel = chats.panel; if (!panel) return;
chats.open = String(id); chats.msgs = []; chats.last = 0;
chats.open = String(id); chats.msgs = []; chats.last = 0;
+ const draft = chats.draft && canWriteChat() ? chats.draft.text : '';
+ chats.draft = null;
if (remember) save({ chat: chats.open });
if (remember) save({ chat: chats.open });
const conv = (chats.list || []).find((c) => String(c.id) === chats.open);
const conv = (chats.list || []).find((c) => String(c.id) === chats.open);
panel.textContent = '';
panel.textContent = '';
@@ -614,6 +620,7 @@
@@ -614,6 +620,7 @@
panel.append(form);
panel.append(form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
box.addEventListener('input', fit);
box.addEventListener('input', fit);
+ if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
@@ -635,7 +642,7 @@
@@ -635,7 +642,7 @@
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
await pull(list, scroll, true);
- if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
async function pull(list, scroll, first) {
@@ -832,6 +839,10 @@
@@ -832,6 +839,10 @@
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!(el && !el.hidden); }
function isOpen() { return !!(el && !el.hidden); }
+ function share({ text = '', title = '' } = {}) {
+ const t = String(text || '').trim().slice(0, 4000);
+ if (t) { chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null; open({ tab: 'chats' }); }
+ }
function open(opts = {}) {
function open(opts = {}) {
o = { ...o, ...opts };
o = { ...o, ...opts };
if (opts.doc) doc = opts.doc;
if (opts.doc) doc = opts.doc;
@@ -881,5 +892,5 @@
@@ -881,5 +892,5 @@
if (isOpen()) layout();
if (isOpen()) layout();
}
}
- root.OpenVibeDock = { mount, open, close, isOpen, update, ask, _state: () => ({ doc, tab, pending }) };
+ root.OpenVibeDock = { mount, open, close, isOpen, update, ask, share, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
'presence.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'presence.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
+ 'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'account-switcher.js',
@@ -513,7 +513,7 @@
@@ -513,7 +513,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// 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': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '7d4affbb5093', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'e13066db0aac', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': '1db802150958', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -271,6 +271,7 @@
@@ -271,6 +271,7 @@
qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>',
qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>',
embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>',
embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>',
more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',
more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',
+ ovchat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
};
};
function tileIcon(id, slug) {
function tileIcon(id, slug) {
const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' });
const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' });
@@ -350,7 +351,17 @@
@@ -350,7 +351,17 @@
if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }];
if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }];
return [id, label, slug, fn(href, st.o.title || '', st.o), null];
return [id, label, slug, fn(href, st.o.title || '', st.o), null];
});
});
- const ordered = [...tiles.slice(0, SHOWN - extra.length), ...extra, ...tiles.slice(SHOWN - extra.length)];
+ // OpenVibe Chat (4.144.0): to someone on OpenVibe, first, for a signed-in person (the Dock is theirs). The sheet
+ // closes and the Dock opens on "Send to…"; nothing is sent until they press Send in the conversation.
+ const dock = root.OpenVibeDock && typeof root.OpenVibeDock.share === 'function'
+ && (typeof root.OpenVibeDock.canSend !== 'function' || root.OpenVibeDock.canSend()) ? root.OpenVibeDock : null;
+ const first = dock ? [['ovchat', 'OpenVibe Chat', null, null, () => {
+ sent(st, 'ovchat');
+ close(st, true);
+ dock.share({ text: o.title ? `${o.title} ${href}` : href, title: o.title || '' });
+ }]] : [];
+ const room = SHOWN - extra.length - first.length;
+ const ordered = [...first, ...tiles.slice(0, room), ...extra, ...tiles.slice(room)];
const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' });
const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' });
const show = st.allTargets ? ordered : ordered.slice(0, SHOWN);
const show = st.allTargets ? ordered : ordered.slice(0, SHOWN);
show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i)));
show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i)));
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.143.0",
+ "version": "4.144.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": {
@@ -50,6 +50,7 @@
@@ -50,6 +50,7 @@
"./presence": "./presence.js",
"./presence": "./presence.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
+ "./dock-share": "./dock-share.js",
"./dock-agent": "./dock-agent.js",
"./dock-agent": "./dock-agent.js",
"./dock-timeline": "./dock-timeline.js",
"./dock-timeline": "./dock-timeline.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];
'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
-const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js'] },
+const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.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(', ')} };`;
@@ -53,7 +53,7 @@ const NOTES = [
@@ -53,7 +53,7 @@ const NOTES = [
// The stand-in's state for the scenario under way.
// The stand-in's state for the scenario under way.
let scenario = {};
let scenario = {};
-const puts = [], decided = [], said = [], reads = [];
+const puts = [], decided = [], said = [], reads = [], started = [];
// K-D3: the agent view Codes' API answers (dock-agent.js) and the controls the Dock pressed.
// K-D3: the agent view Codes' API answers (dock-agent.js) and the controls the Dock pressed.
let agentView = null;
let agentView = null;
const controls = [];
const controls = [];
@@ -110,6 +110,8 @@ const server = http.createServer((req, res) => {
@@ -110,6 +110,8 @@ const server = http.createServer((req, res) => {
if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }
if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }
// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).
// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).
if (u.pathname === '/api/dm/unread') return json(200, { unread: 3 });
if (u.pathname === '/api/dm/unread') return json(200, { unread: 3 });
+ if (u.pathname === '/api/dm/users/search') return json(200, { users: [{ id: 4, username: 'mia', display_name: 'Mia' }, { id: 5, username: 'miandra', display_name: 'Miandra' }] });
+ if (u.pathname === '/api/dm/conversations' && req.method === 'POST') { started.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').user_ids); return json(201, { conversation: { id: 13 } }); }
if (u.pathname === '/api/dm/conversations') {
if (u.pathname === '/api/dm/conversations') {
const ada = { id: 1, username: 'ada', display_name: 'Ada' }, kai = { id: 2, username: 'kai', display_name: 'Kai Moreno' }, jo = { id: 3, username: 'jo', display_name: 'Jo' };
const ada = { id: 1, username: 'ada', display_name: 'Ada' }, kai = { id: 2, username: 'kai', display_name: 'Kai Moreno' }, jo = { id: 3, username: 'jo', display_name: 'Jo' };
return json(200, { conversations: [
return json(200, { conversations: [
@@ -288,6 +290,44 @@ async function drag(p, from, to) {
@@ -288,6 +290,44 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── Send to a chat (4.144.0): a conversation or a name; the message waits in the box until it is sent ──
+ {
+ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
+ said.length = 0; started.length = 0;
+ const p = await fresh(390, 780);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, PILL, 'the pill');
+ const TEXT = 'Friday film https://openvibe.party/p/friday?k=abc';
+ const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
+ await share('Friday film');
+ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the Send to… bar');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), 'Friday film', 'what is being sent, by its title');
+ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'your conversations under it');
+ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
+ await until(p, "document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value.length > 0", 'the message in the box');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-send textarea').value"), TEXT, 'the link waits in the box');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk-ready')"), true, 'and says it is ready to send');
+ await wait(300);
+ assert.deepStrictEqual(said, [], 'nothing is sent until the person sends it');
+ // Someone new, by their name: a name nobody has says so; the right one starts the conversation.
+ await share('Friday film');
+ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
+ await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = '@zz'; document.querySelector('.ovdk-to').requestSubmit(); })()");
+ await until(p, "!document.querySelector('.ovdk-share .ovdk-err').hidden", 'a name nobody has');
+ assert.match(await p.evaluate("document.querySelector('.ovdk-share .ovdk-err').textContent"), /Nobody is called @zz\. Did you mean @mia\?/);
+ await p.evaluate("(() => { document.querySelector('.ovdk-to input').value = 'mia'; document.querySelector('.ovdk-to').requestSubmit(); })()");
+ await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
+ assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
+ assert.deepStrictEqual(said, [], 'still nothing sent');
+ // Cancel: back to the list, nothing waiting.
+ await share();
+ await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk-share-what').textContent"), TEXT, 'with no title, the text itself');
+ await p.evaluate("document.querySelector('.ovdk-share-x').click()");
+ await until(p, "!document.querySelector('.ovdk .ovdk-share') && document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'the list without the bar');
+ await p.close();
+ }
+
// ── The agent view (K-D3): the person's own desk stream, its curtain, End, and the still when it ends ──
// ── The agent view (K-D3): the person's own desk stream, its curtain, End, and the still when it ends ──
{
{
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
@@ -17,10 +17,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -17,10 +17,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).
// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).
// 4.120.0 (2026-11-06, K-D3) adds the agent view's container and its lazy loader: +222 bytes (15435). The view itself
// 4.120.0 (2026-11-06, K-D3) adds the agent view's container and its lazy loader: +222 bytes (15435). The view itself
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
+// Send to… (4.144.0: a conversation or a new name for what the share sheet sends) is dock-share.js, loaded the first
+// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
-const DOCK_BROTLI_BUDGET = 16384; // 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
-const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];
+const DOCK_BROTLI_BUDGET = 16640; // 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
+const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{
@@ -206,6 +206,8 @@ const server = http.createServer((req, res) => {
@@ -206,6 +206,8 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');
assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');
assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');
assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');
assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');
assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');
+ // No Dock on this page (signed out): no OpenVibe Chat tile.
+ assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat without the Dock');
const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');
assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');
@@ -224,6 +226,25 @@ const server = http.createServer((req, res) => {
@@ -224,6 +226,25 @@ const server = http.createServer((req, res) => {
assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');
assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');
await p.evaluate(`${q('close')}.click()`);
await p.evaluate(`${q('close')}.click()`);
await wait(320);
await wait(320);
+ // Signed in (the Dock is on the page): OpenVibe Chat comes first; it closes the sheet and hands the Dock the title
+ // and the link, and nothing is sent from here.
+ await p.evaluate("window.__docked = []; window.OpenVibeDock = { share: (o) => { window.__docked.push(o); }, isOpen: () => false }");
+ await p.evaluate("document.getElementById('send').click()");
+ await wait(300);
+ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-grid .ov-share-tile').getAttribute('data-k')"), 'to-ovchat', 'OpenVibe Chat is the first place');
+ assert.strictEqual(await p.evaluate(`${q('to-ovchat')}.textContent`), 'OpenVibe Chat');
+ await p.evaluate(`${q('to-ovchat')}.click()`);
+ await wait(320);
+ assert.deepStrictEqual(await p.evaluate('window.__docked'), [{ text: 'A robot & a wave https://video.test/v/1', title: 'A robot & a wave' }], 'the Dock gets the title and the link');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('dialog.ov-share')"), false, 'the sheet closes');
+ // A Dock that may not send (a read-only Dock token): no OpenVibe Chat.
+ await p.evaluate("window.OpenVibeDock = { share: () => {}, isOpen: () => false, canSend: () => false }");
+ await p.evaluate("document.getElementById('send').click()");
+ await wait(300);
+ assert.strictEqual(await p.evaluate(`!!${q('to-ovchat')}`), false, 'no OpenVibe Chat when the Dock cannot send');
+ await p.evaluate(`${q('close')}.click()`);
+ await wait(320);
+ await p.evaluate("delete window.OpenVibeDock");
// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
// Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
await p.evaluate("document.getElementById('theirs').click()");
await p.evaluate("document.getElementById('theirs').click()");
await wait(300);
await wait(300);