openvibers/openvibe.shared Commits

4.144.0: send it on OpenVibe — the share sheet's OpenVibe Chat and OpenVibeDock.share (#332)

OP OpenVibers committed 5 days ago · 0027570a4e · 13 files, +205 −14

* 4.144.0: send it on OpenVibe — the share sheet's OpenVibe Chat and OpenVibeDock.share

A signed-in person's share sheet offers OpenVibe Chat first: the Dock opens on Send to…, they pick a conversation or a
name, and the title and link wait in the box, unsent, until they press Send.

* OpenVibe Chat only when the Dock can send (a read-only Dock token reads chats and never sends): OpenVibeDock.canSend()

* Send to… is dock-share.js, a sibling loaded the first time something is sent (the Dock on every signed-in page stays inside its budget)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 40abdf4..b9c28ff 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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`.
ddiff --git a/README.md b/README.md
iindex df1f5a8..aae16e7 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex d2e533b..8f883d3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-share.js b/dock-share.js
nnew file mode 100644
iindex 0000000..247d2f4
---- /dev/null
++++ b/dock-share.js
@@@ -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);
ddiff --git a/dock.js b/dock.js
iindex e4ee1a6..30ca610 100644
---- a/dock.js
++++ b/dock.js
@@@ -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);
ddiff --git a/files.js b/files.js
iindex 366a023..53ea25d 100644
---- a/files.js
++++ b/files.js
@@@ -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',
ddiff --git a/navbar.js b/navbar.js
iindex 25a9c25..4f4e3ff 100644
---- a/navbar.js
++++ b/navbar.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">.
ddiff --git a/ov-share.js b/ov-share.js
iindex 17dfcb6..2cc1d81 100644
---- a/ov-share.js
++++ b/ov-share.js
@@@ -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)));
ddiff --git a/package.json b/package.json
iindex 8ed1e23..c30eb5c 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex c2acedb..c9cd921 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.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(', ')} };`;
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 43c211a..117fd4c 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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 } };
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex b736680..9737d5d 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.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.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 ──────────────────────────────────────────────
{
ddiff --git a/test/ov-share-chrome.test.js b/test/ov-share-chrome.test.js
iindex b7d32e8..c97a119 100644
---- a/test/ov-share-chrome.test.js
++++ b/test/ov-share-chrome.test.js
@@@ -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);