openvibers/openvibe.shared presence.js

presence.js

main 16.5 KB · 246 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe presence (4.131.0; the owner, 2026-11-07: "we could have online status, especially in DMs … Discord …
3// people should be able to easily change it off"). navbar.js loads this beside itself for a signed-in person.
4//
5// OpenVibePresence.start({ token: () => string, apiBase, user }) idempotent: navbar calls it after every render
6// OpenVibePresence.open() the status picker
7//
8// Your dot: a coloured dot on your picture in the bar — online, away, do not disturb, invisible, live — from
9// Network's own answer (network.presence@1, the self view). A heartbeat keeps you online while a tab of any
10// OpenVibe site is visible: POST <apiBase>/api/v1/me/presence/heartbeat, once a minute across all tabs of this site
11// (localStorage ov-presence-beat), { idle: true } after five minutes without a key, click, scroll or touch, so
12// Network turns you Away; the first input after that beats at once. A hidden tab never beats (offline in 3 min).
13// Your status: Online (automatic), Do not disturb (no pings; people see you are busy), Invisible (offline to
14// everyone, nothing else changes), and a short line of your own. PUT <apiBase>/api/v1/me/presence
15// (network.presence-update-request@1). Only people you follow or message can see it (Network's rule).
16// Desktop: a card under the bar. Phones: a sheet from the bottom over a scrim. Escape, × or outside closes it.
17// ═══════════════════════════════════════════════════════════════
18(function (root) {
19 'use strict';
20 if (typeof document === 'undefined' || root.OpenVibePresence) return;
21 const BEAT_MS = 60000, IDLE_MS = 5 * 60000;
22 const STATES = {
23 online: ['Online', '#22c55e'], away: ['Away', '#f59e0b'], dnd: ['Do not disturb', '#ef4444'],
24 invisible: ['Invisible', '#94a3b8'], offline: ['Status hidden', '#94a3b8'], live: ['Live now', '#ef4444'],
25 };
26 const CHOICES = [
27 ['auto', 'online', 'Online', 'Shown as online while you are here, away when you step away'],
28 ['dnd', 'dnd', 'Do not disturb', 'No pings or message sounds; people see you are busy'],
29 ['invisible', 'invisible', 'Invisible', 'You look offline to everyone, and can still do everything'],
30 ];
31 let o = null, me = null, timer = 0, lastInput = Date.now(), lastBeat = 0, itemId = null, pop = null, lastFocus = null;
32 const store = {
33 get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } },
34 set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private window */ } },
35 };
36 const keyOf = () => `ov-presence-me:${(o && o.user && (o.user.subject || o.user.id || o.user.username)) || ''}`;
37 function call(path, init) {
38 const t = o && o.token && o.token();
39 if (!t || typeof fetch !== 'function') return Promise.reject(new Error('signed out'));
40 return fetch(`${o.apiBase}${path}`, Object.assign({ credentials: 'omit' }, init, { headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' } }))
41 .then((r) => (r.ok ? r.json() : Promise.reject(Object.assign(new Error(`HTTP ${r.status}`), { status: r.status }))));
42 }
43
44 function injectStyles() {
45 if (document.getElementById('ov-presence-styles')) return;
46 const s = document.createElement('style');
47 s.id = 'ov-presence-styles';
48 s.textContent = `
49 .ovp-wrap { position: relative; display: inline-flex; flex: none; }
50 .ovp-dot { position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%; box-sizing: border-box; pointer-events: none;
51 background: var(--ovp, #22c55e); border: 2.5px solid var(--bg-secondary, #252530); }
52 .ovp-dot[data-state="invisible"], .ovp-dot[data-state="offline"] { background: var(--bg-secondary, #252530); box-shadow: inset 0 0 0 2px var(--ovp); }
53 .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); }
54 .ovp-dot[data-state="away"] { background: radial-gradient(circle at 70% 30%, var(--bg-secondary, #252530) 0 2.2px, var(--ovp) 2.6px); }
55 .ovp-scrim { position: fixed; inset: 0; z-index: 10040; background: rgba(0, 0, 0, .45); animation: ovp-in .18s ease-out; }
56 .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;
57 border-radius: 18px; border: 1px solid var(--border, #333340); background: var(--bg-card, var(--bg-secondary, #1b1d27)); color: var(--text-primary, #ececf1);
58 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); }
59 .ovp-head { display: flex; align-items: center; gap: 10px; }
60 .ovp-head b { flex: 1; font-size: 15px; }
61 .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; }
62 .ovp-x:hover { background: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); color: var(--text-primary, #fff); }
63 .ovp-text { display: flex; gap: 8px; margin: 0; }
64 .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; }
65 .ovp-text input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 0; border-color: transparent; }
66 .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; }
67 .ovp-btn:hover { background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
68 .ovp-opts { display: grid; gap: 6px; }
69 .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;
70 border: 1px solid var(--border, #333340); background: transparent; color: inherit; font: inherit; cursor: pointer; transition: border-color .15s, background .15s; }
71 .ovp-opt:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333340)); }
72 .ovp-opt[aria-checked="true"] { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
73 .ovp-opt:focus-visible, .ovp-btn:focus-visible, .ovp-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
74 .ovp-opt .ovp-dot { position: static; grid-row: span 2; width: 14px; height: 14px; border-color: var(--bg-card, #1b1d27); }
75 .ovp-opt b { font-size: 14.5px; }
76 .ovp-opt small { font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
77 .ovp-foot { margin: 0; font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
78 .ovp-say { margin: 0; min-height: 1em; font-size: 12.5px; color: var(--text-muted, #9aa0ae); }
79 .ovp-say:empty { display: none; }
80 @keyframes ovp-in { from { opacity: 0; } }
81 @keyframes ovp-pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } }
82 @keyframes ovp-sheet { from { transform: translateY(100%); } }
83 @media (max-width: 640px) {
84 .ovp-pop { top: auto; right: 0; left: 0; bottom: 0; width: auto; max-width: none; border-radius: 22px 22px 0 0; border-bottom: 0;
85 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); }
86 .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); }
87 .ovp-opt { min-height: 56px; }
88 }
89 `;
90 document.head.appendChild(s);
91 }
92
93 const stateOf = (v) => (v ? (v.live ? 'live' : v.state) : null);
94 function dot(state) {
95 const d = document.createElement('span');
96 d.className = 'ovp-dot';
97 d.setAttribute('aria-hidden', 'true');
98 d.dataset.state = state;
99 d.style.setProperty('--ovp', (STATES[state] || STATES.offline)[1]);
100 return d;
101 }
102 /** Your dot on your picture in the bar (the bar re-renders: this runs again each time). */
103 function paint() {
104 const img = document.getElementById('openvibe-avatar-btn');
105 const state = stateOf(me);
106 if (!img || !state) return;
107 let wrap = img.parentElement && img.parentElement.classList.contains('ovp-wrap') ? img.parentElement : null;
108 if (!wrap) { wrap = document.createElement('span'); wrap.className = 'ovp-wrap'; img.replaceWith(wrap); wrap.appendChild(img); }
109 const old = wrap.querySelector('.ovp-dot');
110 const d = dot(state);
111 if (old) old.replaceWith(d); else wrap.appendChild(d);
112 const label = (STATES[state] || STATES.offline)[0];
113 img.setAttribute('title', me && me.status_text ? `${label} · ${me.status_text}` : label);
114 }
115 function remember(view) {
116 if (!view || !view.state) return;
117 me = view;
118 store.set(keyOf(), { view, at: Date.now() });
119 paint();
120 if (pop) drawChoices();
121 }
122
123 function beat(force) {
124 if (!o || document.visibilityState === 'hidden') return;
125 const shared = store.get('ov-presence-beat') || 0;
126 if (!force && Date.now() - Math.max(lastBeat, Number(shared) || 0) < BEAT_MS - 5000) {
127 const saved = store.get(keyOf());
128 if (saved && saved.view) { me = saved.view; paint(); }
129 return;
130 }
131 lastBeat = Date.now();
132 store.set('ov-presence-beat', lastBeat);
133 call('/api/v1/me/presence/heartbeat', { method: 'POST', body: JSON.stringify({ idle: Date.now() - lastInput > IDLE_MS }) })
134 .then(remember).catch((e) => { if (e && e.status === 404) stop(); });
135 }
136 function onInput() {
137 const wasIdle = Date.now() - lastInput > IDLE_MS || (me && me.state === 'away');
138 lastInput = Date.now();
139 if (wasIdle && Date.now() - lastBeat > 15000) beat(true);
140 }
141 function onVisible() { if (document.visibilityState === 'visible') beat(false); }
142 function onStorage(e) { if (e && e.key === keyOf()) { const s = store.get(keyOf()); if (s && s.view) { me = s.view; paint(); } } }
143 function stop() {
144 clearInterval(timer); timer = 0;
145 ['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => document.removeEventListener(t, onInput, true));
146 document.removeEventListener('visibilitychange', onVisible);
147 root.removeEventListener && root.removeEventListener('storage', onStorage);
148 }
149
150 // ── The picker ──────────────────────────────────────────────────────────────────────────────────────────────
151 function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
152 function drawChoices() {
153 const box = pop && pop.querySelector('.ovp-opts');
154 if (!box) return;
155 const now = me ? (me.state === 'away' || me.state === 'offline' ? 'online' : me.state) : 'online';
156 box.replaceChildren(...CHOICES.map(([value, state, label, help]) => {
157 const b = el('button', 'ovp-opt');
158 b.type = 'button';
159 b.setAttribute('role', 'radio');
160 b.setAttribute('aria-checked', String(now === state));
161 b.dataset.value = value;
162 const t = el('span');
163 t.style.display = 'grid';
164 t.append(el('b', '', label), el('small', '', help));
165 b.append(dot(state), t);
166 b.addEventListener('click', () => choose({ state: value, show_status: true }, label));
167 return b;
168 }));
169 }
170 function say(text) { const s = pop && pop.querySelector('.ovp-say'); if (s) s.textContent = text; }
171 function choose(body, label) {
172 say('Saving…');
173 return call('/api/v1/me/presence', { method: 'PUT', body: JSON.stringify(body) })
174 .then((view) => { remember(view); say(label ? `${label}.` : 'Saved.'); })
175 .catch(() => say('That did not save. Check your connection and try again.'));
176 }
177 function close() {
178 if (!pop) return;
179 pop.remove(); pop = null;
180 const s = document.querySelector('.ovp-scrim'); if (s) s.remove();
181 document.removeEventListener('keydown', onKey, true);
182 if (lastFocus && lastFocus.focus) lastFocus.focus();
183 }
184 function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
185 function open() {
186 if (!o) return;
187 if (pop) return close();
188 injectStyles();
189 lastFocus = document.activeElement;
190 const scrim = el('div', 'ovp-scrim');
191 scrim.addEventListener('click', close);
192 pop = el('div', 'ovp-pop');
193 pop.setAttribute('role', 'dialog');
194 pop.setAttribute('aria-label', 'Your status');
195 const head = el('div', 'ovp-head');
196 const x = el('button', 'ovp-x', '×');
197 x.type = 'button';
198 x.setAttribute('aria-label', 'Close');
199 x.addEventListener('click', close);
200 head.append(el('b', '', 'Your status'), x);
201 const form = el('form', 'ovp-text');
202 const input = el('input');
203 input.type = 'text';
204 input.maxLength = 80;
205 input.placeholder = 'What are you up to?';
206 input.setAttribute('aria-label', 'Your status line');
207 input.value = (me && me.status_text) || '';
208 const save = el('button', 'ovp-btn', 'Save');
209 save.type = 'submit';
210 form.append(input, save);
211 form.addEventListener('submit', (e) => { e.preventDefault(); choose({ status_text: input.value.trim() || null }, input.value.trim() ? 'Status line saved' : 'Status line cleared'); });
212 const opts = el('div', 'ovp-opts');
213 opts.setAttribute('role', 'radiogroup');
214 opts.setAttribute('aria-label', 'Show me as');
215 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.'));
216 document.body.append(scrim, pop);
217 drawChoices();
218 document.addEventListener('keydown', onKey, true);
219 const first = pop.querySelector('.ovp-opt[aria-checked="true"]') || pop.querySelector('.ovp-opt');
220 if (first) first.focus();
221 }
222
223 function start(opts) {
224 if (!opts || !opts.user || opts.user.is_anon) return;
225 const first = !o;
226 o = opts;
227 injectStyles();
228 const saved = store.get(keyOf());
229 if (saved && saved.view && !me) me = saved.view;
230 paint();
231 // One menu row in the account menu (added once; the bar redraws itself when it is added).
232 if (!itemId && root.OpenVibeNavbar && root.OpenVibeNavbar.addMenuItem) {
233 itemId = 'ov-presence';
234 root.OpenVibeNavbar.addMenuItem({ id: itemId, label: 'Your status', icon: 'fa-circle-half-stroke', position: 'before', onClick: () => open() });
235 }
236 if (!first) return;
237 ['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => document.addEventListener(t, onInput, { capture: true, passive: true }));
238 document.addEventListener('visibilitychange', onVisible);
239 if (root.addEventListener) root.addEventListener('storage', onStorage);
240 beat(false);
241 timer = setInterval(() => beat(false), BEAT_MS);
242 }
243
244 root.OpenVibePresence = { start, open, stop: () => { stop(); o = null; }, _test: { STATES, CHOICES, get me() { return me; } } };
245})(typeof window !== 'undefined' ? window : globalThis);
246