openvibers/openvibe.shared presence.js
presence.js
| 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 | ; |
| 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 |