openvibers/openvibe.shared Commits
The Dock's Chats tab: your conversations and DMs on every page (4.52.0) (#198)
Changes
@@@ -6,6 +6,24 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.52.0 — 2026-10-30++++- **The Dock's Chats tab** (Dock plan K-D2). The person's conversations and DMs, on every OpenVibe site.++ - **Where they come from:** OpenVibe.Chat's own API with the person's Network token (`GET /api/dm/conversations`,++ `/unread`, `…/messages`, `…/read`; Chat checks they are in each one).++ - **The list:** named for the other person or the group, with the last line ("You: …" when it was theirs), the++ time and the unread count.++ - **A thread opens in place:** their bubbles and yours; Enter sends and Shift+Enter is a new line. It polls for new++ messages every 5 s while it is open, is marked read as it is shown, and links out to Chat.++ - The open chat is the Dock's state, so it follows you between sites. The tab counts Chat's unread messages.++ - **Safety:** what people wrote is always text, never markup, and only https pictures are shown.++- **A tap that redraws its own panel no longer closes the phone sheet.** Opening a chat replaced the list, so the++ tapped node was detached by the time the tap-outside check ran; it now reads the event's path at dispatch.++- **Narrow Docks** (under 440 px) drop the tab icons, so three tabs, their counts, the pin and × fit.++- `dockChatBase` overrides Chat's origin for tests and staging. dock.js is 12.3 KB brotli, for signed-in people only.++- `test/dock-chrome.test.js` adds the Chats scenario: the list and its counts, the thread (text, never markup), Enter++ sends, read receipts, the state, and the phone sheet staying open.++## 4.51.0 — 2026-10-30- **The Dock** (`dock.js`; the Dock plan of 2026-10-30, K-D1; master plan L13). The owner, 10-30: your agent and your@@@ -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.51.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.52.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.@@@ -543,7 +543,9 @@ Network's, so it follows the person from site to site:The account menu has a Dock tile. A site can call `OpenVibeNavbar.openDock('agent' | 'activity')`, pass `dock: false`to leave it out, or pass `dockApiBase` for tests and staging. Desktop toasts stack above the pill (`--ov-dock-clear`).--Chats join in K-D2 and the agent's live screen in K-D3.++**Chats** (4.52.0) is the person's conversations and DMs from OpenVibe.Chat's API++(`/api/dm/conversations`, read and answered in place, unread counts on the tab; `dockChatBase` overrides Chat's origin).++The agent's live screen joins in K-D3.**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every tenminutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.51.0",++ "version": "4.52.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)",@@@ -13,8 +13,9 @@// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.--// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place) and Activity--// (notification-ui.js renderDock(): the bell's list). Chats join in K-D2, the agent's live screen in K-D3.++// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place), Chats (K-D2: the++// person's conversations and DMs from OpenVibe.Chat's own API, GET /api/dm/conversations, opened in place, read and++// answered there) and Activity (notification-ui.js renderDock(): the bell's list). The agent's live screen is K-D3.// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.@@@ -32,8 +33,11 @@pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',++ chat: '<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"/>',++ back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',++ send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',};-- const TABS = [['agent', 'Agents', G.spark], ['activity', 'Activity', G.bell]];++ const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;@@@ -169,6 +173,48 @@.ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }++ .ovdk { container-type: inline-size; }++ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }++ .ovdk-muted { color: var(--text-muted, #8b8b96); }++ .ovdk-i { display: inline-grid; place-items: center; }++ .ovdk-i svg { width: 18px; height: 18px; }++ .ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }++ .ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;++ background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }++ .ovdk-chat:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, transparent); }++ .ovdk-chat:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: -2px; }++ .ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;++ color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }++ .ovdk-av img { width: 100%; height: 100%; object-fit: cover; }++ .ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }++ .ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .is-unread .ovdk-chat-b b, .is-unread .ovdk-chat-p { color: var(--text-primary, #ececf1); font-weight: 750; }++ .ovdk-chat-m { display: grid; justify-items: end; gap: 4px; font-size: 11.5px; color: var(--text-muted, #8b8b96); font-variant-numeric: tabular-nums; }++ .ovdk-unread { min-width: 20px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; text-align: center; font-size: 11px; font-weight: 800;++ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++ .ovdk-thread-h { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovdk-thread-h b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; }++ .ovdk-back { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; display: grid; place-items: center; }++ .ovdk-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }++ .ovdk-msgs { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column; gap: 3px; }++ .ovdk-msg { display: grid; justify-items: start; max-width: 84%; margin-top: 8px; }++ .ovdk-msg.is-cont { margin-top: 0; }++ .ovdk-msg.is-mine { align-self: flex-end; justify-items: end; }++ .ovdk-who { margin: 0 0 3px 10px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #8b8b96); }++ .ovdk-text { padding: 8px 12px; border-radius: 16px; font-size: 14px; line-height: 1.42; white-space: pre-wrap; overflow-wrap: anywhere;++ background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }++ .ovdk-msg.is-mine .ovdk-text { color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++ .ovdk-send { flex: none; display: flex; align-items: flex-end; gap: 8px; margin: 8px 10px 10px; padding: 4px 4px 4px 12px; border-radius: 16px;++ border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }++ .ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }++ .ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }++ .ovdk-go { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;++ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }++ .ovdk-go:disabled { opacity: .55; cursor: default; }++ .ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }++ .ovdk-err[hidden] { display: none; }++ .ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }@media (max-width: 640px) {.ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }@@@ -214,7 +260,14 @@el.querySelector('[data-act="close"]').addEventListener('click', () => close());el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });-- document.addEventListener('click', (e) => { if (isOpen() && phone() && !el.contains(e.target) && !(pill && pill.contains(e.target))) close(); });++ // A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a++ // chat) leaves its target detached, and must not read as outside.++ document.addEventListener('click', (e) => {++ if (!isOpen() || !phone()) return;++ const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];++ if (path.includes(el) || (pill && path.includes(pill)) || (e.target && e.target.isConnected === false)) return;++ close();++ });root.addEventListener('resize', () => { if (isOpen()) layout(); });bindMove();bindSize();@@@ -323,6 +376,8 @@const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };n('agent', agent, c.waiting ? 'waiting' : '');n('activity', c.unread || 0);++ n('chats', chats.unread || 0);++ el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chats, ${chats.unread} unread` : 'Chats');el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');}@@@ -334,6 +389,7 @@}function setTab(id, remember) {tab = TABS.some(([t]) => t === id) ? id : 'activity';++ if (tab !== 'chats') clearTimeout(chats.timer);el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });if (remember && state().tab !== tab) save({ tab });@@@ -342,6 +398,7 @@async function fill(id) {const panel = el.querySelector(`[data-panel="${id}"]`);++ 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 (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }mounts[id] = { refresh: null };panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';@@@ -374,6 +431,156 @@panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;}++ // ── Chats (Dock plan K-D2) ───────────────────────────────++ // 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 chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');++ const me = () => String(o.me || '');++ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };++ function chatCall(path, init = {}) {++ const t = typeof o.token === 'function' ? o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {++ const b = await r.json().catch(() => ({}));++ if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }++ return b;++ });++ }++ function readUnread() {++ clearTimeout(chats.unreadT);++ chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });++ if (isOpen()) chats.unreadT = setTimeout(readUnread, 30000);++ }++ const when = (iso) => {++ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';++ const m = Math.max(0, Math.round((Date.now() - t) / 60000));++ return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;++ };++ function avatar(p, group) {++ const a = h('span', 'ovdk-av');++ const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';++ if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); }++ else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';++ return a;++ }++ const others = (c) => (c.participants || []).filter((p) => p.username !== me());++ const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';++ function chatFoot(panel) {++ const foot = h('div', 'ovdk-foot');++ const a = h('a', '', 'Open Chat →'); a.href = `${chatBase()}/messages`; a.target = '_blank'; a.rel = 'noopener';++ foot.append(h('span', 'ovdk-muted', 'Your conversations and DMs'), a);++ panel.append(foot);++ }++ async function chatList() {++ clearTimeout(chats.timer);++ chats.open = null;++ const panel = chats.panel; if (!panel) return;++ if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }++ 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 (chats.open || tab !== 'chats') return;++ panel.textContent = '';++ const scroll = h('div', 'ovdk-scroll');++ if (!chats.list.length) {++ const none = h('div', 'ovdk-none');++ none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));++ scroll.append(none);++ } else {++ const ul = h('ul', 'ovdk-chats');++ chats.list.forEach((c) => {++ const li = h('li'), b = h('button', 'ovdk-chat');++ b.type = 'button';++ const unread = Number(c.unread_count) || 0;++ const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());++ const body = h('span', 'ovdk-chat-b');++ body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));++ const meta = h('span', 'ovdk-chat-m');++ meta.append(h('time', '', when(c.last_message_at || c.updated_at)));++ if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));++ b.append(avatar(others(c)[0] || {}, !!c.is_group), body, meta);++ b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);++ if (unread) li.className = 'is-unread';++ b.addEventListener('click', () => openChat(String(c.id), true));++ li.append(b); ul.append(li);++ });++ scroll.append(ul);++ }++ panel.append(scroll);++ chatFoot(panel);++ }++ function bubble(m, prev) {++ const mine = m.username === me();++ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);++ if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));++ li.append(h('span', 'ovdk-text', String(m.message || '')));++ li.title = new Date(m.created_at).toLocaleString();++ return li;++ }++ async function openChat(id, remember) {++ clearTimeout(chats.timer);++ const panel = chats.panel; if (!panel) return;++ chats.open = String(id); chats.msgs = []; chats.last = 0;++ if (remember) save({ chat: chats.open });++ const conv = (chats.list || []).find((c) => String(c.id) === chats.open);++ panel.textContent = '';++ const head = h('div', 'ovdk-thread-h');++ const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));++ back.addEventListener('click', () => { save({ chat: null }); chatList(); });++ const title = h('b', '', conv ? titleOf(conv) : 'Conversation');++ head.append(back, title);++ // Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.++ if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });++ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');++ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);++ const form = h('form', 'ovdk-send');++ const box = h('textarea'); box.rows = 1; box.maxLength = 4000; box.placeholder = 'Message'; box.setAttribute('aria-label', 'Message');++ const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));++ const err = h('p', 'ovdk-err'); err.hidden = true;++ form.append(box, go);++ panel.append(head, scroll, err, form);++ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };++ box.addEventListener('input', 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();++ const text = box.value.trim();++ if (!text || chats.busy) return;++ chats.busy = true; go.disabled = true; err.hidden = true;++ try {++ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });++ box.value = ''; fit();++ await pull(list, scroll);++ } catch (x) {++ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';++ err.hidden = false;++ } finally { chats.busy = false; go.disabled = false; box.focus(); }++ });++ list.append(h('li', 'ovdk-wait', null));++ list.firstChild.innerHTML = '<span></span>';++ await pull(list, scroll, true);++ setTimeout(() => { try { box.focus({ preventScroll: true }); } 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) {++ const id = chats.open;++ let got = [];++ try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) {++ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }++ return;++ }++ if (id !== chats.open) return;++ if (first) list.textContent = '';++ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;++ got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });++ if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));++ if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;++ if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });++ clearTimeout(chats.timer);++ if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);++ }++// ── The pill ─────────────────────────────────────────────let pill = null, readT = 0, bound = false;function store(v) {@@@ -451,6 +658,7 @@el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');setTab(want, false);++ readUnread();if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);}@@@ -462,6 +670,7 @@el.removeAttribute('aria-modal');document.documentElement.classList.remove('ovdk-sheet-open');clear(0);++ clearTimeout(chats.timer); clearTimeout(chats.unreadT);save({ open: false });if (inside) {const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);@@@ -989,9 +989,9 @@// ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js-- // keeps { off } for a day) is not asked again. dockApiBase overrides the Network (tests, staging).++ // keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');-- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), sibling });++ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, me: _config.user && _config.user.username, sibling });const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';function loadDock(then) {if (root.OpenVibeDock) return then();@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.51.0",++ "version": "4.52.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -7,7 +7,10 @@// bell's list, Escape closes and gives the focus back to the pill;// • every change reaches the Network as one debounced PUT; on a desktop the head drags the card to a corner and the// free corner resizes it inside 320-720 × 360-900; an open Dock reopens by itself on a desktop, never on a phone;--// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen.++// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;++// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs++// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone++// never reads as a tap outside the sheet.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('assert');const fs = require('fs');@@@ -43,14 +46,14 @@ const NOTES = [// The stand-in's state for the scenario under way.let scenario = {};--const puts = [], decided = [];++const puts = [], decided = [], said = [], reads = [];const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,"><style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head><body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main><script src="/navbar.js"></script>--<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin,++<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin,${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;@@@ -70,6 +73,24 @@ const server = http.createServer((req, res) => {if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);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/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: [++ { id: 11, name: null, is_group: false, last_message: 'see the shots?', last_sender_id: 2, last_message_at: iso(240e3), unread_count: 2, participants: [ada, kai] },++ { id: 12, name: 'Design crit', is_group: true, last_message: 'Shipped it', last_sender_id: 1, last_message_at: iso(3600e3), unread_count: 0, participants: [ada, kai, jo] },++ ] });++ }++ const dm = /^\/api\/dm\/conversations\/(\d+)\/(messages|read)$/.exec(u.pathname);++ if (dm && dm[2] === 'read') { reads.push(dm[1]); return json(200, { ok: true }); }++ if (dm && req.method === 'POST') { said.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').message); return json(201, { message: { id: 900 + said.length } }); }++ if (dm) {++ const after = Number(u.searchParams.get('after')) || 0;++ const all = [{ id: 101, username: 'kai', display_name: 'Kai Moreno', message: 'see the shots?', created_at: iso(240e3) }, { id: 102, username: 'ada', display_name: 'Ada', message: '<b>on it</b>', created_at: iso(120e3) }]++ .concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));++ return json(200, { messages: all.filter((m) => m.id > after) });++ }if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });@@@ -160,6 +181,34 @@ async function drag(p, from, to) {await p.close();}++ // ── Chats (K-D2): the list, a thread in place, Enter sends, read, remembered; a phone keeps the sheet open. ──++ {++ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };++ puts.length = 0; said.length = 0; reads.length = 0;++ const p = await fresh(390, 780);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, PILL, 'the pill');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, "!!document.getElementById('ovdk-tab-chats')", 'the Chats tab');++ await until(p, "document.querySelector('[data-n=chats]').textContent === '3'", 'the unread count from Chat');++ await p.evaluate("document.getElementById('ovdk-tab-chats').click()");++ const rows = await until(p, "(() => { const r = [...document.querySelectorAll('.ovdk .ovdk-chat')]; return r.length ? r.map((b) => b.getAttribute('aria-label') + ' | ' + b.querySelector('.ovdk-chat-p').textContent) : null; })()", 'the conversations');++ assert.deepStrictEqual(rows, ['Kai Moreno, 2 unread | see the shots?', 'Design crit | You: Shipped it'], 'named for the other person (or the group), unread first, mine as "You:"');++ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");++ const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');++ assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');++ await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");++ await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');++ assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');++ assert.ok(reads.includes('11'), 'the thread is marked read as it is shown');++ await wait(900);++ assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`);++ await p.evaluate("document.querySelector('.ovdk-back').click()");++ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'back to the list');++ await p.close();++ }++// ── 320 px: nothing runs off the screen, closed or open. ──{scenario = { counts: { running: 1, waiting: 0, unread: 0 } };@@@ -223,7 +272,7 @@ async function drag(p, from, to) {assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');await p.close();}-- console.log('dock-chrome: the pill only when it matters, Agents and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');++ console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');} finally {await browser.close();server.close();
@@ -6,6 +6,24 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,24 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.52.0 — 2026-10-30
+
+- **The Dock's Chats tab** (Dock plan K-D2). The person's conversations and DMs, on every OpenVibe site.
+ - **Where they come from:** OpenVibe.Chat's own API with the person's Network token (`GET /api/dm/conversations`,
+ `/unread`, `…/messages`, `…/read`; Chat checks they are in each one).
+ - **The list:** named for the other person or the group, with the last line ("You: …" when it was theirs), the
+ time and the unread count.
+ - **A thread opens in place:** their bubbles and yours; Enter sends and Shift+Enter is a new line. It polls for new
+ messages every 5 s while it is open, is marked read as it is shown, and links out to Chat.
+ - The open chat is the Dock's state, so it follows you between sites. The tab counts Chat's unread messages.
+ - **Safety:** what people wrote is always text, never markup, and only https pictures are shown.
+- **A tap that redraws its own panel no longer closes the phone sheet.** Opening a chat replaced the list, so the
+ tapped node was detached by the time the tap-outside check ran; it now reads the event's path at dispatch.
+- **Narrow Docks** (under 440 px) drop the tab icons, so three tabs, their counts, the pin and × fit.
+- `dockChatBase` overrides Chat's origin for tests and staging. dock.js is 12.3 KB brotli, for signed-in people only.
+- `test/dock-chrome.test.js` adds the Chats scenario: the list and its counts, the thread (text, never markup), Enter
+ sends, read receipts, the state, and the phone sheet staying open.
+
## 4.51.0 — 2026-10-30
## 4.51.0 — 2026-10-30
- **The Dock** (`dock.js`; the Dock plan of 2026-10-30, K-D1; master plan L13). The owner, 10-30: your agent and your
- **The Dock** (`dock.js`; the Dock plan of 2026-10-30, K-D1; master plan L13). The owner, 10-30: your agent and your
@@ -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.51.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.52.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.
@@ -543,7 +543,9 @@ Network's, so it follows the person from site to site:
@@ -543,7 +543,9 @@ Network's, so it follows the person from site to site:
The account menu has a Dock tile. A site can call `OpenVibeNavbar.openDock('agent' | 'activity')`, pass `dock: false`
The account menu has a Dock tile. A site can call `OpenVibeNavbar.openDock('agent' | 'activity')`, pass `dock: false`
to leave it out, or pass `dockApiBase` for tests and staging. Desktop toasts stack above the pill (`--ov-dock-clear`).
to leave it out, or pass `dockApiBase` for tests and staging. Desktop toasts stack above the pill (`--ov-dock-clear`).
-Chats join in K-D2 and the agent's live screen in K-D3.
+**Chats** (4.52.0) is the person's conversations and DMs from OpenVibe.Chat's API
+(`/api/dm/conversations`, read and answered in place, unread counts on the tab; `dockChatBase` overrides Chat's origin).
+The agent's live screen joins in K-D3.
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.51.0",
+ "version": "4.52.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)",
@@ -13,8 +13,9 @@
@@ -13,8 +13,9 @@
// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
-// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place) and Activity
-// (notification-ui.js renderDock(): the bell's list). Chats join in K-D2, the agent's live screen in K-D3.
+// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place), Chats (K-D2: the
+// person's conversations and DMs from OpenVibe.Chat's own API, GET /api/dm/conversations, opened in place, read and
+// answered there) and Activity (notification-ui.js renderDock(): the bell's list). The agent's live screen is K-D3.
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
@@ -32,8 +33,11 @@
@@ -32,8 +33,11 @@
pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',
pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
+ chat: '<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"/>',
+ back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
+ send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
};
};
- const TABS = [['agent', 'Agents', G.spark], ['activity', 'Activity', G.bell]];
+ const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
@@ -169,6 +173,48 @@
@@ -169,6 +173,48 @@
.ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }
.ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
+ .ovdk { container-type: inline-size; }
+ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }
+ .ovdk-muted { color: var(--text-muted, #8b8b96); }
+ .ovdk-i { display: inline-grid; place-items: center; }
+ .ovdk-i svg { width: 18px; height: 18px; }
+ .ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }
+ .ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;
+ background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
+ .ovdk-chat:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, transparent); }
+ .ovdk-chat:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: -2px; }
+ .ovdk-av { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; overflow: hidden; font-weight: 800; font-size: 15px;
+ color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
+ .ovdk-av img { width: 100%; height: 100%; object-fit: cover; }
+ .ovdk-chat-b { display: grid; gap: 1px; min-width: 0; }
+ .ovdk-chat-b b { font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovdk-chat-p { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .is-unread .ovdk-chat-b b, .is-unread .ovdk-chat-p { color: var(--text-primary, #ececf1); font-weight: 750; }
+ .ovdk-chat-m { display: grid; justify-items: end; gap: 4px; font-size: 11.5px; color: var(--text-muted, #8b8b96); font-variant-numeric: tabular-nums; }
+ .ovdk-unread { min-width: 20px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; text-align: center; font-size: 11px; font-weight: 800;
+ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+ .ovdk-thread-h { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovdk-thread-h b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; }
+ .ovdk-back { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; display: grid; place-items: center; }
+ .ovdk-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
+ .ovdk-msgs { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column; gap: 3px; }
+ .ovdk-msg { display: grid; justify-items: start; max-width: 84%; margin-top: 8px; }
+ .ovdk-msg.is-cont { margin-top: 0; }
+ .ovdk-msg.is-mine { align-self: flex-end; justify-items: end; }
+ .ovdk-who { margin: 0 0 3px 10px; font-size: 11.5px; font-weight: 700; color: var(--text-muted, #8b8b96); }
+ .ovdk-text { padding: 8px 12px; border-radius: 16px; font-size: 14px; line-height: 1.42; white-space: pre-wrap; overflow-wrap: anywhere;
+ background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
+ .ovdk-msg.is-mine .ovdk-text { color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+ .ovdk-send { flex: none; display: flex; align-items: flex-end; gap: 8px; margin: 8px 10px 10px; padding: 4px 4px 4px 12px; border-radius: 16px;
+ border: 1px solid var(--border-light, var(--border, rgba(255,255,255,.14))); background: var(--bg-primary, #12141b); }
+ .ovdk-send:focus-within { border-color: var(--accent, #3b82f6); }
+ .ovdk-send textarea { flex: 1; min-width: 0; box-sizing: border-box; min-height: 36px; max-height: 140px; padding: 8px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 14px; line-height: 1.4; }
+ .ovdk-go { width: 40px; height: 40px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
+ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
+ .ovdk-go:disabled { opacity: .55; cursor: default; }
+ .ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
+ .ovdk-err[hidden] { display: none; }
+ .ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
@media (max-width: 640px) {
@media (max-width: 640px) {
.ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;
.ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;
border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }
border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }
@@ -214,7 +260,14 @@
@@ -214,7 +260,14 @@
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
- document.addEventListener('click', (e) => { if (isOpen() && phone() && !el.contains(e.target) && !(pill && pill.contains(e.target))) close(); });
+ // A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
+ // chat) leaves its target detached, and must not read as outside.
+ document.addEventListener('click', (e) => {
+ if (!isOpen() || !phone()) return;
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
+ if (path.includes(el) || (pill && path.includes(pill)) || (e.target && e.target.isConnected === false)) return;
+ close();
+ });
root.addEventListener('resize', () => { if (isOpen()) layout(); });
root.addEventListener('resize', () => { if (isOpen()) layout(); });
bindMove();
bindMove();
bindSize();
bindSize();
@@ -323,6 +376,8 @@
@@ -323,6 +376,8 @@
const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };
const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };
n('agent', agent, c.waiting ? 'waiting' : '');
n('agent', agent, c.waiting ? 'waiting' : '');
n('activity', c.unread || 0);
n('activity', c.unread || 0);
+ n('chats', chats.unread || 0);
+ el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chats, ${chats.unread} unread` : 'Chats');
el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));
el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));
el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');
el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');
}
}
@@ -334,6 +389,7 @@
@@ -334,6 +389,7 @@
}
}
function setTab(id, remember) {
function setTab(id, remember) {
tab = TABS.some(([t]) => t === id) ? id : 'activity';
tab = TABS.some(([t]) => t === id) ? id : 'activity';
+ if (tab !== 'chats') clearTimeout(chats.timer);
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
if (remember && state().tab !== tab) save({ tab });
if (remember && state().tab !== tab) save({ tab });
@@ -342,6 +398,7 @@
@@ -342,6 +398,7 @@
async function fill(id) {
async function fill(id) {
const panel = el.querySelector(`[data-panel="${id}"]`);
const panel = el.querySelector(`[data-panel="${id}"]`);
+ 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 (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
@@ -374,6 +431,156 @@
@@ -374,6 +431,156 @@
panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;
panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;
}
}
+ // ── Chats (Dock plan K-D2) ───────────────────────────────
+ // 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 chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
+ const me = () => String(o.me || '');
+ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
+ function chatCall(path, init = {}) {
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
+ const b = await r.json().catch(() => ({}));
+ if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ return b;
+ });
+ }
+ function readUnread() {
+ clearTimeout(chats.unreadT);
+ chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });
+ if (isOpen()) chats.unreadT = setTimeout(readUnread, 30000);
+ }
+ const when = (iso) => {
+ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
+ const m = Math.max(0, Math.round((Date.now() - t) / 60000));
+ return m < 1 ? 'now' : m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} d`;
+ };
+ function avatar(p, group) {
+ const a = h('span', 'ovdk-av');
+ const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
+ if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); }
+ else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
+ return a;
+ }
+ const others = (c) => (c.participants || []).filter((p) => p.username !== me());
+ const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
+ function chatFoot(panel) {
+ const foot = h('div', 'ovdk-foot');
+ const a = h('a', '', 'Open Chat →'); a.href = `${chatBase()}/messages`; a.target = '_blank'; a.rel = 'noopener';
+ foot.append(h('span', 'ovdk-muted', 'Your conversations and DMs'), a);
+ panel.append(foot);
+ }
+ async function chatList() {
+ clearTimeout(chats.timer);
+ chats.open = null;
+ const panel = chats.panel; if (!panel) return;
+ if (!chats.list) { panel.innerHTML = '<div class="ovdk-wait"><span></span></div>'; }
+ 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 (chats.open || tab !== 'chats') return;
+ panel.textContent = '';
+ const scroll = h('div', 'ovdk-scroll');
+ if (!chats.list.length) {
+ const none = h('div', 'ovdk-none');
+ none.append(h('b', '', 'No conversations yet'), document.createTextNode('A message to someone on OpenVibe starts one, and it shows here on every site.'));
+ scroll.append(none);
+ } else {
+ const ul = h('ul', 'ovdk-chats');
+ chats.list.forEach((c) => {
+ const li = h('li'), b = h('button', 'ovdk-chat');
+ b.type = 'button';
+ const unread = Number(c.unread_count) || 0;
+ const mine = (c.participants || []).some((p) => p.id === c.last_sender_id && p.username === me());
+ const body = h('span', 'ovdk-chat-b');
+ body.append(h('b', '', titleOf(c)), h('span', 'ovdk-chat-p', c.last_message ? `${mine ? 'You: ' : ''}${String(c.last_message).replace(/\s+/g, ' ').slice(0, 140)}` : 'No messages yet'));
+ const meta = h('span', 'ovdk-chat-m');
+ meta.append(h('time', '', when(c.last_message_at || c.updated_at)));
+ if (unread) meta.append(h('b', 'ovdk-unread', unread > 99 ? '99+' : String(unread)));
+ b.append(avatar(others(c)[0] || {}, !!c.is_group), body, meta);
+ b.setAttribute('aria-label', `${titleOf(c)}${unread ? `, ${unread} unread` : ''}`);
+ if (unread) li.className = 'is-unread';
+ b.addEventListener('click', () => openChat(String(c.id), true));
+ li.append(b); ul.append(li);
+ });
+ scroll.append(ul);
+ }
+ panel.append(scroll);
+ chatFoot(panel);
+ }
+ function bubble(m, prev) {
+ const mine = m.username === me();
+ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
+ if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
+ li.append(h('span', 'ovdk-text', String(m.message || '')));
+ li.title = new Date(m.created_at).toLocaleString();
+ return li;
+ }
+ async function openChat(id, remember) {
+ clearTimeout(chats.timer);
+ const panel = chats.panel; if (!panel) return;
+ chats.open = String(id); chats.msgs = []; chats.last = 0;
+ if (remember) save({ chat: chats.open });
+ const conv = (chats.list || []).find((c) => String(c.id) === chats.open);
+ panel.textContent = '';
+ const head = h('div', 'ovdk-thread-h');
+ const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
+ back.addEventListener('click', () => { save({ chat: null }); chatList(); });
+ const title = h('b', '', conv ? titleOf(conv) : 'Conversation');
+ head.append(back, title);
+ // Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
+ if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });
+ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
+ const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
+ const form = h('form', 'ovdk-send');
+ const box = h('textarea'); box.rows = 1; box.maxLength = 4000; box.placeholder = 'Message'; box.setAttribute('aria-label', 'Message');
+ const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
+ const err = h('p', 'ovdk-err'); err.hidden = true;
+ form.append(box, go);
+ panel.append(head, scroll, err, form);
+ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
+ box.addEventListener('input', 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();
+ const text = box.value.trim();
+ if (!text || chats.busy) return;
+ chats.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
+ box.value = ''; fit();
+ await pull(list, scroll);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
+ err.hidden = false;
+ } finally { chats.busy = false; go.disabled = false; box.focus(); }
+ });
+ list.append(h('li', 'ovdk-wait', null));
+ list.firstChild.innerHTML = '<span></span>';
+ await pull(list, scroll, true);
+ setTimeout(() => { try { box.focus({ preventScroll: true }); } 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) {
+ const id = chats.open;
+ let got = [];
+ try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) {
+ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }
+ return;
+ }
+ if (id !== chats.open) return;
+ if (first) list.textContent = '';
+ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
+ got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });
+ if (first && !chats.msgs.length) list.append(h('li', 'ovdk-none', 'No messages yet. Say hello.'));
+ if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
+ if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
+ clearTimeout(chats.timer);
+ if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
+ }
+
// ── The pill ─────────────────────────────────────────────
// ── The pill ─────────────────────────────────────────────
let pill = null, readT = 0, bound = false;
let pill = null, readT = 0, bound = false;
function store(v) {
function store(v) {
@@ -451,6 +658,7 @@
@@ -451,6 +658,7 @@
el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
setTab(want, false);
setTab(want, false);
+ readUnread();
if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
@@ -462,6 +670,7 @@
@@ -462,6 +670,7 @@
el.removeAttribute('aria-modal');
el.removeAttribute('aria-modal');
document.documentElement.classList.remove('ovdk-sheet-open');
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clear(0);
+ clearTimeout(chats.timer); clearTimeout(chats.unreadT);
save({ open: false });
save({ open: false });
if (inside) {
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
@@ -989,9 +989,9 @@
@@ -989,9 +989,9 @@
// ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;
// ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;
// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js
// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js
- // keeps { off } for a day) is not asked again. dockApiBase overrides the Network (tests, staging).
+ // keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), sibling });
+ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, me: _config.user && _config.user.username, sibling });
const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';
const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';
function loadDock(then) {
function loadDock(then) {
if (root.OpenVibeDock) return then();
if (root.OpenVibeDock) return then();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.51.0",
+ "version": "4.52.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": {
@@ -7,7 +7,10 @@
@@ -7,7 +7,10 @@
// bell's list, Escape closes and gives the focus back to the pill;
// bell's list, Escape closes and gives the focus back to the pill;
// • every change reaches the Network as one debounced PUT; on a desktop the head drags the card to a corner and the
// • every change reaches the Network as one debounced PUT; on a desktop the head drags the card to a corner and the
// free corner resizes it inside 320-720 × 360-900; an open Dock reopens by itself on a desktop, never on a phone;
// free corner resizes it inside 320-720 × 360-900; an open Dock reopens by itself on a desktop, never on a phone;
-// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen.
+// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;
+// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
+// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
+// never reads as a tap outside the sheet.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -43,14 +46,14 @@ const NOTES = [
@@ -43,14 +46,14 @@ 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 = [];
+const puts = [], decided = [], said = [], reads = [];
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<script src="/navbar.js"></script>
<script src="/navbar.js"></script>
-<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin,
+<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin,
${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}
${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}
links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;
links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;
@@ -70,6 +73,24 @@ const server = http.createServer((req, res) => {
@@ -70,6 +73,24 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);
const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);
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).
+ if (u.pathname === '/api/dm/unread') return json(200, { unread: 3 });
+ 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: [
+ { id: 11, name: null, is_group: false, last_message: 'see the shots?', last_sender_id: 2, last_message_at: iso(240e3), unread_count: 2, participants: [ada, kai] },
+ { id: 12, name: 'Design crit', is_group: true, last_message: 'Shipped it', last_sender_id: 1, last_message_at: iso(3600e3), unread_count: 0, participants: [ada, kai, jo] },
+ ] });
+ }
+ const dm = /^\/api\/dm\/conversations\/(\d+)\/(messages|read)$/.exec(u.pathname);
+ if (dm && dm[2] === 'read') { reads.push(dm[1]); return json(200, { ok: true }); }
+ if (dm && req.method === 'POST') { said.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').message); return json(201, { message: { id: 900 + said.length } }); }
+ if (dm) {
+ const after = Number(u.searchParams.get('after')) || 0;
+ const all = [{ id: 101, username: 'kai', display_name: 'Kai Moreno', message: 'see the shots?', created_at: iso(240e3) }, { id: 102, username: 'ada', display_name: 'Ada', message: '<b>on it</b>', created_at: iso(120e3) }]
+ .concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
+ return json(200, { messages: all.filter((m) => m.id > after) });
+ }
if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });
if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });
if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });
if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });
@@ -160,6 +181,34 @@ async function drag(p, from, to) {
@@ -160,6 +181,34 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── Chats (K-D2): the list, a thread in place, Enter sends, read, remembered; a phone keeps the sheet open. ──
+ {
+ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
+ puts.length = 0; said.length = 0; reads.length = 0;
+ const p = await fresh(390, 780);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, PILL, 'the pill');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, "!!document.getElementById('ovdk-tab-chats')", 'the Chats tab');
+ await until(p, "document.querySelector('[data-n=chats]').textContent === '3'", 'the unread count from Chat');
+ await p.evaluate("document.getElementById('ovdk-tab-chats').click()");
+ const rows = await until(p, "(() => { const r = [...document.querySelectorAll('.ovdk .ovdk-chat')]; return r.length ? r.map((b) => b.getAttribute('aria-label') + ' | ' + b.querySelector('.ovdk-chat-p').textContent) : null; })()", 'the conversations');
+ assert.deepStrictEqual(rows, ['Kai Moreno, 2 unread | see the shots?', 'Design crit | You: Shipped it'], 'named for the other person (or the group), unread first, mine as "You:"');
+ await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
+ const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');
+ assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');
+ await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
+ await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
+ assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
+ assert.ok(reads.includes('11'), 'the thread is marked read as it is shown');
+ await wait(900);
+ assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`);
+ await p.evaluate("document.querySelector('.ovdk-back').click()");
+ await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'back to the list');
+ await p.close();
+ }
+
// ── 320 px: nothing runs off the screen, closed or open. ──
// ── 320 px: nothing runs off the screen, closed or open. ──
{
{
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
@@ -223,7 +272,7 @@ async function drag(p, from, to) {
@@ -223,7 +272,7 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
await p.close();
}
}
- console.log('dock-chrome: the pill only when it matters, Agents and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');
+ console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();