openvibers/openvibe.live Commits

Following in the topics row; the picked topic remembered (#525)

OP OpenVibers committed 12 days ago · 3174d38321 · 6 files, +150 −10

* Following in the topics row, and the picked topic remembered

* Budget: 1252.3 KB of JS (Following chip)

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex ba2471cc..8069f10a 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -473,6 +473,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }
++/* Following: channels the viewer follows that are live now, marked with the live dot. */
++.home-topic[data-topic="@following"]::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--live-red, #ef4444); }
@media (pointer: coarse) { .home-topic { min-height: 44px; } }
.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 981d9183..68d3a16b 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1783,20 +1783,45 @@ function _streamTopics(s) {
return out;
}
++// The chip a viewer picked comes back the next time it is live (market outcome 2: a default set), in this browser
++// only; _homeTopic is what is applied now, which is All while the picked topic is off air.
++const _TOPIC_KEY = 'ov_home_topic';
++let _homeTopicPref = (() => { try { return (typeof localStorage !== 'undefined' && localStorage.getItem(_TOPIC_KEY)) || ''; } catch { return ''; } })();
let _homeTopic = '';
--/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more. */
++// Following (market outcome 3): the live channels the signed-in viewer follows, from GET /api/streams/following
++// (private, never cached by the edge), read at most once a minute. A chip of its own, first after All.
++const _FOLLOWING = '@following';
++let _homeFollowing = { at: 0, ids: new Set(), busy: false };
++let _homeLastStreams = [];
++function _homeFollowingRefresh() {
++ const me = typeof currentUser !== 'undefined' && currentUser;
++ if (!me || typeof api !== 'function') { if (!me) _homeFollowing = { at: 0, ids: new Set(), busy: false }; return; }
++ if (_homeFollowing.busy || Date.now() - _homeFollowing.at < 60000) return;
++ _homeFollowing.busy = true;
++ api('/streams/following').then((d) => {
++ _homeFollowing = { at: Date.now(), ids: new Set(((d && d.user_ids) || []).map(Number)), busy: false };
++ _homeTopicsSync(_homeLastStreams);
++ }).catch(() => { _homeFollowing = { ..._homeFollowing, at: Date.now(), busy: false }; });
++}
++/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more, or
++ * some of them are channels the viewer follows. */
function _homeTopicsSync(streams) {
const row = document.getElementById('home-topics');
if (!row) return;
++ _homeLastStreams = streams;
++ _homeFollowingRefresh();
const counts = new Map();
for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);
-- const show = streams.length >= 2 && counts.size >= 2;
++ const followingN = streams.filter((s) => _homeFollowing.ids.has(Number(s.user_id))).length;
++ const show = streams.length >= 2 && (counts.size >= 2 || followingN > 0);
row.hidden = !show;
if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }
-- if (_homeTopic && !counts.has(_homeTopic)) _homeTopic = '';
++ const available = new Set(counts.keys());
++ if (followingN) available.add(_FOLLOWING);
++ _homeTopic = available.has(_homeTopicPref) ? _homeTopicPref : '';
// Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.
const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
-- const key = `${streams.length}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;
++ const key = `${streams.length}|${followingN}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;
if (row.dataset.key !== key) {
row.dataset.key = key;
const chip = (topic, label, n) => {
@@@ -1812,6 +1837,7 @@ function _homeTopicsSync(streams) {
return b;
};
const nodes = [chip('', 'All', streams.length)];
++ if (followingN) nodes.push(chip(_FOLLOWING, 'Following', followingN));
for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));
row.replaceChildren(...nodes);
if (!row._ovBound) {
@@@ -1819,7 +1845,10 @@ function _homeTopicsSync(streams) {
row.addEventListener('click', (e) => {
const b = e.target.closest('.home-topic');
if (!b) return;
-- _homeTopic = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;
++ const next = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;
++ _homeTopicPref = next;
++ _homeTopic = next;
++ try { if (next) localStorage.setItem(_TOPIC_KEY, next); else localStorage.removeItem(_TOPIC_KEY); } catch { /* private mode */ }
_homeTopicsApply();
});
}
@@@ -1832,7 +1861,10 @@ function _homeTopicsApply() {
const grid = document.getElementById('stream-grid-live');
if (!grid) return;
grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {
-- card.hidden = !!_homeTopic && !(card.dataset.topics || '').split('|').includes(_homeTopic);
++ const keep = !_homeTopic || (_homeTopic === _FOLLOWING
++ ? _homeFollowing.ids.has(Number(card.dataset.user))
++ : (card.dataset.topics || '').split('|').includes(_homeTopic));
++ card.hidden = !keep;
});
}
@@@ -1853,7 +1885,7 @@ function streamCardHTML(s, isLive) {
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const shown = topics.filter((t) => t !== cat).slice(0, 2);
return `
-- <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
++ <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 42d3cff0..5b7ce4e9 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1251, // 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsRawKB: 1253, // 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 7c89f783..8a9ed697 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1222,6 +1222,21 @@ router.get('/', optionalAuth, async (req, res) => {
});
// ── Tags In Use (the browse filters) ─────────────────────────
++// ── The Live Channels I Follow (the home's Following chip) ─────
++// Ids only, of channels live right now that the signed-in viewer follows (Live's projection of Network's follows).
++// Private: never cached by a browser or the edge.
++router.get('/following', requireAuth, async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ try {
++ const rows = await db.all(`SELECT DISTINCT s.user_id FROM streams s JOIN follows f ON f.streamer_id = s.user_id
++ WHERE f.follower_id = ? AND s.is_live = 1`, [req.user.id]);
++ res.json({ user_ids: rows.map((r) => Number(r.user_id)) });
++ } catch (err) {
++ console.error('[Streams] Following error:', err.message);
++ res.status(500).json({ error: 'Failed to read the channels you follow' });
++ }
++});
++
router.get('/tags', async (req, res) => {
try {
// The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).
ddiff --git a/test/home-topics.test.js b/test/home-topics.test.js
iindex 5f6ec229..4aa7973e 100644
---- a/test/home-topics.test.js
++++ b/test/home-topics.test.js
@@@ -27,9 +27,10 @@ function fakeDom(cardTopics) {
};
return { document, row, chips, cards };
}
--function load(dom) {
++function load(dom, { currentUser, api } = {}) {
const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
-- return new Function('document', '_capTag', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; } };`)(dom.document, _capTag);
++ return new Function('document', '_capTag', 'currentUser', 'api', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; },
++ setFollowing(ids) { _homeFollowing = { at: Date.now(), ids: new Set(ids), busy: false }; } };`)(dom.document, _capTag, currentUser, api);
}
// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.
@@@ -78,8 +79,32 @@ const streams = [
assert.strictEqual(m.topic, '', 'no stream has it any more');
}
++// Following (market outcome 3): the live channels the signed-in viewer follows are a chip of their own, first after
++// All, and it filters by the card's user.
++{
++ const dom = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);
++ dom.cards.forEach((c, i) => { c.dataset.user = String(10 + i); });
++ const m = load(dom, { currentUser: { id: 1 }, api: () => new Promise(() => {}) });
++ m.setFollowing([10, 12]);
++ m._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));
++ assert.deepStrictEqual(dom.chips.slice(0, 2).map((c) => c.dataset.topic), ['', '@following']);
++ assert.strictEqual(dom.chips[1].children[2].textContent, '2', 'two followed channels are live');
++ dom.row.onclick({ target: { closest: () => dom.chips[1] } });
++ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, true, false], 'only the followed channels stay');
++ // Signed out, there is no Following chip.
++ const out = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);
++ load(out)._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));
++ assert.ok(!out.chips.some((c) => c.dataset.topic === '@following'));
++}
++
// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.
assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');
++assert.match(src, /data-user="\$\{esc\(String\(s\.user_id \|\| ''\)\)\}"/, 'and its channel, for Following');
++// The picked chip is remembered in this browser and comes back when that topic is live again.
++assert.match(block, /localStorage\.setItem\(_TOPIC_KEY, next\)/);
++const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'routes.js'), 'utf8');
++assert.match(routes, /router\.get\('\/following', requireAuth,[\s\S]{0,120}'private, no-store'/, 'Following is private');
++assert.ok(routes.indexOf("router.get('/following'") < routes.indexOf("router.get('/:id'"), 'before the /:id route');
const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');
assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');
ddiff --git a/test/streams-following.test.js b/test/streams-following.test.js
nnew file mode 100644
iindex 00000000..d7152efe
---- /dev/null
++++ b/test/streams-following.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * GET /api/streams/following: the ids of channels live right now that the signed-in viewer follows (the home's
++ * Following chip, Live market outcome 3). Private: never cached by a browser or the edge. Signed out: 401.
++ * node test/streams-following.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++process.env.NODE_ENV = 'test';
++console.warn = () => {};
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++
++const db = require('../server/db/database');
++// The route's own requireAuth reads a token; here a header names the signed-in user instead.
++const auth = require('../server/auth/auth');
++auth.requireAuth = (req, res, next) => {
++ const id = Number(req.headers['x-test-user']);
++ if (!id) return res.status(401).json({ error: 'Authentication required' });
++ req.user = { id };
++ return next();
++};
++
++(async () => {
++ await db.initDb();
++ const mk = async (username) => {
++ await db.createUser({ username, display_name: username, stream_key: username.padEnd(32, '0') });
++ return Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
++ };
++ const ts = (msAgo) => new Date(Date.now() - msAgo).toISOString().slice(0, 19).replace('T', ' ');
++ const stream = async (userId, live) => db.run(
++ `INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at, created_at) VALUES (?, ?, 'webrtc', ?, ?, ?, ?)`,
++ [userId, `${userId} stream`, live ? 1 : 0, ts(60000), live ? null : ts(30000), ts(60000)]);
++ const follow = async (follower, streamer) => db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [follower, streamer]);
++
++ const viewer = await mk('viewer'), bob = await mk('bob'), carol = await mk('carol'), dave = await mk('dave');
++ await stream(bob, true); // followed, live
++ await stream(carol, true); // live, not followed
++ await stream(dave, false); // followed, not live
++ await follow(viewer, bob);
++ await follow(viewer, dave);
++
++ const app = express();
++ app.use('/api/streams', require('../server/streaming/routes'));
++ const server = await new Promise((resolve) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ try {
++ const out = await fetch(`${base}/api/streams/following`);
++ assert.strictEqual(out.status, 401, 'signed out: 401');
++
++ const me = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(viewer) } });
++ assert.strictEqual(me.status, 200);
++ assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'never cached');
++ assert.deepStrictEqual((await me.json()).user_ids, [bob], 'only followed channels that are live now');
++
++ const nobody = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(carol) } });
++ assert.deepStrictEqual((await nobody.json()).user_ids, [], 'following nobody live: an empty list');
++ console.log('streams following: all checks passed');
++ } finally {
++ server.close();
++ await db.closeDb?.();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });