openvibers/openvibe.shared Commits

The Dock's Chats tab: your conversations and DMs on every page (4.52.0) (#198)

OP OpenVibers committed 14 days ago · 3fdac76091 · 7 files, +292 −14

Dock plan K-D2. The person's conversations from OpenVibe.Chat's own API with
their Network token: named for the other person or the group, the last line,
the time and the unread count; a thread opens in place (theirs and mine,
Enter sends, polled every 5 s while open, marked read as shown) and links
out to Chat. The open chat is the Dock's state. What people wrote is text,
never markup; only https pictures.

A tap that redraws its own panel no longer closes the phone sheet: opening a
chat detached the tapped node before the tap-outside check, which now reads
the event's path at dispatch. Narrow Docks drop the tab icons.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ad1cbd1..41875b6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex bd0ac59..4468cd9 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.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the 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 ten
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
ddiff --git a/STATUS.json b/STATUS.json
iindex 26bca0c..73dbb3d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock.js b/dock.js
iindex efe9288..6eee1fa 100644
---- a/dock.js
++++ b/dock.js
@@@ -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);
ddiff --git a/navbar.js b/navbar.js
iindex 638a3d4..497192d 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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();
ddiff --git a/package.json b/package.json
iindex 2ce6a38..6c66756 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 63122b6..dfe73b5 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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();