openvibers/openvibe.network Commits
Your account home is "You, today": who you follow is live, where you left off, your sites, your money (L43) (#460)
Changes
@@@ -220,39 +220,90 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat@media(max-width:600px){.page{padding:12px}-- .page-header{flex-direction:column;text-align:center}-- .page-header .info .badges{justify-content:center}++ .page-header{gap:12px;margin-bottom:24px}++ .page-header .avatar{width:56px;height:56px}++ .page-header .info h1{font-size:20px}.notif-grid{grid-template-columns:1fr auto auto}.notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}}--/* Dashboard Showcase */--@keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-10px); } }--@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }--.dashboard-showcase { margin: 32px 0; padding: 28px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }--.showcase-header { text-align: center; margin-bottom: 24px; }--.showcase-header h2 { font-size: 1.3rem; font-weight: 800; margin-bottom: 6px; }--.showcase-header p { font-size: 12px; color: var(--text-muted); }--.showcase-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }--.showcase-card { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; border-radius: 10px; background: var(--bg-input); border: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; gap: 8px; animation: float ease-in-out infinite; transition: all 0.3s ease; }--.showcase-card:nth-child(1) { animation-delay: 0s; animation-duration: 3s; }--.showcase-card:nth-child(2) { animation-delay: 0.15s; animation-duration: 3.3s; }--.showcase-card:nth-child(3) { animation-delay: 0.3s; animation-duration: 3.6s; }--.showcase-card:nth-child(4) { animation-delay: 0.45s; animation-duration: 3.2s; }--.showcase-card:hover { transform: translateY(-4px); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }--.showcase-card-icon { font-size: 1.4rem; color: var(--accent-light); }--.showcase-card-label { font-size: 11px; font-weight: 700; text-align: center; }--.quick-access { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }--.quick-link { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); text-decoration: none; color: var(--text-secondary); font-size: 10px; font-weight: 600; text-align: center; transition: all 0.25s ease; animation: fadeInScale 0.5s ease both; }--.quick-link i { font-size: 1rem; margin-bottom: 4px; }--.quick-link:nth-child(1) { animation-delay: 0.1s; }--.quick-link:nth-child(2) { animation-delay: 0.15s; }--.quick-link:nth-child(3) { animation-delay: 0.2s; }--.quick-link:nth-child(4) { animation-delay: 0.25s; }--.quick-link:nth-child(5) { animation-delay: 0.3s; }--.quick-link:nth-child(6) { animation-delay: 0.35s; }--.quick-link:hover { border-color: var(--accent); color: var(--accent-light); background: color-mix(in srgb, var(--accent) 8%, transparent); }--@media(max-width:600px){ .showcase-grid { grid-template-columns: repeat(2, 1fr); } .quick-access { grid-template-columns: repeat(3, 1fr); } }++/* You, today (plan L43, 2026-10-31): the account home. What is live for you, what you were doing, your sites, your++ money and your numbers, then Settings. Rails scroll sideways on a phone (never hidden content), two columns from 760 px. */++.page-header .info .greeting { font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 2px; }++.page-header .info .greeting:empty { display: none; }++.page-header .info .badges:empty { display: none; }++.td-settings-btn { flex: none; align-self: center; text-decoration: none; }++.td { display: grid; gap: 22px; margin: -8px 0 36px; }++.td-sec { display: grid; gap: 10px; min-width: 0; }++.td-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }++.td-h h2 { display: flex; align-items: center; gap: 8px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }++.td-sub { font-size: 12.5px; color: var(--text-muted); }++.td-more { font-size: 13px; font-weight: 650; color: var(--accent-light); text-decoration: none; white-space: nowrap; }++.td-more:hover { text-decoration: underline; }++.td-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--live-red); box-shadow: 0 0 0 0 color-mix(in srgb, var(--live-red) 60%, transparent); animation: tdPulse 2s ease-out infinite; }++@keyframes tdPulse { to { box-shadow: 0 0 0 8px transparent; } }++.td-rail { display: grid; grid-auto-flow: column; gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: thin; padding: 2px 2px 8px; margin: 0 -2px; }++.td-rail > * { scroll-snap-align: start; }++#td-live { grid-auto-columns: minmax(220px, 260px); }++#td-continue-list { grid-auto-columns: minmax(180px, 210px); }++.td-live { display: grid; align-content: start; gap: 4px; color: var(--text); text-decoration: none; min-width: 0; }++.td-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 30%, var(--bg-card)), var(--bg-card)); border: 1px solid var(--border); }++.td-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }++.td-live:hover .td-thumb img { transform: scale(1.03); }++.td-live-dot { position: absolute; top: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: var(--live-red); color: #fff; font-size: 10.5px; font-weight: 800; letter-spacing: .06em; }++.td-viewers { position: absolute; bottom: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .62); color: #fff; font-size: 11.5px; font-weight: 700; }++.td-live-name { font-size: 14px; font-weight: 750; margin-top: 4px; }++.td-live-title, .td-cont-t { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++.td-live-title { font-size: 12.5px; color: var(--text-secondary); line-height: 1.35; }++.td-chips { display: flex; flex-wrap: wrap; gap: 8px; }++.td-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px; font-weight: 650; text-decoration: none; cursor: pointer; }++.td-chip:hover, .td-chip:focus-visible { border-color: var(--accent); }++.td-chip-hot { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); }++.td-card, .td-box { border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }++.td-cont { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; padding: 12px; min-height: 124px; color: var(--text); text-decoration: none; min-width: 0; }++.td-cont:hover, .td-site:hover, .td-try:hover { border-color: var(--accent); }++.td-cont-ic, .td-site-ic { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); font-size: 14px; flex: none; }++.td-cont-t { font-size: 13.5px; font-weight: 650; line-height: 1.35; }++.td-cont-m { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.td-grid { display: grid; align-items: start; gap: 16px; }++.td-box { display: grid; align-content: start; gap: 12px; padding: 16px; min-width: 0; }++.td-sites { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }++.td-site { display: grid; justify-items: center; gap: 6px; padding: 10px 4px; border-radius: 12px; border: 1px solid transparent; color: var(--text); text-decoration: none; font-size: 12px; font-weight: 650; min-width: 0; }++.td-site span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.td-site .td-site-ic { width: 40px; height: 40px; border-radius: 12px; font-size: 16px; }++.td-tries { display: grid; gap: 8px; }++.td-tries::before { content: "Try next"; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }++.td-try { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; border: 1px solid var(--border); color: var(--text); text-decoration: none; }++.td-try-t { display: grid; gap: 1px; min-width: 0; }++.td-try-t b { font-size: 13.5px; }++.td-try-t small { font-size: 12px; color: var(--text-muted); }++.td-bal { display: flex; align-items: baseline; gap: 8px; }++.td-bal b { font-size: 1.6rem; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }++.td-bal span:last-child { font-size: 13px; color: var(--text-secondary); font-weight: 600; }++.td-coin { align-self: center; width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #fde68a, #f59e0b 60%, #b45309); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15); }++.td-coin-v { background: radial-gradient(circle at 35% 35%, #bbf7d0, #22c55e 60%, #15803d); }++.td-money-acts { display: flex; flex-wrap: wrap; gap: 8px; }++.td-stuff { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding-top: 14px; border-top: 1px solid var(--border); }++.td-stats { display: flex; flex-wrap: wrap; gap: 8px 22px; }++.td-stats:empty { display: none; }++.td-stat { display: inline-flex; align-items: baseline; gap: 6px; padding: 0; border: 0; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: default; }++button.td-stat { cursor: pointer; }++button.td-stat:hover span { color: var(--accent-light); }++.td-stat b { font-size: 1.05rem; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }++.td .recent-tools { margin-top: 0; }++.settings-h { font-size: 1.05rem; font-weight: 800; margin: 0 0 12px; scroll-margin-top: 72px; }++@media (min-width: 760px) {++ .td-grid { grid-template-columns: 1.25fr 1fr; }++}++@media (max-width: 600px) {++ .td-sites { grid-template-columns: repeat(4, minmax(0, 1fr)); }++ .td-settings-btn { padding: 8px 10px; font-size: 0; gap: 0; }++ .td-settings-btn i { font-size: 16px; }++}++@media (prefers-reduced-motion: reduce) {++ .td-pulse { animation: none; }++ .td-thumb img { transition: none; }++}.recent-tools { margin-top: 14px; }.recent-tools h3 { font-size: 12px; font-weight: 700; color: var(--text-secondary); margin: 0 0 8px; display: flex; gap: 6px; align-items: center; }.recent-tools-list { display: flex; flex-wrap: wrap; gap: 6px; }@@@ -9,8 +9,8 @@OpenVibeAccountSwitcher.init({ apiBase: API });const _signedIn = !!(getAuthToken() || getAnonToken());loadUser();--buildDashboardShowcase();if (_signedIn) {++ loadToday();openInitialSection();refreshApprovalsBadge();}@@@ -109,11 +109,11 @@ function renderHeader(user) {document.getElementById('user-name').textContent = user.display_name || user.username;document.getElementById('user-subtitle').textContent = user.is_anon? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`-- : '@' + user.username + (user.email ? ' · ' + user.email : '');++ : '@' + user.username; // never the email: this page may be on a stream (it is in Profile below)const badges = document.getElementById('user-badges');badges.innerHTML = user.is_anon? `<span class="badge badge-anon">anonymous</span>`-- : `<span class="badge badge-${user.role}">${user.role}</span>`;++ : (user.role && user.role !== 'user' ? `<span class="badge badge-${user.role}">${user.role.replace(/_/g, ' ')}</span>` : '');if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;}nnew file mode 100644@@@ -0,0 +1,190 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — account hub (/my): "You, today" (plan L43, 2026-10-31).++ The owner: "I hate this dashboard… redo the whole thing considering everything we have now and what's useful".++ What is happening for you right now, then what you were doing, your money, your sites ranked by your own use, and++ your numbers; the settings follow below. Every block is real data, and a block with nothing in it is left out.++ Classic script, global scope: my/core.js's apiFetch and showSection; my/boot.js calls loadToday(). No ES modules.++ ═══════════════════════════════════════════════════════════════ */++// [label, icon, address] for each site the history knows.++const TD_SITES = {++ live: ['Live', 'fa-tower-broadcast', 'https://openvibe.live'], tools: ['Tools', 'fa-screwdriver-wrench', 'https://openvibe.tools'],++ games: ['Games', 'fa-gamepad', 'https://openvibe.games'], media: ['Media', 'fa-photo-film', 'https://openvibe.media'],++ network: ['Network', 'fa-circle-nodes', 'https://openvibe.network'], community: ['Community', 'fa-users', 'https://openvibe.community'],++ space: ['Space', 'fa-shapes', 'https://openvibe.space'], chat: ['Chat', 'fa-comments', 'https://openvibe.chat'],++ codes: ['Codes', 'fa-code', 'https://openvibe.codes'], blog: ['Blog', 'fa-pen-nib', 'https://openvibe.blog'],++ wiki: ['Wiki', 'fa-book-open', 'https://openvibe.wiki'], news: ['News', 'fa-newspaper', 'https://openvibe.news'],++ reviews: ['Reviews', 'fa-star', 'https://openvibe.reviews'], tips: ['Tips', 'fa-hand-holding-heart', 'https://openvibe.tips'],++ vip: ['VIP', 'fa-crown', 'https://openvibe.vip'], trade: ['Trade', 'fa-chart-line', 'https://openvibe.trade'],++ host: ['Host', 'fa-server', 'https://openvibe.host'], deals: ['Deals', 'fa-tags', 'https://openvibe.deals'],++ coupons: ['Coupons', 'fa-ticket', 'https://openvibe.coupons'], openre: ['Restream', 'fa-arrows-split-up-and-left', 'https://openre.stream'],++ design: ['Design', 'fa-palette', 'https://openvibe.design'], report: ['Report', 'fa-chart-pie', 'https://openvibe.report'],++};++// What to try next, most used across the network first, each with the reason it is worth a look.++const TD_TRY = [++ ['live', 'Go live from your browser', 'No OBS and no follower minimum'],++ ['tools', 'Free tools, no sign-up', 'Hundreds of them, each open and simple'],++ ['games', 'Play in your browser', 'Quick games, alone or with friends'],++ ['design', 'Make your own look', 'Themes and icons that follow you everywhere'],++ ['chat', 'One inbox for every site', 'Messages from Live, Games and the rest'],++ ['codes', 'Code with AI in the cloud', 'Bring your own Claude Code, Codex and more'],++ ['wiki', 'Look anything up', 'An open wiki the community keeps'],++ ['news', 'The news, briefly', 'Many sources on one page'],++];++++function tdEl(tag, cls, kids, attrs) {++ const n = document.createElement(tag);++ if (cls) n.className = cls;++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);++ (kids || []).forEach((c) => { if (c != null) n.append(c); });++ return n;++}++const tdIcon = (name) => tdEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });++function tdAgo(at) {++ const t = Date.parse(at && !/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? String(at).replace(' ', 'T') + 'Z' : at);++ if (!Number.isFinite(t)) return '';++ const s = Math.max(0, (Date.now() - t) / 1000);++ if (s < 60) return 'just now';++ if (s < 3600) return `${Math.floor(s / 60)} min ago`;++ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;++ if (s < 86400 * 7) return `${Math.floor(s / 86400)} d ago`;++ return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });++}++const tdNum = (n) => Number(n || 0).toLocaleString();++const tdSafeUrl = (u) => (/^https:\/\//.test(String(u || '')) ? String(u) : null);++++/** The greeting above your name, by your own clock. */++function renderGreeting() {++ const h = new Date().getHours();++ const el = document.getElementById('user-greeting');++ if (el) el.textContent = h < 5 ? 'Up late' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';++}++++/** Now: the people you follow who are live, your unread notifications, and Go live for a streamer. */++function renderNow(live, unread) {++ const box = document.getElementById('td-now');++ const rail = document.getElementById('td-live');++ const chips = document.getElementById('td-chips');++ const people = (live && live.live) || [];++ rail.replaceChildren(...people.map((p) => {++ const thumb = tdEl('span', 'td-thumb', [tdEl('span', 'td-live-dot', ['LIVE'])]);++ const src = tdSafeUrl(p.thumbnail);++ if (src) { const img = tdEl('img', '', [], { src, alt: '', loading: 'lazy', decoding: 'async' }); img.onerror = () => img.remove(); thumb.prepend(img); }++ if (p.viewers) thumb.append(tdEl('span', 'td-viewers', [tdIcon('fa-eye'), ` ${tdNum(p.viewers)}`]));++ return tdEl('a', 'td-live', [thumb, tdEl('span', 'td-live-name', [p.name || p.username]), tdEl('span', 'td-live-title', [p.title || ''])], { href: p.url });++ }));++ box.hidden = people.length === 0;++ const sub = document.getElementById('td-now-sub');++ if (sub) sub.textContent = people.length ? `${people.length} of the ${tdNum(live.following)} you follow` : '';++++ const out = [];++ const count = unread && Number(unread.count);++ if (count > 0) {++ const b = tdEl('button', 'td-chip td-chip-hot', [tdIcon('fa-bell'), ` ${tdNum(count)} new notification${count === 1 ? '' : 's'}`], { type: 'button' });++ b.addEventListener('click', () => { const bell = document.querySelector('[data-ov-bell], .openvibe-navbar-bell, #openvibe-notif-btn'); if (bell) bell.click(); else showSection('notifications'); });++ out.push(b);++ }++ const role = currentUser && currentUser.role;++ if (role && role !== 'user') out.push(tdEl('a', 'td-chip', [tdIcon('fa-tower-broadcast'), ' Go live'], { href: 'https://openvibe.live/broadcast' }));++ if (!people.length && live && live.anyone_live > 0) out.push(tdEl('a', 'td-chip', [tdIcon('fa-circle-play'), ` ${tdNum(live.anyone_live)} live on OpenVibe now`], { href: 'https://openvibe.live/' }));++ chips.replaceChildren(...out);++ chips.hidden = out.length === 0;++}++++/** Continue: the newest things you opened across the sites (Network history), one card per address. */++function renderContinue(hist) {++ const box = document.getElementById('td-continue');++ const list = document.getElementById('td-continue-list');++ const seen = new Set();++ const items = ((hist && hist.items) || []).filter((it) => { const u = tdSafeUrl(it.url); if (!u || seen.has(u)) return false; seen.add(u); return true; }).slice(0, 10);++ list.replaceChildren(...items.map((it) => {++ const site = TD_SITES[it.service] || [it.service_label || 'OpenVibe', 'fa-globe'];++ const icon = /^fa-[a-z0-9-]+$/.test(String(it.icon || '')) ? it.icon : site[1];++ return tdEl('a', 'td-card td-cont', [++ tdEl('span', 'td-cont-ic', [tdIcon(icon)]),++ tdEl('span', 'td-cont-t', [it.title || it.url]),++ tdEl('span', 'td-cont-m', [`${it.service_label || site[0]} · ${tdAgo(it.updated_at || it.created_at)}`]),++ ], { href: it.url });++ }));++ box.hidden = items.length === 0;++}++++/** Your money: OpenCoins and Vibes, with the last change. */++function renderMoney(coins, last) {++ const box = document.getElementById('coins'); // the navbar's OpenCoins chip links to /my#coins++ if (!coins || coins.balance == null) { box.hidden = true; return; }++ document.getElementById('td-coins').textContent = tdNum(coins.balance);++ const vibes = document.getElementById('td-vibes-row');++ vibes.hidden = coins.vibes == null;++ if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);++ const t = last && last.transactions && last.transactions[0];++ const line = document.getElementById('td-money-last');++ line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';++ line.hidden = !t;++ box.hidden = false;++}++++/** Your sites: the ones you use most (history counts, then the switcher's pins and recents), and two to try. */++function renderSites(hist, sites) {++ const box = document.getElementById('td-sites');++ const grid = document.getElementById('td-sites-grid');++ const counts = new Map();++ ((sites && sites.pinned) || []).forEach((id) => counts.set(id, (counts.get(id) || 0) + 1000));++ ((hist && hist.services) || []).forEach((s) => counts.set(s.service, (counts.get(s.service) || 0) + Number(s.count || 0)));++ ((sites && sites.recent) || []).forEach((id, i) => counts.set(id, (counts.get(id) || 0) + (8 - i)));++ const ranked = [...counts.entries()].filter(([id]) => TD_SITES[id] && id !== 'network').sort((a, b) => b[1] - a[1]).slice(0, 8).map(([id]) => id);++ const mine = ranked.length > 4 ? ranked.slice(0, ranked.length - (ranked.length % 4)) : ranked; // whole rows of four; All sites has the rest++ grid.replaceChildren(...mine.map((id) => {++ const [label, icon, url] = TD_SITES[id];++ return tdEl('a', 'td-site', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', '', [label])], { href: url });++ }));++ grid.hidden = mine.length === 0;++ const tries = TD_TRY.filter(([id]) => !mine.includes(id) && !counts.has(id)).slice(0, 2);++ const tryBox = document.getElementById('td-try');++ tryBox.replaceChildren(...tries.map(([id, what, why]) => {++ const [, icon, url] = TD_SITES[id];++ return tdEl('a', 'td-try', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', 'td-try-t', [tdEl('b', '', [what]), tdEl('small', '', [why])])], { href: url });++ }));++ tryBox.hidden = tries.length === 0;++ document.getElementById('td-sites-h').textContent = mine.length ? 'Your sites' : 'Start here';++ box.hidden = mine.length === 0 && tries.length === 0;++}++++/** Your numbers: follows, machines and history; the full picture is on openvibe.report/you (the link beside them). */++function renderStuff(live, hist, nodes) {++ const row = document.getElementById('td-stuff-row');++ const cells = [];++ const add = (n, label, act) => {++ if (!(n > 0)) return;++ const kids = [tdEl('b', '', [tdNum(n)]), tdEl('span', '', [label])];++ if (!act) return cells.push(tdEl('span', 'td-stat', kids));++ const b = tdEl('button', 'td-stat', kids, { type: 'button' });++ b.addEventListener('click', act);++ cells.push(b);++ };++ add(live && live.following, 'following');++ add(nodes && nodes.nodes && nodes.nodes.filter((n) => !n.revoked_at).length, 'machines');++ add(hist && hist.total, 'pages in your history', () => { showSection('history'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });++ row.replaceChildren(...cells);++}++++async function loadToday() {++ if (!document.getElementById('today')) return;++ renderGreeting();++ if (typeof isAnonSession === 'function' && isAnonSession()) return; // a guest identity keeps no history, follows or coins++ const quiet = (p) => p.catch(() => null);++ const [live, hist, coins, last, unread, sites, nodes] = await Promise.all([++ quiet(apiFetch('/api/v1/me/live-follows')),++ quiet(apiFetch('/api/history?limit=30')),++ quiet(apiFetch('/api/coins/me?with=vibes')),++ quiet(apiFetch('/api/coins/me/history?limit=1')),++ quiet(apiFetch('/api/notifications/unread-count')),++ quiet(apiFetch('/api/themes/me/sites')),++ quiet(apiFetch('/api/v1/me/nodes')),++ ]);++ renderNow(live, unread);++ renderContinue(hist);++ renderMoney(coins, last);++ renderSites(hist, sites);++ renderStuff(live, hist, nodes);++ document.getElementById('today').classList.add('td-ready');++}@@@ -4,29 +4,6 @@my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and onmy/boot.js (loaded last), which starts the page. No ES modules.═══════════════════════════════════════════════════════════════ */--// Build dashboard showcase--function buildDashboardShowcase() {-- const grid = document.getElementById('my-showcase-grid');-- const quickAccess = document.getElementById('my-quick-access');-- if (!grid || !quickAccess) return;-- const popular = [-- { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },-- { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },-- { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },-- { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },-- ];-- grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');-- const quickLinks = [-- { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },-- { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },-- { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },-- { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },-- { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },-- { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },-- ];-- quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');--}--// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`// is the person's own (starred here or in the Tools launcher). Names come from the public Tools// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).@@@ -43,26 +43,53 @@<div class="page-header" id="page-header"><img class="avatar" id="user-avatar" src="" alt=""><div class="info">++ <div class="greeting" id="user-greeting"></div><h1 id="user-name">Loading...</h1><div class="subtitle" id="user-subtitle"></div><div class="badges" id="user-badges"></div></div>++ <a class="btn btn-outline td-settings-btn" href="#settings"><i class="fa-solid fa-gear" aria-hidden="true"></i> Settings</a></div>-- <div class="dashboard-showcase">-- <div class="showcase-header">-- <h2>Quick Access</h2>-- <p>Your favorite services at a glance</p>++ <!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your++ numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->++ <div class="td" id="today">++ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">++ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live now</h2><span class="td-sub" id="td-now-sub"></span></div>++ <div class="td-rail" id="td-live"></div>++ </section>++ <div class="td-chips" id="td-chips" hidden></div>++ <section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">++ <div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>++ <div class="td-rail" id="td-continue-list"></div>++ </section>++ <div class="td-grid">++ <section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">++ <div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>++ <div class="td-sites" id="td-sites-grid"></div>++ <div class="td-tries" id="td-try" hidden></div>++ </section>++ <section class="td-box td-money" id="coins" hidden aria-labelledby="td-money-h">++ <div class="td-h"><h2 id="td-money-h">Your money</h2><a class="td-more" href="https://openvibe.live/#vibes">Add Vibes</a></div>++ <div class="td-bal"><span class="td-coin" aria-hidden="true"></span><b id="td-coins">0</b><span>OpenCoins</span></div>++ <div class="td-bal" id="td-vibes-row" hidden><span class="td-coin td-coin-v" aria-hidden="true"></span><b id="td-vibes">0</b><span>Vibes</span></div>++ <p class="td-sub" id="td-money-last" hidden></p>++ <div class="td-money-acts"><a class="td-chip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a></div>++ </section></div>-- <div class="showcase-grid" id="my-showcase-grid"></div>-- <div class="quick-access" id="my-quick-access"></div><div class="recent-tools" id="my-recent-tools" hidden><h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3><div class="recent-tools-list" id="my-recent-tools-list"></div><p class="recent-tools-status" id="my-recent-tools-status" role="status"></p></div>++ <div class="td-stuff">++ <div class="td-stats" id="td-stuff-row"></div>++ <a class="td-more" href="https://openvibe.report/you">Your numbers on openvibe.report <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>++ </div></div>++ <h2 class="settings-h" id="settings">Settings</h2>++<div class="section-tabs"><button class="active" onclick="showSection('profile',this)"><i class="fa-solid fa-user"></i> Profile</button><button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>@@@ -394,6 +421,7 @@<script src="/js/my/core.js" defer></script><script src="/js/my/tools.js" defer></script>++<script src="/js/my/today.js" defer></script><script src="/js/my/themes.js" defer></script><script src="/js/my/profile.js" defer></script><script src="/js/my/consent.js" defer></script>@@@ -507,6 +507,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.blocconst followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read') });app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);++ // Your account home's "Now" (L43): the people you follow who are live, from Live's public list (held 10 s).++ app.use('/api/v1/me/live-follows', rateLimit({ windowMs: 60_000, max: 60 }), require('./today/live-follows').router({ requireAuth, liveUrl: config.services.live.internalUrl }));// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * The people you follow who are live now: the "Now" row of your account home (/my, plan L43, 2026-10-31).++ *++ * GET /api/v1/me/live-follows → { live: [{ username, name, title, viewers, thumbnail, avatar, url, since }],++ * following, anyone_live }++ *++ * Follows are Network's (subjects); who is live is Live's public stream list, read from Live's internal address and++ * kept for ten seconds, so a hundred open account pages ask Live once. When Live does not answer, nobody is live:++ * the home leaves the row out rather than failing.++ */++const express = require('express');++++const TTL_MS = 10_000;++const TIMEOUT_MS = 2_500;++const MAX = 12;++let held = { at: 0, streams: [] };++let pending = null;++++/** Live's public list of live streams (ten seconds old at most), or [] when Live is away. */++async function liveStreams(liveUrl, { fetchImpl = fetch, now = Date.now } = {}) {++ if (held.at && now() - held.at < TTL_MS) return held.streams;++ if (pending) return pending;++ pending = (async () => {++ try {++ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ const body = r.ok ? await r.json() : null;++ held = { at: now(), streams: Array.isArray(body && body.streams) ? body.streams : [] };++ } catch {++ held = { at: now(), streams: [] };++ } finally {++ pending = null;++ }++ return held.streams;++ })();++ return pending;++}++++const text = (v, n) => String(v == null ? '' : v).slice(0, n);++const httpsUrl = (v) => (/^https:\/\/[^\s"'<>]+$/.test(String(v || '')) ? String(v) : null);++++function router({ requireAuth, liveUrl, publicLive = 'https://openvibe.live', fetchImpl } = {}) {++ const r = express.Router();++ r.get('/', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ if (!req.user || req.user.is_anon) return res.json({ live: [], following: 0, anyone_live: 0 });++ const db = req.app.locals.db;++ const me = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(req.user.id);++ const follows = me && me.subject_id ? await db.prepare(`SELECT LOWER(u.username) AS username FROM user_follows f JOIN users u ON u.subject_id = f.target_id++ WHERE f.active = 1 AND f.follower_subject = ? AND f.target_type = 'channel'`).all(me.subject_id) : [];++ const followed = new Set(follows.map((f) => f.username));++ const streams = await liveStreams(liveUrl, { fetchImpl });++ const seen = new Set();++ const live = [];++ for (const s of streams) {++ const name = String(s && s.username || '').toLowerCase();++ if (!followed.has(name) || seen.has(name) || live.length >= MAX) continue;++ seen.add(name);++ live.push({++ username: text(s.username, 64),++ name: text(s.display_name || s.username, 80),++ title: text(s.ai_title || s.title, 140),++ viewers: Math.max(0, Number(s.total_viewer_count != null ? s.total_viewer_count : s.viewer_count) || 0),++ thumbnail: httpsUrl(s.thumbnail_url) || (String(s.thumbnail_url || '').startsWith('/') ? publicLive + s.thumbnail_url : null),++ avatar: httpsUrl(s.avatar_url),++ url: `${publicLive}/@${encodeURIComponent(String(s.username || ''))}`,++ since: text(s.started_at, 40) || null,++ });++ }++ res.json({ live, following: followed.size, anyone_live: new Set(streams.map((s) => String(s && s.username || '').toLowerCase())).size });++ });++ return r;++}++++/** Tests: forget what Live said. */++function forget() { held = { at: 0, streams: [] }; pending = null; }++++module.exports = { router, liveStreams, forget };nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++// Your account home's "Now" (plan L43): the people you follow who are live, from Live's public list held ten seconds.++// Only your own follows, only who is live, Live's secrets never pass, and a Live that does not answer means nobody.++// node --import ./test/helpers/pg-preload.mjs test/live-follows.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const liveFollows = require('../server/today/live-follows');++++(async () => {++ const log = console.log; console.log = () => {};++ const db = getDb();++ console.log = log;++ const PEOPLE = [[71, 'wren'], [72, 'Goosely'], [73, 'maticus'], [74, 'quietone']];++ for (const [id, name] of PEOPLE) await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (?, ?, 'x', 'user') ON CONFLICT DO NOTHING").run(id, name);++ const S = {};++ for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ // wren follows Goosely (live), maticus (live) and quietone (not live); one follow ended.++ for (const [who, whom, active] of [['wren', 'Goosely', 1], ['wren', 'maticus', 1], ['wren', 'quietone', 1], ['quietone', 'maticus', 0]]) {++ await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, ?) ON CONFLICT DO NOTHING").run(S[who], S[whom], active);++ }++++ let asked = 0; let answer = null;++ const fetchImpl = async (url) => {++ asked++;++ assert.strictEqual(url, 'http://live.internal/api/streams', 'Live\'s internal address');++ if (!answer) throw new Error('down');++ return { ok: true, json: async () => answer };++ };++ answer = { streams: [++ { username: 'goosely', display_name: 'Goosely', title: 'Geese at dawn', ai_title: '', viewer_count: 3, total_viewer_count: 9, thumbnail_url: '/thumbs/g.jpg', avatar_url: 'https://openvibe.media/a/g.png', started_at: '2026-10-31T18:00:00Z', stream_key: 'sk_secret', managed_stream_key: 'mk_secret' },++ { username: 'maticus', display_name: 'Maticus', title: 'LOOI', viewer_count: 12, thumbnail_url: 'javascript:alert(1)', avatar_url: 'http://insecure.example/a.png' },++ { username: 'maticus', display_name: 'Maticus', title: 'LOOI, second slot', viewer_count: 2 },++ { username: 'stranger', display_name: 'Stranger', title: 'Not followed', viewer_count: 40 },++ ] };++++ // The session itself is Network's requireAuth (tested on its own); here a request names its person.++ const requireAuth = (req, res, next) => { const id = Number(String(req.headers.authorization || '').replace(/^User /, '')); if (!id) return res.status(401).json({ error: 'sign in' }); req.user = { id }; next(); };++ const app = express();++ app.locals.db = db;++ app.use('/api/v1/me/live-follows', liveFollows.router({ requireAuth, liveUrl: 'http://live.internal', fetchImpl }));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = async (as) => fetch(`${base}/api/v1/me/live-follows`, { headers: as ? { authorization: `User ${as}` } : {} })++ .then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));++++ assert.strictEqual((await get()).status, 401, 'a person\'s own');++++ let r = await get(71);++ assert.strictEqual(r.cache, 'private, no-store');++ assert.deepStrictEqual(r.body.live.map((x) => x.username), ['goosely', 'maticus'], 'the people wren follows who are live, once each, in Live\'s order');++ assert.deepStrictEqual([r.body.following, r.body.anyone_live], [3, 3]);++ const g = r.body.live[0];++ assert.deepStrictEqual([g.name, g.title, g.viewers, g.thumbnail, g.avatar, g.url], ['Goosely', 'Geese at dawn', 9, 'https://openvibe.live/thumbs/g.jpg', 'https://openvibe.media/a/g.png', 'https://openvibe.live/@goosely']);++ assert.ok(!JSON.stringify(r.body).includes('secret'), 'no ingest key passes');++ const m = r.body.live[1];++ assert.deepStrictEqual([m.thumbnail, m.avatar], [null, null], 'only https pictures, and Live\'s own paths');++++ r = await get(73);++ assert.deepStrictEqual([r.body.live, r.body.following], [[], 0], 'nobody followed: nobody live');++ r = await get(74);++ assert.deepStrictEqual(r.body.live, [], 'an ended follow does not count');++ assert.strictEqual(asked, 1, 'Live was asked once for all of them');++++ // Live away: nobody is live, and the page still answers.++ liveFollows.forget(); answer = null;++ r = await get(71);++ assert.deepStrictEqual([r.status, r.body.live, r.body.anyone_live], [200, [], 0]);++++ server.close();++ console.log('live follows: your follows who are live, once each, no secrets, Live asked once per 10 s, Live away means nobody');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/my/…).--const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];++const modules = ['core.js', 'tools.js', 'today.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);@@@ -41,7 +41,7 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');assert.ok(bootSrc.includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');// Signed out (loadUser() redirects to /login), no section loads and no badge is asked for: each would answer 401.--assert.match(bootSrc, /if \(_signedIn\) \{\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API');++assert.match(bootSrc, /if \(_signedIn\) \{\s*loadToday\(\);\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API (You, today included)');// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.{const vm = require('vm');nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++// The account home, "You, today" (plan L43; the owner, 10-31: "I hate this dashboard… redo the whole thing"). my/today.js++// runs here on a tiny DOM with sample answers from each API:++// - Now: the people you follow who are live, a notifications chip, Go live only for a streamer;++// - Continue: the newest history, one card per address, https only;++// - Your sites: ranked by your own use (pins, history counts, recents), Network itself left out, two to try that you++// have not used; Your money from /api/coins; your numbers;++// - an empty block stays out, and no API answer breaks the page.++// It also checks the page: no Quick Access grid, no email beside your name, Settings below.++// node test/my-today.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/today.js'), 'utf8');++const HTML = fs.readFileSync(path.join(__dirname, '..', 'public/my.html'), 'utf8');++const PROFILE = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/profile.js'), 'utf8');++const BOOT = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/boot.js'), 'utf8');++const TOOLS = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/tools.js'), 'utf8');++++class Node {++ constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this.hidden = false; this.listeners = {}; this._text = ''; this.style = {}; }++ setAttribute(k, v) { this.attrs[k] = String(v); }++ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }++ append(...kids) { for (const k of kids) this.children.push(typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k); }++ prepend(k) { this.children.unshift(k); }++ replaceChildren(...kids) { this.children = []; this.append(...kids); }++ remove() {}++ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }++ click() { (this.listeners.click || []).forEach((fn) => fn({})); }++ scrollIntoView() { this.scrolled = true; }++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }++ set textContent(v) { this._text = String(v); this.children = []; }++ get href() { return this.attrs.href; }++ get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }++ all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }++}++++function page() {++ const ids = {};++ for (const [, id] of HTML.matchAll(/id="((?:td-|user-greeting|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');++ const shown = [];++ const sandbox = {++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },++ console, Date, Number, String, Map, Set, Math, JSON, Promise,++ currentUser: { role: 'user' },++ showSection: (id) => shown.push(id),++ isAnonSession: () => false,++ };++ vm.runInNewContext(SRC, sandbox);++ return { ids, sandbox, shown };++}++const links = (n) => n.all((c) => c.tagName === 'A').map((a) => a.href);++const ago = (min) => new Date(Date.now() - min * 60000).toISOString();++++// ── The page: no Quick Access grid, no email next to your name, Settings below ──++assert.ok(!/Quick Access|showcase-grid|quick-access/.test(HTML), 'the Quick Access grid is gone');++assert.ok(!/buildDashboardShowcase/.test(TOOLS + BOOT), 'and its builder');++assert.match(HTML, /id="today"/);++assert.ok(HTML.indexOf('id="today"') < HTML.indexOf('id="settings"') && HTML.indexOf('id="settings"') < HTML.indexOf('class="section-tabs"'), 'You, today first, then Settings');++assert.ok(!/user\.email \? ' · '/.test(PROFILE), 'the email is not beside your name (this page may be on a stream)');++assert.match(BOOT, /loadToday\(\)/, 'boot starts it');++assert.match(HTML, /id="coins"/, 'the navbar\'s OpenCoins chip (/my#coins) lands on Your money');++++(async () => {++ // ── Everything at once ──++ let p = page();++ p.sandbox.currentUser = { role: 'streamer' };++ p.sandbox.renderNow({ live: [++ { username: 'goosely', name: 'Goosely', title: 'Geese at dawn', viewers: 9, thumbnail: 'https://openvibe.live/t/g.jpg', url: 'https://openvibe.live/@goosely' },++ { username: 'maticus', name: 'Maticus', title: 'LOOI', viewers: 0, thumbnail: 'javascript:alert(1)', url: 'https://openvibe.live/@maticus' },++ ], following: 14, anyone_live: 5 }, { count: 3 });++ assert.strictEqual(p.ids['td-now'].hidden, false);++ assert.deepStrictEqual(links(p.ids['td-live']), ['https://openvibe.live/@goosely', 'https://openvibe.live/@maticus']);++ assert.strictEqual(p.ids['td-live'].all((c) => c.tagName === 'IMG').length, 1, 'only an https thumbnail is shown');++ assert.strictEqual(p.ids['td-now-sub'].textContent, '2 of the 14 you follow');++ const chips = p.ids['td-chips'];++ assert.match(chips.textContent, /3 new notifications/);++ assert.match(chips.textContent, /Go live/, 'a streamer can go live from here');++ assert.ok(!/live on OpenVibe now/.test(chips.textContent), 'not "who else is live" while people you follow are');++++ p.sandbox.renderContinue({ items: [++ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', service_label: 'Live', updated_at: ago(5) },++ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', updated_at: ago(50) },++ { title: 'My theme', url: 'https://openvibe.design/themes/mine', service: 'design', icon: 'fa-palette', updated_at: ago(130) },++ { title: 'Odd', url: 'javascript:alert(1)', service: 'live', updated_at: ago(1) },++ ] });++ assert.deepStrictEqual(links(p.ids['td-continue-list']), ['https://openvibe.live/@goosely', 'https://openvibe.design/themes/mine'], 'one card per address, https only');++ assert.match(p.ids['td-continue-list'].textContent, /Live · 5 min ago/);++ assert.match(p.ids['td-continue-list'].textContent, /2 h ago/);++++ p.sandbox.renderSites(++ { services: [{ service: 'network', count: 99 }, { service: 'live', count: 40 }, { service: 'tools', count: 12 }, { service: 'nope', count: 9 }] },++ { pinned: ['design'], recent: ['games', 'live'] });++ assert.deepStrictEqual(links(p.ids['td-sites-grid']), ['https://openvibe.design', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.games'], 'pins first, then your own use; Network and unknown sites left out');++ const tries = links(p.ids['td-try']);++ assert.strictEqual(tries.length, 2, 'two to try');++ assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');++ assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');++++ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });++ assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);++ assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);++++ p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });++ assert.match(p.ids['td-stuff-row'].textContent, /14following/);++ assert.match(p.ids['td-stuff-row'].textContent, /1machines/, 'revoked machines do not count');++ p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();++ assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');++++ // ── Nothing to show: each block stays out; a new account gets "Start here" ──++ p = page();++ p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });++ assert.strictEqual(p.ids['td-now'].hidden, true, 'nobody you follow is live: no Live now row');++ assert.match(p.ids['td-chips'].textContent, /4 live on OpenVibe now/, 'but who else is, in one chip');++ assert.ok(!/Go live|notification/.test(p.ids['td-chips'].textContent), 'no Go live for a viewer, no zero count');++ p.sandbox.renderContinue(null);++ assert.strictEqual(p.ids['td-continue'].hidden, true);++ p.sandbox.renderSites(null, null);++ assert.deepStrictEqual([p.ids['td-sites'].hidden, p.ids['td-sites-h'].textContent, links(p.ids['td-try']).length], [false, 'Start here', 2]);++ p.sandbox.renderMoney(null, null);++ assert.strictEqual(p.ids.coins.hidden, true, 'no coins answer: no money block');++ p.sandbox.renderStuff(null, null, null);++ assert.strictEqual(p.ids['td-stuff-row'].children.length, 0);++++ // ── Every API down: the page still draws ──++ p = page();++ p.sandbox.apiFetch = () => Promise.reject(Object.assign(new Error('down'), { status: 503 }));++ await p.sandbox.loadToday();++ assert.ok(/td-ready/.test(p.ids.today.className), 'drawn');++ assert.ok(p.ids['td-greeting'] === undefined && /Good|Up late/.test(p.ids['user-greeting'].textContent), 'with a greeting');++++ console.log('my today: live follows, continue, your sites by your own use, money and numbers; empty blocks stay out; APIs down still draws');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -119,6 +119,7 @@ const REVIEWED = {'server/discord/discord-service.js fetch': [2, 'discord.js channels.fetch: Discord API only'],'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],++ 'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
@@ -220,39 +220,90 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -220,39 +220,90 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@media(max-width:600px){
@media(max-width:600px){
.page{padding:12px}
.page{padding:12px}
- .page-header{flex-direction:column;text-align:center}
- .page-header .info .badges{justify-content:center}
+ .page-header{gap:12px;margin-bottom:24px}
+ .page-header .avatar{width:56px;height:56px}
+ .page-header .info h1{font-size:20px}
.notif-grid{grid-template-columns:1fr auto auto}
.notif-grid{grid-template-columns:1fr auto auto}
.notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}
.notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}
}
}
-/* Dashboard Showcase */
-@keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-10px); } }
-@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
-.dashboard-showcase { margin: 32px 0; padding: 28px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
-.showcase-header { text-align: center; margin-bottom: 24px; }
-.showcase-header h2 { font-size: 1.3rem; font-weight: 800; margin-bottom: 6px; }
-.showcase-header p { font-size: 12px; color: var(--text-muted); }
-.showcase-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
-.showcase-card { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; border-radius: 10px; background: var(--bg-input); border: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; gap: 8px; animation: float ease-in-out infinite; transition: all 0.3s ease; }
-.showcase-card:nth-child(1) { animation-delay: 0s; animation-duration: 3s; }
-.showcase-card:nth-child(2) { animation-delay: 0.15s; animation-duration: 3.3s; }
-.showcase-card:nth-child(3) { animation-delay: 0.3s; animation-duration: 3.6s; }
-.showcase-card:nth-child(4) { animation-delay: 0.45s; animation-duration: 3.2s; }
-.showcase-card:hover { transform: translateY(-4px); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
-.showcase-card-icon { font-size: 1.4rem; color: var(--accent-light); }
-.showcase-card-label { font-size: 11px; font-weight: 700; text-align: center; }
-.quick-access { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
-.quick-link { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); text-decoration: none; color: var(--text-secondary); font-size: 10px; font-weight: 600; text-align: center; transition: all 0.25s ease; animation: fadeInScale 0.5s ease both; }
-.quick-link i { font-size: 1rem; margin-bottom: 4px; }
-.quick-link:nth-child(1) { animation-delay: 0.1s; }
-.quick-link:nth-child(2) { animation-delay: 0.15s; }
-.quick-link:nth-child(3) { animation-delay: 0.2s; }
-.quick-link:nth-child(4) { animation-delay: 0.25s; }
-.quick-link:nth-child(5) { animation-delay: 0.3s; }
-.quick-link:nth-child(6) { animation-delay: 0.35s; }
-.quick-link:hover { border-color: var(--accent); color: var(--accent-light); background: color-mix(in srgb, var(--accent) 8%, transparent); }
-@media(max-width:600px){ .showcase-grid { grid-template-columns: repeat(2, 1fr); } .quick-access { grid-template-columns: repeat(3, 1fr); } }
+/* You, today (plan L43, 2026-10-31): the account home. What is live for you, what you were doing, your sites, your
+ money and your numbers, then Settings. Rails scroll sideways on a phone (never hidden content), two columns from 760 px. */
+.page-header .info .greeting { font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 2px; }
+.page-header .info .greeting:empty { display: none; }
+.page-header .info .badges:empty { display: none; }
+.td-settings-btn { flex: none; align-self: center; text-decoration: none; }
+.td { display: grid; gap: 22px; margin: -8px 0 36px; }
+.td-sec { display: grid; gap: 10px; min-width: 0; }
+.td-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.td-h h2 { display: flex; align-items: center; gap: 8px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
+.td-sub { font-size: 12.5px; color: var(--text-muted); }
+.td-more { font-size: 13px; font-weight: 650; color: var(--accent-light); text-decoration: none; white-space: nowrap; }
+.td-more:hover { text-decoration: underline; }
+.td-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--live-red); box-shadow: 0 0 0 0 color-mix(in srgb, var(--live-red) 60%, transparent); animation: tdPulse 2s ease-out infinite; }
+@keyframes tdPulse { to { box-shadow: 0 0 0 8px transparent; } }
+.td-rail { display: grid; grid-auto-flow: column; gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: thin; padding: 2px 2px 8px; margin: 0 -2px; }
+.td-rail > * { scroll-snap-align: start; }
+#td-live { grid-auto-columns: minmax(220px, 260px); }
+#td-continue-list { grid-auto-columns: minmax(180px, 210px); }
+.td-live { display: grid; align-content: start; gap: 4px; color: var(--text); text-decoration: none; min-width: 0; }
+.td-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 30%, var(--bg-card)), var(--bg-card)); border: 1px solid var(--border); }
+.td-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }
+.td-live:hover .td-thumb img { transform: scale(1.03); }
+.td-live-dot { position: absolute; top: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: var(--live-red); color: #fff; font-size: 10.5px; font-weight: 800; letter-spacing: .06em; }
+.td-viewers { position: absolute; bottom: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .62); color: #fff; font-size: 11.5px; font-weight: 700; }
+.td-live-name { font-size: 14px; font-weight: 750; margin-top: 4px; }
+.td-live-title, .td-cont-t { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.td-live-title { font-size: 12.5px; color: var(--text-secondary); line-height: 1.35; }
+.td-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.td-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px; font-weight: 650; text-decoration: none; cursor: pointer; }
+.td-chip:hover, .td-chip:focus-visible { border-color: var(--accent); }
+.td-chip-hot { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); }
+.td-card, .td-box { border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
+.td-cont { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; padding: 12px; min-height: 124px; color: var(--text); text-decoration: none; min-width: 0; }
+.td-cont:hover, .td-site:hover, .td-try:hover { border-color: var(--accent); }
+.td-cont-ic, .td-site-ic { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); font-size: 14px; flex: none; }
+.td-cont-t { font-size: 13.5px; font-weight: 650; line-height: 1.35; }
+.td-cont-m { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.td-grid { display: grid; align-items: start; gap: 16px; }
+.td-box { display: grid; align-content: start; gap: 12px; padding: 16px; min-width: 0; }
+.td-sites { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
+.td-site { display: grid; justify-items: center; gap: 6px; padding: 10px 4px; border-radius: 12px; border: 1px solid transparent; color: var(--text); text-decoration: none; font-size: 12px; font-weight: 650; min-width: 0; }
+.td-site span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.td-site .td-site-ic { width: 40px; height: 40px; border-radius: 12px; font-size: 16px; }
+.td-tries { display: grid; gap: 8px; }
+.td-tries::before { content: "Try next"; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
+.td-try { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; border: 1px solid var(--border); color: var(--text); text-decoration: none; }
+.td-try-t { display: grid; gap: 1px; min-width: 0; }
+.td-try-t b { font-size: 13.5px; }
+.td-try-t small { font-size: 12px; color: var(--text-muted); }
+.td-bal { display: flex; align-items: baseline; gap: 8px; }
+.td-bal b { font-size: 1.6rem; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
+.td-bal span:last-child { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
+.td-coin { align-self: center; width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #fde68a, #f59e0b 60%, #b45309); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15); }
+.td-coin-v { background: radial-gradient(circle at 35% 35%, #bbf7d0, #22c55e 60%, #15803d); }
+.td-money-acts { display: flex; flex-wrap: wrap; gap: 8px; }
+.td-stuff { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding-top: 14px; border-top: 1px solid var(--border); }
+.td-stats { display: flex; flex-wrap: wrap; gap: 8px 22px; }
+.td-stats:empty { display: none; }
+.td-stat { display: inline-flex; align-items: baseline; gap: 6px; padding: 0; border: 0; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: default; }
+button.td-stat { cursor: pointer; }
+button.td-stat:hover span { color: var(--accent-light); }
+.td-stat b { font-size: 1.05rem; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
+.td .recent-tools { margin-top: 0; }
+.settings-h { font-size: 1.05rem; font-weight: 800; margin: 0 0 12px; scroll-margin-top: 72px; }
+@media (min-width: 760px) {
+ .td-grid { grid-template-columns: 1.25fr 1fr; }
+}
+@media (max-width: 600px) {
+ .td-sites { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .td-settings-btn { padding: 8px 10px; font-size: 0; gap: 0; }
+ .td-settings-btn i { font-size: 16px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .td-pulse { animation: none; }
+ .td-thumb img { transition: none; }
+}
.recent-tools { margin-top: 14px; }
.recent-tools { margin-top: 14px; }
.recent-tools h3 { font-size: 12px; font-weight: 700; color: var(--text-secondary); margin: 0 0 8px; display: flex; gap: 6px; align-items: center; }
.recent-tools h3 { font-size: 12px; font-weight: 700; color: var(--text-secondary); margin: 0 0 8px; display: flex; gap: 6px; align-items: center; }
.recent-tools-list { display: flex; flex-wrap: wrap; gap: 6px; }
.recent-tools-list { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
OpenVibeAccountSwitcher.init({ apiBase: API });
OpenVibeAccountSwitcher.init({ apiBase: API });
const _signedIn = !!(getAuthToken() || getAnonToken());
const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
loadUser();
-buildDashboardShowcase();
if (_signedIn) {
if (_signedIn) {
+ loadToday();
openInitialSection();
openInitialSection();
refreshApprovalsBadge();
refreshApprovalsBadge();
}
}
@@ -109,11 +109,11 @@ function renderHeader(user) {
@@ -109,11 +109,11 @@ function renderHeader(user) {
document.getElementById('user-name').textContent = user.display_name || user.username;
document.getElementById('user-name').textContent = user.display_name || user.username;
document.getElementById('user-subtitle').textContent = user.is_anon
document.getElementById('user-subtitle').textContent = user.is_anon
? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
- : '@' + user.username + (user.email ? ' · ' + user.email : '');
+ : '@' + user.username; // never the email: this page may be on a stream (it is in Profile below)
const badges = document.getElementById('user-badges');
const badges = document.getElementById('user-badges');
badges.innerHTML = user.is_anon
badges.innerHTML = user.is_anon
? `<span class="badge badge-anon">anonymous</span>`
? `<span class="badge badge-anon">anonymous</span>`
- : `<span class="badge badge-${user.role}">${user.role}</span>`;
+ : (user.role && user.role !== 'user' ? `<span class="badge badge-${user.role}">${user.role.replace(/_/g, ' ')}</span>` : '');
if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — account hub (/my): "You, today" (plan L43, 2026-10-31).
+ The owner: "I hate this dashboard… redo the whole thing considering everything we have now and what's useful".
+ What is happening for you right now, then what you were doing, your money, your sites ranked by your own use, and
+ your numbers; the settings follow below. Every block is real data, and a block with nothing in it is left out.
+ Classic script, global scope: my/core.js's apiFetch and showSection; my/boot.js calls loadToday(). No ES modules.
+ ═══════════════════════════════════════════════════════════════ */
+// [label, icon, address] for each site the history knows.
+const TD_SITES = {
+ live: ['Live', 'fa-tower-broadcast', 'https://openvibe.live'], tools: ['Tools', 'fa-screwdriver-wrench', 'https://openvibe.tools'],
+ games: ['Games', 'fa-gamepad', 'https://openvibe.games'], media: ['Media', 'fa-photo-film', 'https://openvibe.media'],
+ network: ['Network', 'fa-circle-nodes', 'https://openvibe.network'], community: ['Community', 'fa-users', 'https://openvibe.community'],
+ space: ['Space', 'fa-shapes', 'https://openvibe.space'], chat: ['Chat', 'fa-comments', 'https://openvibe.chat'],
+ codes: ['Codes', 'fa-code', 'https://openvibe.codes'], blog: ['Blog', 'fa-pen-nib', 'https://openvibe.blog'],
+ wiki: ['Wiki', 'fa-book-open', 'https://openvibe.wiki'], news: ['News', 'fa-newspaper', 'https://openvibe.news'],
+ reviews: ['Reviews', 'fa-star', 'https://openvibe.reviews'], tips: ['Tips', 'fa-hand-holding-heart', 'https://openvibe.tips'],
+ vip: ['VIP', 'fa-crown', 'https://openvibe.vip'], trade: ['Trade', 'fa-chart-line', 'https://openvibe.trade'],
+ host: ['Host', 'fa-server', 'https://openvibe.host'], deals: ['Deals', 'fa-tags', 'https://openvibe.deals'],
+ coupons: ['Coupons', 'fa-ticket', 'https://openvibe.coupons'], openre: ['Restream', 'fa-arrows-split-up-and-left', 'https://openre.stream'],
+ design: ['Design', 'fa-palette', 'https://openvibe.design'], report: ['Report', 'fa-chart-pie', 'https://openvibe.report'],
+};
+// What to try next, most used across the network first, each with the reason it is worth a look.
+const TD_TRY = [
+ ['live', 'Go live from your browser', 'No OBS and no follower minimum'],
+ ['tools', 'Free tools, no sign-up', 'Hundreds of them, each open and simple'],
+ ['games', 'Play in your browser', 'Quick games, alone or with friends'],
+ ['design', 'Make your own look', 'Themes and icons that follow you everywhere'],
+ ['chat', 'One inbox for every site', 'Messages from Live, Games and the rest'],
+ ['codes', 'Code with AI in the cloud', 'Bring your own Claude Code, Codex and more'],
+ ['wiki', 'Look anything up', 'An open wiki the community keeps'],
+ ['news', 'The news, briefly', 'Many sources on one page'],
+];
+
+function tdEl(tag, cls, kids, attrs) {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
+ (kids || []).forEach((c) => { if (c != null) n.append(c); });
+ return n;
+}
+const tdIcon = (name) => tdEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });
+function tdAgo(at) {
+ const t = Date.parse(at && !/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? String(at).replace(' ', 'T') + 'Z' : at);
+ if (!Number.isFinite(t)) return '';
+ const s = Math.max(0, (Date.now() - t) / 1000);
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
+ if (s < 86400 * 7) return `${Math.floor(s / 86400)} d ago`;
+ return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+const tdNum = (n) => Number(n || 0).toLocaleString();
+const tdSafeUrl = (u) => (/^https:\/\//.test(String(u || '')) ? String(u) : null);
+
+/** The greeting above your name, by your own clock. */
+function renderGreeting() {
+ const h = new Date().getHours();
+ const el = document.getElementById('user-greeting');
+ if (el) el.textContent = h < 5 ? 'Up late' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
+}
+
+/** Now: the people you follow who are live, your unread notifications, and Go live for a streamer. */
+function renderNow(live, unread) {
+ const box = document.getElementById('td-now');
+ const rail = document.getElementById('td-live');
+ const chips = document.getElementById('td-chips');
+ const people = (live && live.live) || [];
+ rail.replaceChildren(...people.map((p) => {
+ const thumb = tdEl('span', 'td-thumb', [tdEl('span', 'td-live-dot', ['LIVE'])]);
+ const src = tdSafeUrl(p.thumbnail);
+ if (src) { const img = tdEl('img', '', [], { src, alt: '', loading: 'lazy', decoding: 'async' }); img.onerror = () => img.remove(); thumb.prepend(img); }
+ if (p.viewers) thumb.append(tdEl('span', 'td-viewers', [tdIcon('fa-eye'), ` ${tdNum(p.viewers)}`]));
+ return tdEl('a', 'td-live', [thumb, tdEl('span', 'td-live-name', [p.name || p.username]), tdEl('span', 'td-live-title', [p.title || ''])], { href: p.url });
+ }));
+ box.hidden = people.length === 0;
+ const sub = document.getElementById('td-now-sub');
+ if (sub) sub.textContent = people.length ? `${people.length} of the ${tdNum(live.following)} you follow` : '';
+
+ const out = [];
+ const count = unread && Number(unread.count);
+ if (count > 0) {
+ const b = tdEl('button', 'td-chip td-chip-hot', [tdIcon('fa-bell'), ` ${tdNum(count)} new notification${count === 1 ? '' : 's'}`], { type: 'button' });
+ b.addEventListener('click', () => { const bell = document.querySelector('[data-ov-bell], .openvibe-navbar-bell, #openvibe-notif-btn'); if (bell) bell.click(); else showSection('notifications'); });
+ out.push(b);
+ }
+ const role = currentUser && currentUser.role;
+ if (role && role !== 'user') out.push(tdEl('a', 'td-chip', [tdIcon('fa-tower-broadcast'), ' Go live'], { href: 'https://openvibe.live/broadcast' }));
+ if (!people.length && live && live.anyone_live > 0) out.push(tdEl('a', 'td-chip', [tdIcon('fa-circle-play'), ` ${tdNum(live.anyone_live)} live on OpenVibe now`], { href: 'https://openvibe.live/' }));
+ chips.replaceChildren(...out);
+ chips.hidden = out.length === 0;
+}
+
+/** Continue: the newest things you opened across the sites (Network history), one card per address. */
+function renderContinue(hist) {
+ const box = document.getElementById('td-continue');
+ const list = document.getElementById('td-continue-list');
+ const seen = new Set();
+ const items = ((hist && hist.items) || []).filter((it) => { const u = tdSafeUrl(it.url); if (!u || seen.has(u)) return false; seen.add(u); return true; }).slice(0, 10);
+ list.replaceChildren(...items.map((it) => {
+ const site = TD_SITES[it.service] || [it.service_label || 'OpenVibe', 'fa-globe'];
+ const icon = /^fa-[a-z0-9-]+$/.test(String(it.icon || '')) ? it.icon : site[1];
+ return tdEl('a', 'td-card td-cont', [
+ tdEl('span', 'td-cont-ic', [tdIcon(icon)]),
+ tdEl('span', 'td-cont-t', [it.title || it.url]),
+ tdEl('span', 'td-cont-m', [`${it.service_label || site[0]} · ${tdAgo(it.updated_at || it.created_at)}`]),
+ ], { href: it.url });
+ }));
+ box.hidden = items.length === 0;
+}
+
+/** Your money: OpenCoins and Vibes, with the last change. */
+function renderMoney(coins, last) {
+ const box = document.getElementById('coins'); // the navbar's OpenCoins chip links to /my#coins
+ if (!coins || coins.balance == null) { box.hidden = true; return; }
+ document.getElementById('td-coins').textContent = tdNum(coins.balance);
+ const vibes = document.getElementById('td-vibes-row');
+ vibes.hidden = coins.vibes == null;
+ if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);
+ const t = last && last.transactions && last.transactions[0];
+ const line = document.getElementById('td-money-last');
+ line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';
+ line.hidden = !t;
+ box.hidden = false;
+}
+
+/** Your sites: the ones you use most (history counts, then the switcher's pins and recents), and two to try. */
+function renderSites(hist, sites) {
+ const box = document.getElementById('td-sites');
+ const grid = document.getElementById('td-sites-grid');
+ const counts = new Map();
+ ((sites && sites.pinned) || []).forEach((id) => counts.set(id, (counts.get(id) || 0) + 1000));
+ ((hist && hist.services) || []).forEach((s) => counts.set(s.service, (counts.get(s.service) || 0) + Number(s.count || 0)));
+ ((sites && sites.recent) || []).forEach((id, i) => counts.set(id, (counts.get(id) || 0) + (8 - i)));
+ const ranked = [...counts.entries()].filter(([id]) => TD_SITES[id] && id !== 'network').sort((a, b) => b[1] - a[1]).slice(0, 8).map(([id]) => id);
+ const mine = ranked.length > 4 ? ranked.slice(0, ranked.length - (ranked.length % 4)) : ranked; // whole rows of four; All sites has the rest
+ grid.replaceChildren(...mine.map((id) => {
+ const [label, icon, url] = TD_SITES[id];
+ return tdEl('a', 'td-site', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', '', [label])], { href: url });
+ }));
+ grid.hidden = mine.length === 0;
+ const tries = TD_TRY.filter(([id]) => !mine.includes(id) && !counts.has(id)).slice(0, 2);
+ const tryBox = document.getElementById('td-try');
+ tryBox.replaceChildren(...tries.map(([id, what, why]) => {
+ const [, icon, url] = TD_SITES[id];
+ return tdEl('a', 'td-try', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', 'td-try-t', [tdEl('b', '', [what]), tdEl('small', '', [why])])], { href: url });
+ }));
+ tryBox.hidden = tries.length === 0;
+ document.getElementById('td-sites-h').textContent = mine.length ? 'Your sites' : 'Start here';
+ box.hidden = mine.length === 0 && tries.length === 0;
+}
+
+/** Your numbers: follows, machines and history; the full picture is on openvibe.report/you (the link beside them). */
+function renderStuff(live, hist, nodes) {
+ const row = document.getElementById('td-stuff-row');
+ const cells = [];
+ const add = (n, label, act) => {
+ if (!(n > 0)) return;
+ const kids = [tdEl('b', '', [tdNum(n)]), tdEl('span', '', [label])];
+ if (!act) return cells.push(tdEl('span', 'td-stat', kids));
+ const b = tdEl('button', 'td-stat', kids, { type: 'button' });
+ b.addEventListener('click', act);
+ cells.push(b);
+ };
+ add(live && live.following, 'following');
+ add(nodes && nodes.nodes && nodes.nodes.filter((n) => !n.revoked_at).length, 'machines');
+ add(hist && hist.total, 'pages in your history', () => { showSection('history'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });
+ row.replaceChildren(...cells);
+}
+
+async function loadToday() {
+ if (!document.getElementById('today')) return;
+ renderGreeting();
+ if (typeof isAnonSession === 'function' && isAnonSession()) return; // a guest identity keeps no history, follows or coins
+ const quiet = (p) => p.catch(() => null);
+ const [live, hist, coins, last, unread, sites, nodes] = await Promise.all([
+ quiet(apiFetch('/api/v1/me/live-follows')),
+ quiet(apiFetch('/api/history?limit=30')),
+ quiet(apiFetch('/api/coins/me?with=vibes')),
+ quiet(apiFetch('/api/coins/me/history?limit=1')),
+ quiet(apiFetch('/api/notifications/unread-count')),
+ quiet(apiFetch('/api/themes/me/sites')),
+ quiet(apiFetch('/api/v1/me/nodes')),
+ ]);
+ renderNow(live, unread);
+ renderContinue(hist);
+ renderMoney(coins, last);
+ renderSites(hist, sites);
+ renderStuff(live, hist, nodes);
+ document.getElementById('today').classList.add('td-ready');
+}
@@ -4,29 +4,6 @@
@@ -4,29 +4,6 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
my/boot.js (loaded last), which starts the page. No ES modules.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-// Build dashboard showcase
-function buildDashboardShowcase() {
- const grid = document.getElementById('my-showcase-grid');
- const quickAccess = document.getElementById('my-quick-access');
- if (!grid || !quickAccess) return;
- const popular = [
- { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },
- { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },
- { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },
- { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },
- ];
- grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');
- const quickLinks = [
- { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },
- { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },
- { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },
- { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },
- { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },
- { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },
- ];
- quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
-}
-
// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
@@ -43,26 +43,53 @@
@@ -43,26 +43,53 @@
<div class="page-header" id="page-header">
<div class="page-header" id="page-header">
<img class="avatar" id="user-avatar" src="" alt="">
<img class="avatar" id="user-avatar" src="" alt="">
<div class="info">
<div class="info">
+ <div class="greeting" id="user-greeting"></div>
<h1 id="user-name">Loading...</h1>
<h1 id="user-name">Loading...</h1>
<div class="subtitle" id="user-subtitle"></div>
<div class="subtitle" id="user-subtitle"></div>
<div class="badges" id="user-badges"></div>
<div class="badges" id="user-badges"></div>
</div>
</div>
+ <a class="btn btn-outline td-settings-btn" href="#settings"><i class="fa-solid fa-gear" aria-hidden="true"></i> Settings</a>
</div>
</div>
- <div class="dashboard-showcase">
- <div class="showcase-header">
- <h2>Quick Access</h2>
- <p>Your favorite services at a glance</p>
+ <!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your
+ numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->
+ <div class="td" id="today">
+ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
+ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live now</h2><span class="td-sub" id="td-now-sub"></span></div>
+ <div class="td-rail" id="td-live"></div>
+ </section>
+ <div class="td-chips" id="td-chips" hidden></div>
+ <section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">
+ <div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>
+ <div class="td-rail" id="td-continue-list"></div>
+ </section>
+ <div class="td-grid">
+ <section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">
+ <div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>
+ <div class="td-sites" id="td-sites-grid"></div>
+ <div class="td-tries" id="td-try" hidden></div>
+ </section>
+ <section class="td-box td-money" id="coins" hidden aria-labelledby="td-money-h">
+ <div class="td-h"><h2 id="td-money-h">Your money</h2><a class="td-more" href="https://openvibe.live/#vibes">Add Vibes</a></div>
+ <div class="td-bal"><span class="td-coin" aria-hidden="true"></span><b id="td-coins">0</b><span>OpenCoins</span></div>
+ <div class="td-bal" id="td-vibes-row" hidden><span class="td-coin td-coin-v" aria-hidden="true"></span><b id="td-vibes">0</b><span>Vibes</span></div>
+ <p class="td-sub" id="td-money-last" hidden></p>
+ <div class="td-money-acts"><a class="td-chip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a></div>
+ </section>
</div>
</div>
- <div class="showcase-grid" id="my-showcase-grid"></div>
- <div class="quick-access" id="my-quick-access"></div>
<div class="recent-tools" id="my-recent-tools" hidden>
<div class="recent-tools" id="my-recent-tools" hidden>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
<p class="recent-tools-status" id="my-recent-tools-status" role="status"></p>
<p class="recent-tools-status" id="my-recent-tools-status" role="status"></p>
</div>
</div>
+ <div class="td-stuff">
+ <div class="td-stats" id="td-stuff-row"></div>
+ <a class="td-more" href="https://openvibe.report/you">Your numbers on openvibe.report <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>
+ </div>
</div>
</div>
+ <h2 class="settings-h" id="settings">Settings</h2>
+
<div class="section-tabs">
<div class="section-tabs">
<button class="active" onclick="showSection('profile',this)"><i class="fa-solid fa-user"></i> Profile</button>
<button class="active" onclick="showSection('profile',this)"><i class="fa-solid fa-user"></i> Profile</button>
<button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>
<button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>
@@ -394,6 +421,7 @@
@@ -394,6 +421,7 @@
<script src="/js/my/core.js" defer></script>
<script src="/js/my/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/tools.js" defer></script>
+<script src="/js/my/today.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/consent.js" defer></script>
@@ -507,6 +507,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -507,6 +507,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read') });
const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read') });
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
+ // Your account home's "Now" (L43): the people you follow who are live, from Live's public list (held 10 s).
+ app.use('/api/v1/me/live-follows', rateLimit({ windowMs: 60_000, max: 60 }), require('./today/live-follows').router({ requireAuth, liveUrl: config.services.live.internalUrl }));
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * The people you follow who are live now: the "Now" row of your account home (/my, plan L43, 2026-10-31).
+ *
+ * GET /api/v1/me/live-follows → { live: [{ username, name, title, viewers, thumbnail, avatar, url, since }],
+ * following, anyone_live }
+ *
+ * Follows are Network's (subjects); who is live is Live's public stream list, read from Live's internal address and
+ * kept for ten seconds, so a hundred open account pages ask Live once. When Live does not answer, nobody is live:
+ * the home leaves the row out rather than failing.
+ */
+const express = require('express');
+
+const TTL_MS = 10_000;
+const TIMEOUT_MS = 2_500;
+const MAX = 12;
+let held = { at: 0, streams: [] };
+let pending = null;
+
+/** Live's public list of live streams (ten seconds old at most), or [] when Live is away. */
+async function liveStreams(liveUrl, { fetchImpl = fetch, now = Date.now } = {}) {
+ if (held.at && now() - held.at < TTL_MS) return held.streams;
+ if (pending) return pending;
+ pending = (async () => {
+ try {
+ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ const body = r.ok ? await r.json() : null;
+ held = { at: now(), streams: Array.isArray(body && body.streams) ? body.streams : [] };
+ } catch {
+ held = { at: now(), streams: [] };
+ } finally {
+ pending = null;
+ }
+ return held.streams;
+ })();
+ return pending;
+}
+
+const text = (v, n) => String(v == null ? '' : v).slice(0, n);
+const httpsUrl = (v) => (/^https:\/\/[^\s"'<>]+$/.test(String(v || '')) ? String(v) : null);
+
+function router({ requireAuth, liveUrl, publicLive = 'https://openvibe.live', fetchImpl } = {}) {
+ const r = express.Router();
+ r.get('/', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ if (!req.user || req.user.is_anon) return res.json({ live: [], following: 0, anyone_live: 0 });
+ const db = req.app.locals.db;
+ const me = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(req.user.id);
+ const follows = me && me.subject_id ? await db.prepare(`SELECT LOWER(u.username) AS username FROM user_follows f JOIN users u ON u.subject_id = f.target_id
+ WHERE f.active = 1 AND f.follower_subject = ? AND f.target_type = 'channel'`).all(me.subject_id) : [];
+ const followed = new Set(follows.map((f) => f.username));
+ const streams = await liveStreams(liveUrl, { fetchImpl });
+ const seen = new Set();
+ const live = [];
+ for (const s of streams) {
+ const name = String(s && s.username || '').toLowerCase();
+ if (!followed.has(name) || seen.has(name) || live.length >= MAX) continue;
+ seen.add(name);
+ live.push({
+ username: text(s.username, 64),
+ name: text(s.display_name || s.username, 80),
+ title: text(s.ai_title || s.title, 140),
+ viewers: Math.max(0, Number(s.total_viewer_count != null ? s.total_viewer_count : s.viewer_count) || 0),
+ thumbnail: httpsUrl(s.thumbnail_url) || (String(s.thumbnail_url || '').startsWith('/') ? publicLive + s.thumbnail_url : null),
+ avatar: httpsUrl(s.avatar_url),
+ url: `${publicLive}/@${encodeURIComponent(String(s.username || ''))}`,
+ since: text(s.started_at, 40) || null,
+ });
+ }
+ res.json({ live, following: followed.size, anyone_live: new Set(streams.map((s) => String(s && s.username || '').toLowerCase())).size });
+ });
+ return r;
+}
+
+/** Tests: forget what Live said. */
+function forget() { held = { at: 0, streams: [] }; pending = null; }
+
+module.exports = { router, liveStreams, forget };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+// Your account home's "Now" (plan L43): the people you follow who are live, from Live's public list held ten seconds.
+// Only your own follows, only who is live, Live's secrets never pass, and a Live that does not answer means nobody.
+// node --import ./test/helpers/pg-preload.mjs test/live-follows.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const liveFollows = require('../server/today/live-follows');
+
+(async () => {
+ const log = console.log; console.log = () => {};
+ const db = getDb();
+ console.log = log;
+ const PEOPLE = [[71, 'wren'], [72, 'Goosely'], [73, 'maticus'], [74, 'quietone']];
+ for (const [id, name] of PEOPLE) await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (?, ?, 'x', 'user') ON CONFLICT DO NOTHING").run(id, name);
+ const S = {};
+ for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ // wren follows Goosely (live), maticus (live) and quietone (not live); one follow ended.
+ for (const [who, whom, active] of [['wren', 'Goosely', 1], ['wren', 'maticus', 1], ['wren', 'quietone', 1], ['quietone', 'maticus', 0]]) {
+ await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, ?) ON CONFLICT DO NOTHING").run(S[who], S[whom], active);
+ }
+
+ let asked = 0; let answer = null;
+ const fetchImpl = async (url) => {
+ asked++;
+ assert.strictEqual(url, 'http://live.internal/api/streams', 'Live\'s internal address');
+ if (!answer) throw new Error('down');
+ return { ok: true, json: async () => answer };
+ };
+ answer = { streams: [
+ { username: 'goosely', display_name: 'Goosely', title: 'Geese at dawn', ai_title: '', viewer_count: 3, total_viewer_count: 9, thumbnail_url: '/thumbs/g.jpg', avatar_url: 'https://openvibe.media/a/g.png', started_at: '2026-10-31T18:00:00Z', stream_key: 'sk_secret', managed_stream_key: 'mk_secret' },
+ { username: 'maticus', display_name: 'Maticus', title: 'LOOI', viewer_count: 12, thumbnail_url: 'javascript:alert(1)', avatar_url: 'http://insecure.example/a.png' },
+ { username: 'maticus', display_name: 'Maticus', title: 'LOOI, second slot', viewer_count: 2 },
+ { username: 'stranger', display_name: 'Stranger', title: 'Not followed', viewer_count: 40 },
+ ] };
+
+ // The session itself is Network's requireAuth (tested on its own); here a request names its person.
+ const requireAuth = (req, res, next) => { const id = Number(String(req.headers.authorization || '').replace(/^User /, '')); if (!id) return res.status(401).json({ error: 'sign in' }); req.user = { id }; next(); };
+ const app = express();
+ app.locals.db = db;
+ app.use('/api/v1/me/live-follows', liveFollows.router({ requireAuth, liveUrl: 'http://live.internal', fetchImpl }));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = async (as) => fetch(`${base}/api/v1/me/live-follows`, { headers: as ? { authorization: `User ${as}` } : {} })
+ .then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
+
+ assert.strictEqual((await get()).status, 401, 'a person\'s own');
+
+ let r = await get(71);
+ assert.strictEqual(r.cache, 'private, no-store');
+ assert.deepStrictEqual(r.body.live.map((x) => x.username), ['goosely', 'maticus'], 'the people wren follows who are live, once each, in Live\'s order');
+ assert.deepStrictEqual([r.body.following, r.body.anyone_live], [3, 3]);
+ const g = r.body.live[0];
+ assert.deepStrictEqual([g.name, g.title, g.viewers, g.thumbnail, g.avatar, g.url], ['Goosely', 'Geese at dawn', 9, 'https://openvibe.live/thumbs/g.jpg', 'https://openvibe.media/a/g.png', 'https://openvibe.live/@goosely']);
+ assert.ok(!JSON.stringify(r.body).includes('secret'), 'no ingest key passes');
+ const m = r.body.live[1];
+ assert.deepStrictEqual([m.thumbnail, m.avatar], [null, null], 'only https pictures, and Live\'s own paths');
+
+ r = await get(73);
+ assert.deepStrictEqual([r.body.live, r.body.following], [[], 0], 'nobody followed: nobody live');
+ r = await get(74);
+ assert.deepStrictEqual(r.body.live, [], 'an ended follow does not count');
+ assert.strictEqual(asked, 1, 'Live was asked once for all of them');
+
+ // Live away: nobody is live, and the page still answers.
+ liveFollows.forget(); answer = null;
+ r = await get(71);
+ assert.deepStrictEqual([r.status, r.body.live, r.body.anyone_live], [200, [], 0]);
+
+ server.close();
+ console.log('live follows: your follows who are live, once each, no secrets, Live asked once per 10 s, Live away means nobody');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// The module map, in load order (public/js/my/…).
-const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
+const modules = ['core.js', 'tools.js', 'today.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -41,7 +41,7 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
@@ -41,7 +41,7 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');
const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');
assert.ok(bootSrc.includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
assert.ok(bootSrc.includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
// Signed out (loadUser() redirects to /login), no section loads and no badge is asked for: each would answer 401.
// Signed out (loadUser() redirects to /login), no section loads and no badge is asked for: each would answer 401.
-assert.match(bootSrc, /if \(_signedIn\) \{\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API');
+assert.match(bootSrc, /if \(_signedIn\) \{\s*loadToday\(\);\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API (You, today included)');
// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
{
{
const vm = require('vm');
const vm = require('vm');
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+// The account home, "You, today" (plan L43; the owner, 10-31: "I hate this dashboard… redo the whole thing"). my/today.js
+// runs here on a tiny DOM with sample answers from each API:
+// - Now: the people you follow who are live, a notifications chip, Go live only for a streamer;
+// - Continue: the newest history, one card per address, https only;
+// - Your sites: ranked by your own use (pins, history counts, recents), Network itself left out, two to try that you
+// have not used; Your money from /api/coins; your numbers;
+// - an empty block stays out, and no API answer breaks the page.
+// It also checks the page: no Quick Access grid, no email beside your name, Settings below.
+// node test/my-today.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/today.js'), 'utf8');
+const HTML = fs.readFileSync(path.join(__dirname, '..', 'public/my.html'), 'utf8');
+const PROFILE = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/profile.js'), 'utf8');
+const BOOT = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/boot.js'), 'utf8');
+const TOOLS = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/tools.js'), 'utf8');
+
+class Node {
+ constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this.hidden = false; this.listeners = {}; this._text = ''; this.style = {}; }
+ setAttribute(k, v) { this.attrs[k] = String(v); }
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
+ append(...kids) { for (const k of kids) this.children.push(typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k); }
+ prepend(k) { this.children.unshift(k); }
+ replaceChildren(...kids) { this.children = []; this.append(...kids); }
+ remove() {}
+ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
+ click() { (this.listeners.click || []).forEach((fn) => fn({})); }
+ scrollIntoView() { this.scrolled = true; }
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
+ set textContent(v) { this._text = String(v); this.children = []; }
+ get href() { return this.attrs.href; }
+ get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
+ all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
+}
+
+function page() {
+ const ids = {};
+ for (const [, id] of HTML.matchAll(/id="((?:td-|user-greeting|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
+ const shown = [];
+ const sandbox = {
+ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
+ console, Date, Number, String, Map, Set, Math, JSON, Promise,
+ currentUser: { role: 'user' },
+ showSection: (id) => shown.push(id),
+ isAnonSession: () => false,
+ };
+ vm.runInNewContext(SRC, sandbox);
+ return { ids, sandbox, shown };
+}
+const links = (n) => n.all((c) => c.tagName === 'A').map((a) => a.href);
+const ago = (min) => new Date(Date.now() - min * 60000).toISOString();
+
+// ── The page: no Quick Access grid, no email next to your name, Settings below ──
+assert.ok(!/Quick Access|showcase-grid|quick-access/.test(HTML), 'the Quick Access grid is gone');
+assert.ok(!/buildDashboardShowcase/.test(TOOLS + BOOT), 'and its builder');
+assert.match(HTML, /id="today"/);
+assert.ok(HTML.indexOf('id="today"') < HTML.indexOf('id="settings"') && HTML.indexOf('id="settings"') < HTML.indexOf('class="section-tabs"'), 'You, today first, then Settings');
+assert.ok(!/user\.email \? ' · '/.test(PROFILE), 'the email is not beside your name (this page may be on a stream)');
+assert.match(BOOT, /loadToday\(\)/, 'boot starts it');
+assert.match(HTML, /id="coins"/, 'the navbar\'s OpenCoins chip (/my#coins) lands on Your money');
+
+(async () => {
+ // ── Everything at once ──
+ let p = page();
+ p.sandbox.currentUser = { role: 'streamer' };
+ p.sandbox.renderNow({ live: [
+ { username: 'goosely', name: 'Goosely', title: 'Geese at dawn', viewers: 9, thumbnail: 'https://openvibe.live/t/g.jpg', url: 'https://openvibe.live/@goosely' },
+ { username: 'maticus', name: 'Maticus', title: 'LOOI', viewers: 0, thumbnail: 'javascript:alert(1)', url: 'https://openvibe.live/@maticus' },
+ ], following: 14, anyone_live: 5 }, { count: 3 });
+ assert.strictEqual(p.ids['td-now'].hidden, false);
+ assert.deepStrictEqual(links(p.ids['td-live']), ['https://openvibe.live/@goosely', 'https://openvibe.live/@maticus']);
+ assert.strictEqual(p.ids['td-live'].all((c) => c.tagName === 'IMG').length, 1, 'only an https thumbnail is shown');
+ assert.strictEqual(p.ids['td-now-sub'].textContent, '2 of the 14 you follow');
+ const chips = p.ids['td-chips'];
+ assert.match(chips.textContent, /3 new notifications/);
+ assert.match(chips.textContent, /Go live/, 'a streamer can go live from here');
+ assert.ok(!/live on OpenVibe now/.test(chips.textContent), 'not "who else is live" while people you follow are');
+
+ p.sandbox.renderContinue({ items: [
+ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', service_label: 'Live', updated_at: ago(5) },
+ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', updated_at: ago(50) },
+ { title: 'My theme', url: 'https://openvibe.design/themes/mine', service: 'design', icon: 'fa-palette', updated_at: ago(130) },
+ { title: 'Odd', url: 'javascript:alert(1)', service: 'live', updated_at: ago(1) },
+ ] });
+ assert.deepStrictEqual(links(p.ids['td-continue-list']), ['https://openvibe.live/@goosely', 'https://openvibe.design/themes/mine'], 'one card per address, https only');
+ assert.match(p.ids['td-continue-list'].textContent, /Live · 5 min ago/);
+ assert.match(p.ids['td-continue-list'].textContent, /2 h ago/);
+
+ p.sandbox.renderSites(
+ { services: [{ service: 'network', count: 99 }, { service: 'live', count: 40 }, { service: 'tools', count: 12 }, { service: 'nope', count: 9 }] },
+ { pinned: ['design'], recent: ['games', 'live'] });
+ assert.deepStrictEqual(links(p.ids['td-sites-grid']), ['https://openvibe.design', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.games'], 'pins first, then your own use; Network and unknown sites left out');
+ const tries = links(p.ids['td-try']);
+ assert.strictEqual(tries.length, 2, 'two to try');
+ assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');
+ assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');
+
+ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });
+ assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);
+ assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);
+
+ p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });
+ assert.match(p.ids['td-stuff-row'].textContent, /14following/);
+ assert.match(p.ids['td-stuff-row'].textContent, /1machines/, 'revoked machines do not count');
+ p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
+ assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
+
+ // ── Nothing to show: each block stays out; a new account gets "Start here" ──
+ p = page();
+ p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });
+ assert.strictEqual(p.ids['td-now'].hidden, true, 'nobody you follow is live: no Live now row');
+ assert.match(p.ids['td-chips'].textContent, /4 live on OpenVibe now/, 'but who else is, in one chip');
+ assert.ok(!/Go live|notification/.test(p.ids['td-chips'].textContent), 'no Go live for a viewer, no zero count');
+ p.sandbox.renderContinue(null);
+ assert.strictEqual(p.ids['td-continue'].hidden, true);
+ p.sandbox.renderSites(null, null);
+ assert.deepStrictEqual([p.ids['td-sites'].hidden, p.ids['td-sites-h'].textContent, links(p.ids['td-try']).length], [false, 'Start here', 2]);
+ p.sandbox.renderMoney(null, null);
+ assert.strictEqual(p.ids.coins.hidden, true, 'no coins answer: no money block');
+ p.sandbox.renderStuff(null, null, null);
+ assert.strictEqual(p.ids['td-stuff-row'].children.length, 0);
+
+ // ── Every API down: the page still draws ──
+ p = page();
+ p.sandbox.apiFetch = () => Promise.reject(Object.assign(new Error('down'), { status: 503 }));
+ await p.sandbox.loadToday();
+ assert.ok(/td-ready/.test(p.ids.today.className), 'drawn');
+ assert.ok(p.ids['td-greeting'] === undefined && /Good|Up late/.test(p.ids['user-greeting'].textContent), 'with a greeting');
+
+ console.log('my today: live follows, continue, your sites by your own use, money and numbers; empty blocks stay out; APIs down still draws');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -119,6 +119,7 @@ const REVIEWED = {
@@ -119,6 +119,7 @@ const REVIEWED = {
'server/discord/discord-service.js fetch': [2, 'discord.js channels.fetch: Discord API only'],
'server/discord/discord-service.js fetch': [2, 'discord.js channels.fetch: Discord API only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
+ 'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],