openvibers/openvibe.shared history.js
history.js
| 1 | /** |
| 2 | * openvibe-shared/history.js — cross-site history for a signed-in account. |
| 3 | * |
| 4 | * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase }) |
| 5 | * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}] |
| 6 | * OpenVibeHistory.clear({ token }) → wipes it |
| 7 | * |
| 8 | * Every property records what the account touched — a tool used, a stream watched, a paste |
| 9 | * opened, a game played — so "Recently used" in the shared navbar and the History tab on the |
| 10 | * Network show one timeline across the whole network instead of one per site. Writes are |
| 11 | * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one) |
| 12 | * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for |
| 13 | * anonymous sessions, and the user can pause or clear the timeline from their account page. |
| 14 | * |
| 15 | * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include |
| 16 | * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token. |
| 17 | */ |
| 18 | (function (root) { |
| 19 | ; |
| 20 | if (root.OpenVibeHistory) return; |
| 21 | |
| 22 | const DEFAULT_API = 'https://openvibe.network'; |
| 23 | const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render |
| 24 | const THROTTLE_MS = 5 * 60 * 1000; |
| 25 | |
| 26 | function token(opts) { |
| 27 | if (opts && opts.token) return opts.token; |
| 28 | const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/); |
| 29 | if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } } |
| 30 | try { return localStorage.getItem('ov_token'); } catch { return null; } |
| 31 | } |
| 32 | |
| 33 | function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; } |
| 34 | |
| 35 | function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } } |
| 36 | function markSent(url) { |
| 37 | try { |
| 38 | const m = sent(); m[url] = Date.now(); |
| 39 | const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k]; |
| 40 | sessionStorage.setItem(SENT_KEY, JSON.stringify(m)); |
| 41 | } catch { /* storage unavailable */ } |
| 42 | } |
| 43 | |
| 44 | /** Guess an icon and service label from the URL when the caller gives none. */ |
| 45 | function inferService(url) { |
| 46 | let host = ''; |
| 47 | try { host = new URL(url, location.href).hostname; } catch { return { service: null }; } |
| 48 | const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/); |
| 49 | if (!m) return { service: null }; |
| 50 | return { service: m[2], sub: m[1] || null }; |
| 51 | } |
| 52 | |
| 53 | const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' }; |
| 54 | |
| 55 | async function record(entry, opts) { |
| 56 | const t = token(opts); |
| 57 | if (!t || !entry) return false; |
| 58 | const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000); |
| 59 | if (!url) return false; |
| 60 | const last = sent()[url] || 0; |
| 61 | if (!entry.force && Date.now() - last < THROTTLE_MS) return false; |
| 62 | markSent(url); |
| 63 | const inf = inferService(url); |
| 64 | const body = { |
| 65 | type: String(entry.type || 'page').slice(0, 32), |
| 66 | title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200), |
| 67 | url, |
| 68 | image_url: String(entry.image_url || entry.picture || '').slice(0, 2000) || null, |
| 69 | private: entry.private === true, |
| 70 | mature: entry.mature === true, |
| 71 | icon: entry.icon || ICONS[entry.type] || null, |
| 72 | service: entry.service || inf.service || null, |
| 73 | sub: entry.sub || inf.sub || null, |
| 74 | meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null, |
| 75 | }; |
| 76 | try { |
| 77 | const res = await fetch(`${api(opts)}/api/history`, { |
| 78 | method: 'POST', keepalive: true, |
| 79 | headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, |
| 80 | body: JSON.stringify(body), |
| 81 | }); |
| 82 | return res.ok; |
| 83 | } catch { return false; } |
| 84 | } |
| 85 | |
| 86 | async function recent(opts) { |
| 87 | const t = token(opts); |
| 88 | if (!t) return []; |
| 89 | const q = new URLSearchParams(); |
| 90 | if (opts && opts.limit) q.set('limit', String(opts.limit)); |
| 91 | if (opts && opts.service) q.set('service', opts.service); |
| 92 | if (opts && opts.type) q.set('type', opts.type); |
| 93 | try { |
| 94 | const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } }); |
| 95 | if (!res.ok) return []; |
| 96 | const data = await res.json(); |
| 97 | return (data && data.items) || []; |
| 98 | } catch { return []; } |
| 99 | } |
| 100 | |
| 101 | async function clear(opts) { |
| 102 | const t = token(opts); |
| 103 | if (!t) return false; |
| 104 | try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; } |
| 105 | } |
| 106 | |
| 107 | async function remove(id, opts) { |
| 108 | const t = token(opts); |
| 109 | if (!t) return false; |
| 110 | try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; } |
| 111 | } |
| 112 | |
| 113 | root.OpenVibeHistory = { record, recent, clear, remove, ICONS }; |
| 114 | })(typeof window !== 'undefined' ? window : globalThis); |
| 115 |