openvibers/openvibe.shared Commits
4.131.0: your online status on every site (#319)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,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)",@@@ -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@@@ -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);// Dropdownif (u) {@@@ -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",nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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); });
@@ -1,5 +1,18 @@
@@ -1,5 +1,18 @@
# Changelog
# 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
## 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
- **Your things on every site** (the owner, 11-07: "make sure DMs are accessible across all sites … same with open coins
@@ -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.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.130.0",
+ "version": "4.131.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)",
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"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 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)",
"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)",
"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)",
"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)",
"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)",
@@ -22,6 +22,7 @@ const BROWSER = Object.freeze([
@@ -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)
'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
'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
'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-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
@@ -1224,6 +1224,14 @@
@@ -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; }
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()));
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'. */
/** Open the Dock on a tab: 'agent' | 'activity'. */
function openDock(tab) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open({ tab }); }); }
function openDock(tab) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open({ tab }); }); }
@@ -1906,7 +1914,7 @@
@@ -1906,7 +1914,7 @@
queueFx();
queueFx();
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// 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
// Dropdown
if (u) {
if (u) {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -47,6 +47,7 @@
@@ -47,6 +47,7 @@
"./notification-live": "./notification-live.js",
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
+ "./presence": "./presence.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-agent": "./dock-agent.js",
"./dock-agent": "./dock-agent.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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); });