openvibers/openvibe.shared Commits

4.131.0: your online status on every site (#319)

OP OpenVibers committed 6 days ago · 827cf1c715 · 8 files, +377 −4

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0cbd62f..03ff409 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,18 @@
# Changelog
++## 4.131.0 — 2026-11-07
++
++- **Your online status, on every site** (the owner, 11-07: "we could have online status, especially in DMs … Discord
++ … people should be able to easily change it off"). New `presence.js`, loaded by the navbar beside itself for a
++ signed-in person (`presence: false` turns it off):
++ - **a dot on your picture** in the bar: online, away, do not disturb, invisible, or live — Network's own answer
++ (`network.presence@1`, the self view), saved per person so every tab paints at once;
++ - **a heartbeat** (`POST /api/v1/me/presence/heartbeat`) while a tab is visible, once a minute across tabs, and
++ `idle: true` after five minutes without input so Network shows Away; the first input after that beats at once;
++ - **Your status** in the account menu: Online (automatic), Do not disturb, Invisible, and a status line of your own
++ (`PUT /api/v1/me/presence`). A card under the bar on a computer, a sheet from the bottom on a phone. Only people
++ you follow or message can see it (Network's rule).
++
## 4.130.0 — 2026-11-07
- **Your things on every site** (the owner, 11-07: "make sure DMs are accessible across all sites … same with open coins
ddiff --git a/README.md b/README.md
iindex 591c499..0bb1aa3 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.130.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.131.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex c82e43e..948d63f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.130.0",
++ "version": "4.131.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)",
@@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
++ "your online status on every site: a dot on your picture, a heartbeat once a minute across tabs, and a picker (Online, Do not disturb, Invisible, a status line) in the account menu (4.131.0)",
"your things on every site: Messages (opens your DMs in place, in the Dock) with Chat's unread count, and the OpenCoins and Vibes balances, on unless a site says no (4.130.0)",
"one menu: the burger opens the OpenVibe menu, this site's pages first and Display at its foot; the old drawer only for sites that ask (4.129.0)",
"the menu rethought around real use: who is live, your places dock, this site's pages, next from here, two places new to you, you and your agents, tools, every site behind a count, jump back in; a click beacon when Network asks (4.128.0)",
ddiff --git a/files.js b/files.js
iindex 5ee7ce4..6e3169d 100644
---- a/files.js
++++ b/files.js
@@@ -22,6 +22,7 @@ const BROWSER = Object.freeze([
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
++ 'presence.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
ddiff --git a/navbar.js b/navbar.js
iindex 1f629d6..c6312ea 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1224,6 +1224,14 @@
try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }
loadDock(() => root.OpenVibeDock.mount(dockOpts()));
}
++ // Your online status (presence.js, 4.131.0): its dot on your picture, a heartbeat, the picker. presence: false turns it off.
++ function mountPresence(u) {
++ if (_config.presence === false || !u || u.is_anon || !_config.token) return;
++ const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u });
++ if (root.OpenVibePresence) return go();
++ if (document.getElementById('ov-presence-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
++ }
/** Open the Dock on a tab: 'agent' | 'activity'. */
function openDock(tab) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open({ tab }); }); }
@@@ -1906,7 +1914,7 @@
queueFx();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
-- setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);
++ setTimeout(() => { mountBell(nav, u); mountDock(u); mountPresence(u); }, 0);
// Dropdown
if (u) {
ddiff --git a/package.json b/package.json
iindex 5c96c03..0198ddb 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.130.0",
++ "version": "4.131.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -47,6 +47,7 @@
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
++ "./presence": "./presence.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-agent": "./dock-agent.js",
ddiff --git a/presence.js b/presence.js
nnew file mode 100644
iindex 0000000..24d755f
---- /dev/null
++++ b/presence.js
@@@ -0,0 +1,245 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe presence (4.131.0; the owner, 2026-11-07: "we could have online status, especially in DMs … Discord …
++// people should be able to easily change it off"). navbar.js loads this beside itself for a signed-in person.
++//
++// OpenVibePresence.start({ token: () => string, apiBase, user }) idempotent: navbar calls it after every render
++// OpenVibePresence.open() the status picker
++//
++// Your dot: a coloured dot on your picture in the bar — online, away, do not disturb, invisible, live — from
++// Network's own answer (network.presence@1, the self view). A heartbeat keeps you online while a tab of any
++// OpenVibe site is visible: POST <apiBase>/api/v1/me/presence/heartbeat, once a minute across all tabs of this site
++// (localStorage ov-presence-beat), { idle: true } after five minutes without a key, click, scroll or touch, so
++// Network turns you Away; the first input after that beats at once. A hidden tab never beats (offline in 3 min).
++// Your status: Online (automatic), Do not disturb (no pings; people see you are busy), Invisible (offline to
++// everyone, nothing else changes), and a short line of your own. PUT <apiBase>/api/v1/me/presence
++// (network.presence-update-request@1). Only people you follow or message can see it (Network's rule).
++// Desktop: a card under the bar. Phones: a sheet from the bottom over a scrim. Escape, × or outside closes it.
++// ═══════════════════════════════════════════════════════════════
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibePresence) return;
++ const BEAT_MS = 60000, IDLE_MS = 5 * 60000;
++ const STATES = {
++ online: ['Online', '#22c55e'], away: ['Away', '#f59e0b'], dnd: ['Do not disturb', '#ef4444'],
++ invisible: ['Invisible', '#94a3b8'], offline: ['Status hidden', '#94a3b8'], live: ['Live now', '#ef4444'],
++ };
++ const CHOICES = [
++ ['auto', 'online', 'Online', 'Shown as online while you are here, away when you step away'],
++ ['dnd', 'dnd', 'Do not disturb', 'No pings or message sounds; people see you are busy'],
++ ['invisible', 'invisible', 'Invisible', 'You look offline to everyone, and can still do everything'],
++ ];
++ let o = null, me = null, timer = 0, lastInput = Date.now(), lastBeat = 0, itemId = null, pop = null, lastFocus = null;
++ const store = {
++ get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } },
++ set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private window */ } },
++ };
++ const keyOf = () => `ov-presence-me:${(o && o.user && (o.user.subject || o.user.id || o.user.username)) || ''}`;
++ function call(path, init) {
++ const t = o && o.token && o.token();
++ if (!t || typeof fetch !== 'function') return Promise.reject(new Error('signed out'));
++ return fetch(`${o.apiBase}${path}`, Object.assign({ credentials: 'omit' }, init, { headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' } }))
++ .then((r) => (r.ok ? r.json() : Promise.reject(Object.assign(new Error(`HTTP ${r.status}`), { status: r.status }))));
++ }
++
++ function injectStyles() {
++ if (document.getElementById('ov-presence-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'ov-presence-styles';
++ s.textContent = `
++ .ovp-wrap { position: relative; display: inline-flex; flex: none; }
++ .ovp-dot { position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%; box-sizing: border-box; pointer-events: none;
++ background: var(--ovp, #22c55e); border: 2.5px solid var(--bg-secondary, #252530); }
++ .ovp-dot[data-state="invisible"], .ovp-dot[data-state="offline"] { background: var(--bg-secondary, #252530); box-shadow: inset 0 0 0 2px var(--ovp); }
++ .ovp-dot[data-state="dnd"]::after { content: ""; position: absolute; left: 1.5px; right: 1.5px; top: 50%; height: 1.5px; margin-top: -.75px; border-radius: 2px; background: var(--bg-secondary, #252530); }
++ .ovp-dot[data-state="away"] { background: radial-gradient(circle at 70% 30%, var(--bg-secondary, #252530) 0 2.2px, var(--ovp) 2.6px); }
++ .ovp-scrim { position: fixed; inset: 0; z-index: 10040; background: rgba(0, 0, 0, .45); animation: ovp-in .18s ease-out; }
++ .ovp-pop { position: fixed; z-index: 10041; top: 60px; right: 12px; width: 340px; max-width: calc(100vw - 24px); box-sizing: border-box; display: grid; gap: 12px; padding: 16px;
++ border-radius: 18px; border: 1px solid var(--border, #333340); background: var(--bg-card, var(--bg-secondary, #1b1d27)); color: var(--text-primary, #ececf1);
++ font: 14px/1.4 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; box-shadow: 0 18px 50px rgba(0, 0, 0, .45); animation: ovp-pop .18s cubic-bezier(.2, .8, .2, 1); }
++ .ovp-head { display: flex; align-items: center; gap: 10px; }
++ .ovp-head b { flex: 1; font-size: 15px; }
++ .ovp-x { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 999px; background: transparent; color: var(--text-muted, #9aa0ae); font-size: 20px; cursor: pointer; }
++ .ovp-x:hover { background: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); color: var(--text-primary, #fff); }
++ .ovp-text { display: flex; gap: 8px; margin: 0; }
++ .ovp-text input { flex: 1; min-width: 0; height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border, #333340); background: var(--bg-primary, #12141b); color: inherit; font: inherit; }
++ .ovp-text input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 0; border-color: transparent; }
++ .ovp-btn { height: 40px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, #333340); background: transparent; color: inherit; font: inherit; font-weight: 650; cursor: pointer; }
++ .ovp-btn:hover { background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
++ .ovp-opts { display: grid; gap: 6px; }
++ .ovp-opt { display: grid; grid-template-columns: 14px minmax(0, 1fr); align-items: center; column-gap: 12px; width: 100%; padding: 11px 12px; border-radius: 14px; text-align: left;
++ border: 1px solid var(--border, #333340); background: transparent; color: inherit; font: inherit; cursor: pointer; transition: border-color .15s, background .15s; }
++ .ovp-opt:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333340)); }
++ .ovp-opt[aria-checked="true"] { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
++ .ovp-opt:focus-visible, .ovp-btn:focus-visible, .ovp-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++ .ovp-opt .ovp-dot { position: static; grid-row: span 2; width: 14px; height: 14px; border-color: var(--bg-card, #1b1d27); }
++ .ovp-opt b { font-size: 14.5px; }
++ .ovp-opt small { font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
++ .ovp-foot { margin: 0; font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
++ .ovp-say { margin: 0; min-height: 1em; font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
++ .ovp-say:empty { display: none; }
++ @keyframes ovp-in { from { opacity: 0; } }
++ @keyframes ovp-pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } }
++ @keyframes ovp-sheet { from { transform: translateY(100%); } }
++ @media (max-width: 640px) {
++ .ovp-pop { top: auto; right: 0; left: 0; bottom: 0; width: auto; max-width: none; border-radius: 22px 22px 0 0; border-bottom: 0;
++ padding: 10px max(16px, env(safe-area-inset-right, 0px)) calc(18px + env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px)); animation: ovp-sheet .24s cubic-bezier(.2, .8, .2, 1); }
++ .ovp-pop::before { content: ""; justify-self: center; width: 40px; height: 4px; border-radius: 99px; background: color-mix(in srgb, var(--text-primary, #fff) 25%, transparent); }
++ .ovp-opt { min-height: 56px; }
++ }
++ @media (prefers-reduced-motion: reduce) { .ovp-scrim, .ovp-pop { animation: none; } }`;
++ document.head.appendChild(s);
++ }
++
++ const stateOf = (v) => (v ? (v.live ? 'live' : v.state) : null);
++ function dot(state) {
++ const d = document.createElement('span');
++ d.className = 'ovp-dot';
++ d.setAttribute('aria-hidden', 'true');
++ d.dataset.state = state;
++ d.style.setProperty('--ovp', (STATES[state] || STATES.offline)[1]);
++ return d;
++ }
++ /** Your dot on your picture in the bar (the bar re-renders: this runs again each time). */
++ function paint() {
++ const img = document.getElementById('openvibe-avatar-btn');
++ const state = stateOf(me);
++ if (!img || !state) return;
++ let wrap = img.parentElement && img.parentElement.classList.contains('ovp-wrap') ? img.parentElement : null;
++ if (!wrap) { wrap = document.createElement('span'); wrap.className = 'ovp-wrap'; img.replaceWith(wrap); wrap.appendChild(img); }
++ const old = wrap.querySelector('.ovp-dot');
++ const d = dot(state);
++ if (old) old.replaceWith(d); else wrap.appendChild(d);
++ const label = (STATES[state] || STATES.offline)[0];
++ img.setAttribute('title', me && me.status_text ? `${label} · ${me.status_text}` : label);
++ }
++ function remember(view) {
++ if (!view || !view.state) return;
++ me = view;
++ store.set(keyOf(), { view, at: Date.now() });
++ paint();
++ if (pop) drawChoices();
++ }
++
++ function beat(force) {
++ if (!o || document.visibilityState === 'hidden') return;
++ const shared = store.get('ov-presence-beat') || 0;
++ if (!force && Date.now() - Math.max(lastBeat, Number(shared) || 0) < BEAT_MS - 5000) {
++ const saved = store.get(keyOf());
++ if (saved && saved.view) { me = saved.view; paint(); }
++ return;
++ }
++ lastBeat = Date.now();
++ store.set('ov-presence-beat', lastBeat);
++ call('/api/v1/me/presence/heartbeat', { method: 'POST', body: JSON.stringify({ idle: Date.now() - lastInput > IDLE_MS }) })
++ .then(remember).catch((e) => { if (e && e.status === 404) stop(); });
++ }
++ function onInput() {
++ const wasIdle = Date.now() - lastInput > IDLE_MS || (me && me.state === 'away');
++ lastInput = Date.now();
++ if (wasIdle && Date.now() - lastBeat > 15000) beat(true);
++ }
++ function onVisible() { if (document.visibilityState === 'visible') beat(false); }
++ function onStorage(e) { if (e && e.key === keyOf()) { const s = store.get(keyOf()); if (s && s.view) { me = s.view; paint(); } } }
++ function stop() {
++ clearInterval(timer); timer = 0;
++ ['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => document.removeEventListener(t, onInput, true));
++ document.removeEventListener('visibilitychange', onVisible);
++ root.removeEventListener && root.removeEventListener('storage', onStorage);
++ }
++
++ // ── The picker ──────────────────────────────────────────────────────────────────────────────────────────────
++ function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
++ function drawChoices() {
++ const box = pop && pop.querySelector('.ovp-opts');
++ if (!box) return;
++ const now = me ? (me.state === 'away' || me.state === 'offline' ? 'online' : me.state) : 'online';
++ box.replaceChildren(...CHOICES.map(([value, state, label, help]) => {
++ const b = el('button', 'ovp-opt');
++ b.type = 'button';
++ b.setAttribute('role', 'radio');
++ b.setAttribute('aria-checked', String(now === state));
++ b.dataset.value = value;
++ const t = el('span');
++ t.style.display = 'grid';
++ t.append(el('b', '', label), el('small', '', help));
++ b.append(dot(state), t);
++ b.addEventListener('click', () => choose({ state: value, show_status: true }, label));
++ return b;
++ }));
++ }
++ function say(text) { const s = pop && pop.querySelector('.ovp-say'); if (s) s.textContent = text; }
++ function choose(body, label) {
++ say('Saving…');
++ return call('/api/v1/me/presence', { method: 'PUT', body: JSON.stringify(body) })
++ .then((view) => { remember(view); say(label ? `${label}.` : 'Saved.'); })
++ .catch(() => say('That did not save. Check your connection and try again.'));
++ }
++ function close() {
++ if (!pop) return;
++ pop.remove(); pop = null;
++ const s = document.querySelector('.ovp-scrim'); if (s) s.remove();
++ document.removeEventListener('keydown', onKey, true);
++ if (lastFocus && lastFocus.focus) lastFocus.focus();
++ }
++ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
++ function open() {
++ if (!o) return;
++ if (pop) return close();
++ injectStyles();
++ lastFocus = document.activeElement;
++ const scrim = el('div', 'ovp-scrim');
++ scrim.addEventListener('click', close);
++ pop = el('div', 'ovp-pop');
++ pop.setAttribute('role', 'dialog');
++ pop.setAttribute('aria-label', 'Your status');
++ const head = el('div', 'ovp-head');
++ const x = el('button', 'ovp-x', '×');
++ x.type = 'button';
++ x.setAttribute('aria-label', 'Close');
++ x.addEventListener('click', close);
++ head.append(el('b', '', 'Your status'), x);
++ const form = el('form', 'ovp-text');
++ const input = el('input');
++ input.type = 'text';
++ input.maxLength = 80;
++ input.placeholder = 'What are you up to?';
++ input.setAttribute('aria-label', 'Your status line');
++ input.value = (me && me.status_text) || '';
++ const save = el('button', 'ovp-btn', 'Save');
++ save.type = 'submit';
++ form.append(input, save);
++ form.addEventListener('submit', (e) => { e.preventDefault(); choose({ status_text: input.value.trim() || null }, input.value.trim() ? 'Status line saved' : 'Status line cleared'); });
++ const opts = el('div', 'ovp-opts');
++ opts.setAttribute('role', 'radiogroup');
++ opts.setAttribute('aria-label', 'Show me as');
++ pop.append(head, form, opts, el('p', 'ovp-say'), el('p', 'ovp-foot', 'Only people you follow or message can see your status. Invisible hides it from everyone.'));
++ document.body.append(scrim, pop);
++ drawChoices();
++ document.addEventListener('keydown', onKey, true);
++ const first = pop.querySelector('.ovp-opt[aria-checked="true"]') || pop.querySelector('.ovp-opt');
++ if (first) first.focus();
++ }
++
++ function start(opts) {
++ if (!opts || !opts.user || opts.user.is_anon) return;
++ const first = !o;
++ o = opts;
++ injectStyles();
++ const saved = store.get(keyOf());
++ if (saved && saved.view && !me) me = saved.view;
++ paint();
++ // One menu row in the account menu (added once; the bar redraws itself when it is added).
++ if (!itemId && root.OpenVibeNavbar && root.OpenVibeNavbar.addMenuItem) {
++ itemId = 'ov-presence';
++ root.OpenVibeNavbar.addMenuItem({ id: itemId, label: 'Your status', icon: 'fa-circle-half-stroke', position: 'before', onClick: () => open() });
++ }
++ if (!first) return;
++ ['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => document.addEventListener(t, onInput, { capture: true, passive: true }));
++ document.addEventListener('visibilitychange', onVisible);
++ if (root.addEventListener) root.addEventListener('storage', onStorage);
++ beat(false);
++ timer = setInterval(() => beat(false), BEAT_MS);
++ }
++
++ root.OpenVibePresence = { start, open, stop: () => { stop(); o = null; }, _test: { STATES, CHOICES, get me() { return me; } } };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/presence-chrome.test.js b/test/presence-chrome.test.js
nnew file mode 100644
iindex 0000000..e1a8e03
---- /dev/null
++++ b/test/presence-chrome.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// Your online status (4.131.0), in a real headless Chrome: signed in, the navbar loads presence.js beside itself, a
++// heartbeat answers "online" and a dot shows on your picture; the picker sets Do not disturb (one PUT with
++// { state: 'dnd', show_status: true }) and the dot follows; a status line saves; Escape closes the picker; a second
++// tab within the minute does not beat again; presence: false loads nothing. Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('presence-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const PAGE = (off) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>
++<body><div id="navbar-mount"></div><main><h1>Chat</h1></main>
++<script>
++ window.__calls = [];
++ let view = { subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', state: 'online', status_text: null, emoji: null, live: false, updated_at: '2026-11-07T10:00:00Z' };
++ window.fetch = async (url, init) => {
++ url = String(url);
++ window.__calls.push({ url, method: (init && init.method) || 'GET', body: init && init.body ? JSON.parse(init.body) : null });
++ if (/\\/api\\/v1\\/me\\/presence\\/heartbeat$/.test(url)) return new Response(JSON.stringify(view), { status: 200 });
++ if (/\\/api\\/v1\\/me\\/presence$/.test(url)) {
++ const b = JSON.parse(init.body);
++ if (b.state === 'dnd') view = Object.assign({}, view, { state: 'dnd' });
++ if ('status_text' in b) view = Object.assign({}, view, { status_text: b.status_text });
++ return new Response(JSON.stringify(view), { status: 200 });
++ }
++ return new Response('{}', { status: 404 });
++ };
++</script>
++<script src="/navbar.js"></script>
++<script>
++ OpenVibeNavbar.init({ service: 'chat', brandName: 'OpenVibe.Chat', auth: 'external', apiBase: 'https://openvibe.network', wallet: false, messages: false, dock: false, notifications: false, recent: false,
++ ${off ? 'presence: false, ' : ''}token: 'x', user: { id: 42, subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', username: 'moosely', display_name: 'moosely', avatar_url: null },
++ links: [{ label: 'Rooms', href: '/', icon: 'fa-comments' }] });
++</script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/on' || u.pathname === '/off') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE(u.pathname === '/off')); }
++ const name = u.pathname.replace(/^\/(shared\/)?/, '');
++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-presence-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (p, expr, ms = 4000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(60); } };
++ try {
++ let context = null;
++ for (const width of [390, 1280]) {
++ context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width });
++ await p.goto(`${base}/on`, { maxMs: 5000 });
++ const state = await until(p, `(() => { const d = document.querySelector('#openvibe-avatar-btn + .ovp-dot, .ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`);
++ assert.strictEqual(state, 'online', `${width}px: the dot says online`);
++ const beats = await p.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`);
++ assert.strictEqual(beats, 1, 'one heartbeat on load');
++ assert.strictEqual(await p.evaluate(`document.getElementById('openvibe-avatar-btn').title`), 'Online');
++
++ await p.evaluate(`OpenVibePresence.open()`);
++ assert.ok(await until(p, `!!document.querySelector('.ovp-pop')`), `${width}px: the picker opens`);
++ await wait(400); // the sheet's slide-in
++ const box = await p.evaluate(`(() => { const r = document.querySelector('.ovp-pop').getBoundingClientRect(); return { l: r.left, r: r.right, b: r.bottom, w: innerWidth, h: innerHeight }; })()`);
++ assert.ok(box.l >= 0 && box.r <= box.w, `${width}px: the picker fits the screen ${JSON.stringify(box)}`);
++ if (width === 390) assert.ok(Math.abs(box.b - box.h) < 2, 'on a phone it is a sheet at the bottom');
++ await p.evaluate(`document.querySelector('.ovp-opt[data-value="dnd"]').click()`);
++ assert.strictEqual(await until(p, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state === 'dnd' && d.dataset.state; })()`), 'dnd', `${width}px: Do not disturb shows`);
++ const put = await p.evaluate(`window.__calls.find((c) => c.method === 'PUT')`);
++ assert.deepStrictEqual(put.body, { state: 'dnd', show_status: true });
++ assert.strictEqual(await p.evaluate(`document.querySelector('.ovp-opt[aria-checked="true"]').dataset.value`), 'dnd', 'the picker marks it');
++ await p.evaluate(`(() => { const i = document.querySelector('.ovp-text input'); i.value = 'Building a world'; i.form.requestSubmit(); })()`);
++ assert.ok(await until(p, `/Building a world/.test(document.getElementById('openvibe-avatar-btn').title)`), 'the status line saves and shows on hover');
++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
++ assert.ok(await until(p, `!document.querySelector('.ovp-pop') && !document.querySelector('.ovp-scrim')`), 'Escape closes it');
++ await p.close();
++ }
++ // A second tab of the same browser within the minute: no heartbeat, the saved answer (Do not disturb) paints.
++ const again = await h.openPage(browser, context, { width: 1280 });
++ await again.goto(`${base}/on`, { maxMs: 5000 });
++ assert.strictEqual(await until(again, `(() => { const d = document.querySelector('.ovp-wrap .ovp-dot'); return d && d.dataset.state; })()`), 'dnd');
++ assert.strictEqual(await again.evaluate(`window.__calls.filter((c) => /heartbeat/.test(c.url)).length`), 0, 'a second tab within the minute does not beat again');
++ await again.close();
++ const off = await h.openPage(browser, context, { width: 1280 });
++ await off.goto(`${base}/off`, { maxMs: 5000 });
++ await wait(400);
++ assert.strictEqual(await off.evaluate(`!!window.OpenVibePresence || !!document.getElementById('ov-presence-loader')`), false, 'presence: false loads nothing');
++ await off.close();
++ console.log('presence-chrome: the dot, one heartbeat a minute across tabs, the picker (sheet on a phone), Do not disturb, a status line, Escape; presence: false ok');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });