openvibers/openvibe.live Commits

Connect the home visit digest to the Rundown (#734)

OP OpenVibers committed 4 days ago · 3758c5dead · 6 files, +261 −65

* Polish Rundown feed and make the sticky bar follow the navbar

* Connect the home visit digest to Rundown and OpenVibe activity

* Merge origin/main into home digest branch

* Test home digest topic selection against the new design

* Merge remote-tracking branch 'origin/main' into auto/live-home-digest-1109

* Since you were here: tiles line up, names and Across OpenVibe read in full

The stat tiles stretched to the tallest one and pushed their numbers down; they start at the top now. A streamer's line
and each Across OpenVibe item take two lines instead of being cut mid-word on a phone.

* Merge remote-tracking branch 'origin/main' into auto/live-home-digest-1109

* Merge remote-tracking branch 'origin/main' into auto/live-home-digest-1109

# Conflicts:
#	server/home/routes.js

* Merge remote-tracking branch 'origin/main' into auto/live-home-digest-1109

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex c420ef30..a16f4012 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -213,6 +213,71 @@
.digest-hot-line { display: block; font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; text-decoration: none; }
.digest-hot-line:hover b { text-decoration: underline; }
@media (max-width: 640px) { .digest-extra { white-space: normal; } }
++/* Visit digest: one compact stack of activity, topic-linked takes and people. */
++#page-home .home-digest { background: var(--bg-secondary); box-shadow: none; border-color: var(--border); }
++#page-home .digest-head { margin-bottom: 10px; }
++#page-home .digest-title { margin: 0; font-size: 1rem; line-height: 1.35; letter-spacing: 0; text-transform: none; }
++#page-home .digest-title span { color: var(--text-muted); font-size: .8rem; font-weight: 500; white-space: nowrap; }
++#page-home .digest-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
++#page-home .digest-tile { min-width: 0; min-height: 70px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-tertiary); display: flex; flex-direction: column; justify-content: flex-start; gap: 4px; }
++#page-home .digest-tile-label { color: var(--text-secondary); font-size: .75rem; white-space: nowrap; }
++#page-home .digest-tile-label i { color: var(--accent); margin-right: 3px; }
++#page-home .digest-tile-value { color: var(--text-primary); font-weight: 800; font-size: 1.25rem; font-variant-numeric: tabular-nums; line-height: 1.1; }
++#page-home .digest-tile-value small { font-weight: 600; font-size: .75rem; color: var(--text-muted); }
++#page-home .digest-new-faces { display: flex; margin-top: 4px; }
++#page-home .digest-new-faces a { margin-right: -5px; border-radius: 50%; }
++#page-home .digest-avatar { width: 28px; height: 28px; flex: none; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary); border: 1px solid var(--border); }
++#page-home .digest-avatar--initial { display: inline-grid; place-items: center; font-size: .75rem; font-weight: 800; color: var(--text-primary); }
++#page-home .digest-section { margin-top: 11px; padding-top: 9px; border-top: 1px solid var(--border); }
++#page-home .digest-section h3 { margin: 0 0 6px; font-size: .76rem; font-weight: 800; color: var(--text-secondary); }
++#page-home .digest-topic-row { min-height: 49px; display: flex; align-items: center; gap: 8px; padding: 4px 0; color: var(--text-primary); text-decoration: none; border-radius: 6px; }
++#page-home .digest-topic-row:hover, #page-home .digest-person-actions a:hover, #page-home .digest-across a:hover { color: var(--accent); }
++#page-home .digest-topic-chip { max-width: 95px; flex: 0 0 auto; padding: 3px 6px; border-radius: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .75rem; font-weight: 700; background: color-mix(in srgb, var(--accent) 15%, var(--bg-tertiary)); color: var(--accent); }
++#page-home .digest-topic-chip--games, #page-home .digest-topic-chip--gaming { background: color-mix(in srgb, #aa79fa 16%, var(--bg-tertiary)); color: #aa79fa; }
++#page-home .digest-topic-chip--tech { background: color-mix(in srgb, #48bcb4 16%, var(--bg-tertiary)); color: #238e88; }
++#page-home .digest-topic-chip--culture { background: color-mix(in srgb, #e0a342 16%, var(--bg-tertiary)); color: #ae711e; }
++#page-home .digest-topic-text { min-width: 0; display: flex; align-items: center; gap: 7px; }
++#page-home .digest-topic-text > span { min-width: 0; }
++#page-home .digest-topic-text strong, #page-home .digest-topic-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++#page-home .digest-topic-text strong { font-size: .79rem; font-weight: 600; }
++#page-home .digest-topic-text small { margin-top: 2px; color: var(--text-muted); font-size: .75rem; }
++#page-home .digest-person { display: flex; align-items: center; gap: 8px; min-height: 47px; }
++#page-home .digest-person-main { min-width: 0; flex: 1; }
++#page-home .digest-person-main a { color: var(--text-primary); font-size: .8rem; font-weight: 700; text-decoration: none; }
++#page-home .digest-person-main small { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: var(--text-muted); font-size: .75rem; line-height: 1.35; }
++#page-home .digest-person-actions { display: flex; align-items: center; gap: 4px; }
++#page-home .digest-person-actions a { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 7px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-secondary); font-size: .75rem; font-weight: 700; white-space: nowrap; text-decoration: none; }
++#page-home .digest-person-actions i { margin-right: 3px; }
++#page-home .digest-across { display: flex; align-items: center; gap: 6px 12px; margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border); }
++#page-home .digest-across strong { flex: none; font-size: .71rem; }
++#page-home .digest-across a { min-width: 0; display: flex; align-items: center; color: var(--text-secondary); text-decoration: none; font-size: .75rem; }
++/* Across OpenVibe: each item in full on two lines at most (it was cut mid-word on a phone). */
++#page-home .digest-across a span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }
++#page-home .digest-across i { color: var(--accent); margin-right: 3px; }
++#page-home .digest-across small { color: var(--text-muted); }
++#page-home .digest-empty { margin: 0; font-size: .84rem; color: var(--text-secondary); }
++#page-home .digest-empty a { color: var(--accent); }
++@media (min-width: 701px) {
++ #page-home .home-digest { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
++ #page-home .digest-head, #page-home .digest-tiles, #page-home .digest-across, #page-home .digest-empty { grid-column: 1 / -1; }
++ #page-home .digest-section { min-width: 0; }
++}
++@media (max-width: 700px) {
++ #page-home .digest-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ #page-home .digest-tile { min-height: 62px; }
++ #page-home .digest-across { align-items: flex-start; flex-wrap: wrap; }
++ #page-home .digest-across strong { width: 100%; }
++ #page-home .digest-across a { max-width: 48%; }
++}
++@media (max-width: 400px) {
++ #page-home .digest-topic-row { display: block; padding: 6px 0; }
++ #page-home .digest-topic-chip { display: inline-block; width: max-content; max-width: 100%; margin-bottom: 4px; }
++ #page-home .digest-topic-text strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; }
++}
++@media (pointer: coarse) {
++ #page-home .digest-topic-row, #page-home .digest-person, #page-home .digest-person-actions a, #page-home .digest-across a { min-height: 44px; }
++ #page-home .digest-new-faces a { min-width: 32px; min-height: 32px; }
++}
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal .goal-bar { flex: 1; }
.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 23301596..87c2c0c9 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1454,38 +1454,8 @@ async function loadHomeDigest() {
let d;
try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }
catch { box.innerHTML = ''; box.style.display = 'none'; return; }
-- if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }
-- const away = !!(since && currentUser);
-- const n = (v) => Number(v || 0).toLocaleString();
-- const chip = (u, extra, mods) => `
-- <a class="digest-chip ${mods}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')" title="${esc(u.last_title || u.title || '')}">
-- ${_avatarSpan(u.avatar_url, u.username, u.profile_color)}
-- <span class="digest-name">${esc(u.display_name || u.username)}</span>
-- <span class="digest-extra">${extra}</span>
-- ${u.followed ? '<span class="digest-follow" title="You follow them"><i class="fa-solid fa-heart"></i></span>' : ''}
-- </a>${u.recap_stream_id ? `<a class="digest-recap" href="/recap/${u.recap_stream_id}" onclick="return handleLinkClick(event, '/recap/${u.recap_stream_id}')" title="After-show report"><i class="fa-solid fa-clipboard-list"></i></a>` : ''}`;
-- const parts = [];
-- for (const u of (d.liveNow || [])) parts.push(chip(u, `<i class="fa-solid fa-circle live-dot"></i> LIVE${u.viewer_count ? ` · ${n(u.viewer_count)} watching` : ' now'}`, `digest-chip--live${u.followed ? ' digest-chip--followed' : ''}`));
-- for (const u of (d.streamed || [])) parts.push(chip(u, `${u.sessions > 1 ? `${u.sessions}× · ` : ''}${u.hours >= 0.1 ? `${u.hours}h · ` : ''}${esc(timeAgo(u.last_at))}${u.peak_viewers > 1 ? ` · peak ${n(u.peak_viewers)}` : ''}`, u.followed ? 'digest-chip--followed' : ''));
-- const st = d.stats || {};
-- const stats = [
-- st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
-- st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',
-- st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',
-- st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
-- st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
-- st.takes ? `<span class="digest-stat"><i class="fa-solid fa-newspaper"></i> <b>${n(st.takes)}</b> Rundown take${st.takes === 1 ? '' : 's'}</span>` : '',
-- ].filter(Boolean).join('');
-- const hot = (d.hot || []).map(h => { const href = h.slug ? `/rundown/t/${h.slug}` : `/rundown/${h.username}`; const t = esc(String(h.text || '').slice(0, 140)); return `<a class="digest-hot-line" href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"><i class="fa-solid fa-quote-left"></i> ${h.quoted ? `“${t}”` : t} <b>— ${esc(h.display_name || h.username)}</b></a>`; }).join('');
-- const sinceLabel = d.since ? (away ? `since your last visit · ${esc(timeAgo(d.since))}` : `last ${Math.max(1, Math.round((Date.now() - Date.parse(d.since)) / 3600000))}h`) : '';
-- box.innerHTML = `
-- <div class="digest-head">
-- <span class="digest-title"><i class="fa-solid ${away ? 'fa-clock-rotate-left' : 'fa-bolt'}"></i> ${away ? 'While you were away' : 'Lately on OpenVibe'}</span>
-- <span class="digest-since">${sinceLabel}</span>
-- </div>
-- ${stats ? `<div class="digest-stats">${stats}</div>` : ''}
-- ${parts.length ? `<div class="digest-row">${parts.join('')}</div>` : ''}
-- ${hot ? `<div class="digest-hot">${hot}</div>` : ''}`;
++ if (!d || typeof d.html !== 'string') { box.style.display = 'none'; return; }
++ box.innerHTML = d.html;
box.style.display = '';
}
ddiff --git a/server/home/digest.js b/server/home/digest.js
nnew file mode 100644
iindex 00000000..322c9409
---- /dev/null
++++ b/server/home/digest.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++const { frontPageOk } = require('./front-page-text');
++
++/** Rows arrive best first. Skip unsafe or unlabelled takes before keeping three distinct topics. */
++function selectTopics(rows) {
++ const seen = new Set();
++ return (rows || []).map((r) => {
++ let quote = null;
++ try { quote = JSON.parse(r.quotes_json || '[]')[0] || null; } catch { /* use the headline */ }
++ return { id: r.id, stream_id: r.stream_id, text: quote?.text || r.title, topic: r.topic, kind: r.kind,
++ said_at: r.said_at, username: r.username, display_name: r.display_name, avatar_url: r.avatar_url };
++ }).filter((r) => {
++ if (!frontPageOk(r.text) || !frontPageOk(r.topic) || seen.has(r.topic)) return false;
++ seen.add(r.topic);
++ return true;
++ }).slice(0, 3);
++}
++
++const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
++const age = (value) => {
++ const ms = Date.now() - Date.parse(value);
++ if (!Number.isFinite(ms)) return 'recently';
++ if (ms < 3600000) return `${Math.max(1, Math.floor(ms / 60000))} min ago`;
++ if (ms < 86400000) return `${Math.floor(ms / 3600000)} h ago`;
++ return `${Math.floor(ms / 86400000)} d ago`;
++};
++const until = (value) => {
++ const ms = Date.parse(value) - Date.now();
++ if (!Number.isFinite(ms) || ms <= 0) return 'soon';
++ if (ms < 3600000) return `in ${Math.max(1, Math.ceil(ms / 60000))} min`;
++ if (ms < 86400000) return `in ${Math.ceil(ms / 3600000)} h`;
++ return `in ${Math.ceil(ms / 86400000)} d`;
++};
++const avatar = (user) => user.avatar_url
++ ? `<img class="digest-avatar" src="${esc(user.avatar_url)}" alt="" loading="lazy">`
++ : `<span class="digest-avatar digest-avatar--initial">${esc((user.display_name || user.username || '?').slice(0, 1).toUpperCase())}</span>`;
++const name = (user) => esc(user.display_name || user.username);
++const delta = (value) => value > 0 ? `+${value}` : String(value);
++const tile = (icon, label, value, change, extra = '') => `<div class="digest-tile"><span class="digest-tile-label"><i class="fa-solid ${icon}" aria-hidden="true"></i> ${label}</span><span class="digest-tile-value">${Number(value || 0).toLocaleString()}${change == null ? '' : ` <small title="Today compared with yesterday">${delta(change)}</small>`}</span>${extra}</div>`;
++const validProfile = (username) => /^[a-z0-9_-]{1,64}$/i.test(String(username || ''));
++const profile = (u) => validProfile(u.username) ? `/@${encodeURIComponent(u.username)}` : '/';
++const text = (s, max = 145) => esc(String(s || '').slice(0, max));
++
++function render(d) {
++ const active = d.stats.streams || d.stats.takes || d.stats.new_follows || d.stats.chat_lines || d.stats.new_members || d.liveNow.length || d.streamed.length || d.topics.length || d.across.length;
++ const since = age(d.since);
++ if (!active) {
++ const next = d.nextStream;
++ return `<div class="digest-head"><h2 class="digest-title">Since you were here <span>· ${esc(since)}</span></h2></div><p class="digest-empty">Nothing new since ${esc(since)}${next ? `. Next up: <a href="${profile(next)}">${text(next.title, 70)} with ${name(next)}</a> ${esc(until(next.starts_at))}` : '.'}</p>`;
++ }
++ const st = d.stats;
++ const third = ['fa-heart', 'New follows', st.new_follows, d.changes.new_follows];
++ const faces = d.newPeople.map((p) => `<a href="${profile(p)}" aria-label="${name(p)} joined OpenVibe" title="${name(p)}">${avatar(p)}</a>`).join('');
++ const tiles = [tile('fa-tower-broadcast', 'Streams', st.streams, d.changes.streams), tile('fa-newspaper', 'Rundown takes', st.takes, d.changes.takes), tile(...third), tile('fa-user-plus', 'New here', st.new_members, d.changes.new_members, faces ? `<span class="digest-new-faces">${faces}</span>` : '')].join('');
++ const topics = d.topics.map((t) => {
++ const link = `/rundown/e/${Number(t.stream_id)}#rd-take-${Number(t.id)}`;
++ return `<a class="digest-topic-row" href="${link}"><span class="digest-topic-chip digest-topic-chip--${esc(t.kind || 'other')}">${text(t.topic, 40)}</span><span class="digest-topic-text">${avatar(t)}<span><strong>${text(t.text)}</strong><small>in ${name(t)}'s stream · ${esc(age(t.said_at))}</small></span></span></a>`;
++ }).join('');
++ const people = [...d.liveNow.map((u) => ({ ...u, live: true })), ...d.streamed].slice(0, 3).map((u) => {
++ const watch = u.watch_url || (u.live ? profile(u) : null);
++ const details = u.live ? `Live now · ${Number(u.viewer_count || 0)} watching` : `streamed ${Number(u.sessions) === 1 ? 'once' : `${Number(u.sessions)} times`} · ${Math.max(1, Math.round(Number(u.hours || 0) * 60))} min · peak ${Number(u.peak_viewers || 0)} watching`;
++ return `<div class="digest-person">${avatar(u)}<span class="digest-person-main"><a href="${profile(u)}">${name(u)}</a><small>${esc(details)}</small></span><span class="digest-person-actions">${watch ? `<a href="${esc(watch)}">Watch</a>` : ''}<a href="/rundown/${encodeURIComponent(u.username)}"><i class="fa-solid fa-newspaper" aria-hidden="true"></i> Rundown</a></span></div>`;
++ }).join('');
++ const across = d.across.length ? `<div class="digest-across"><strong>Across OpenVibe</strong>${d.across.map((x) => `<a href="${esc(x.url)}"><i class="fa-solid ${esc(x.icon)}" aria-hidden="true"></i><span>${text(x.title, 62)} <small>· ${esc(x.site)}</small></span></a>`).join('')}</div>` : '';
++ return `<div class="digest-head"><h2 class="digest-title">Since you were here <span>· ${esc(since)}</span></h2></div><div class="digest-tiles">${tiles}</div>${topics ? `<section class="digest-section"><h3>What they talked about</h3>${topics}</section>` : ''}${people ? `<section class="digest-section"><h3>Who streamed</h3>${people}</section>` : ''}${across}`;
++}
++
++module.exports = { render, selectTopics };
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex d8204822..0a5fe037 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -15,6 +15,8 @@ const db = require('../db/database');
const { can } = require('../auth/permissions');
const media = require('../media-client');
const { videoUrl } = require('../web/video-url');
++const { render: renderDigest, selectTopics } = require('./digest');
++const rundownRead = require('../rundown/read');
const router = express.Router();
const SLOGAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // must match slogan-job INTERVAL_MS
@@@ -498,6 +500,49 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
// users), the numbers, and the best Rundown takes.
const { requireAuth, optionalAuth } = require('../auth/auth');
++let acrossCache = { at: 0, frame: [], report: null };
++async function acrossOpenVibe(sinceMs) {
++ if (Date.now() - acrossCache.at > 5 * 60_000) {
++ const read = async (url) => {
++ try {
++ const r = await require('../net/egress').fetchText(url, { timeoutMs: 1300, maxBytes: 128 * 1024 });
++ return r.status === 200 ? JSON.parse(r.text) : null;
++ } catch { return null; }
++ };
++ const [frame, report] = await Promise.all([
++ read('https://openvibe.network/api/frame?host=openvibe.live'),
++ read('https://openvibe.report/api/v1/stats?days=7'),
++ ]);
++ acrossCache = { at: Date.now(), frame: Array.isArray(frame?.cards) ? frame.cards : [], report };
++ }
++ const picks = [];
++ for (const card of acrossCache.frame) {
++ if (!['new-pages', 'hot-threads', 'top-stories', 'new-worlds'].includes(card.id)) continue;
++ const site = { 'new-pages': 'Wiki', 'hot-threads': 'Community', 'top-stories': 'News', 'new-worlds': 'Games' }[card.id];
++ const icon = { Wiki: 'fa-book-open', Community: 'fa-comments', News: 'fa-newspaper', Games: 'fa-gamepad' }[site];
++ for (const item of (card.items || []).slice(0, 3)) {
++ const match = String(item.meta || '').match(/(?:^|· |\s)(\d+)\s*([mhd])\s*ago\b/i);
++ if (!match) continue; // Frame's refresh time is not an item's creation time.
++ const unit = { m: 60000, h: 3600000, d: 86400000 }[match[2].toLowerCase()];
++ if ((Number(match[1]) + 1) * unit > Date.now() - sinceMs) continue;
++ let url;
++ try { url = new URL(item.url); } catch { continue; }
++ if (url.protocol !== 'https:' || !/^openvibe\.(wiki|community|news|games)$/.test(url.hostname)) continue;
++ picks.push({ title: String(item.title || '').slice(0, 100), url: url.href, site, icon });
++ break;
++ }
++ }
++ const daily = acrossCache.report?.accounts?.daily;
++ if (Array.isArray(daily) && daily.length > 1) {
++ const today = daily[daily.length - 1], yesterday = daily[daily.length - 2];
++ if (Number(today.n) > 0 && sinceMs <= Date.parse(`${today.day}T00:00:00Z`)) {
++ const change = Number(today.n) - Number(yesterday.n || 0);
++ picks.push({ title: `${today.n} people joined today${change ? ` (${change > 0 ? '+' : ''}${change} vs yesterday)` : ''}`, url: 'https://openvibe.report/', site: 'Report', icon: 'fa-chart-line' });
++ }
++ }
++ return picks.slice(0, 3);
++}
++
router.get('/daily-quest', optionalAuth, async (req, res) => {
const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;
const data = await require('./daily-quest').readDailyQuest({ token, timeZone: String(req.query.time_zone || 'UTC') });
@@@ -522,10 +567,11 @@ router.get('/digest', optionalAuth, async (req, res) => {
const streamedRows = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,
COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers,
ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1)::float8 AS hours,
-- (SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title
++ (SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title,
++ (SELECT id FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_stream_id
FROM streams s JOIN users u ON u.id = s.user_id
WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0
-- GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql]) || [])
++ GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql, sinceSql]) || [])
.filter(r => !liveIds.has(r.user_id));
const streamed = [];
for (const r of streamedRows) {
@@@ -535,6 +581,13 @@ router.get('/digest', optionalAuth, async (req, res) => {
}
streamed.sort((a, b) => (b.followed - a.followed) || (b.hours - a.hours));
streamed.splice(8);
++ if (streamed.length) {
++ const vods = await require('../media-proxy/lookups').publicVodsForStreams(streamed.slice(0, 3).map((r) => r.last_stream_id), { budgetMs: 900 });
++ for (const r of streamed) {
++ const vod = vods.get(Number(r.last_stream_id));
++ r.watch_url = vod ? videoUrl('vod', vod.id, null, vod.watch_id) : null;
++ }
++ }
const one = async (sql, params) => { try { return await get(sql, params); } catch { return null; } };
const get = async (sql, params) => await db.get(sql, params);
const stats = {
@@@ -544,23 +597,50 @@ router.get('/digest', optionalAuth, async (req, res) => {
// rollback); a synchronous peek answers the last good count while Chat refreshes.
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
-- new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
-- takes: Number((await one("SELECT COUNT(*) AS n FROM rundown_segments WHERE status = 'live' AND said_at >= ?", [sinceSql]) || {}).n || 0),
++ new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),
++ takes: Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
++ ${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @since
++ AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),
};
-- // The best Rundown takes since then (server/rundown): the quote when there is one, else the headline. The front
-- // page leaves out a take with a slur, strong profanity or a hate topic (front-page-text.js) and shows the next.
-- let hot = [];
++ const previous = new Date(now - 86400000).toISOString().replace('T', ' ').slice(0, 19);
++ const prior = new Date(now - 2 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
++ const changeFor = async (table, column, condition = '1 = 1') => {
++ const rows = await db.all(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} >= ? AND ${column} < ? AND ${condition}`, [prior, previous]);
++ const current = await db.all(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} >= ? AND ${condition}`, [previous]);
++ return Number(current[0]?.n || 0) - Number(rows[0]?.n || 0);
++ };
++ const publicTakes = async (start, end) => Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
++ ${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @start AND s.said_at < @end
++ AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);
++ const changes = {
++ streams: await changeFor('streams', 'started_at'),
++ takes: await publicTakes(previous, new Date(now).toISOString().replace('T', ' ').slice(0, 19)) - await publicTakes(prior, previous),
++ new_follows: await changeFor('follows', 'created_at'),
++ new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL'),
++ };
++ const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);
++ // A take is shown only when the stream is in this visit's window, the topic is live, and Rundown allows everyone to see it.
++ let topics = [];
try {
-- const { frontPageOk } = require('./front-page-text');
-- hot = (await db.all(`SELECT s.title, s.quotes_json, s.said_at, t.title AS topic, t.slug, u.username, u.display_name FROM rundown_segments s
-- JOIN users u ON u.id = s.user_id LEFT JOIN rundown_topics t ON t.id = s.topic_id
-- WHERE s.status = 'live' AND s.said_at >= ? AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 ORDER BY s.score DESC, s.said_at DESC LIMIT 15`, [sinceSql]) || [])
-- .map((r) => { let q = null; try { q = JSON.parse(r.quotes_json || '[]')[0] || null; } catch { q = null; } return { text: q ? q.text : r.title, quoted: !!q, topic: r.topic || null, slug: r.slug || null, said_at: r.said_at, username: r.username, display_name: r.display_name }; })
-- .filter((h) => frontPageOk(h.text) && (!h.topic || frontPageOk(h.topic)))
-- .slice(0, 3);
-- } catch { hot = []; }
++ const { AUDIENCE_JOINS, VISIBLE } = require('../rundown/read');
++ const rows = await db.all(`SELECT s.id, s.stream_id, s.title, s.quotes_json, s.said_at, t.title AS topic, t.kind, u.username, u.display_name, u.avatar_url
++ FROM rundown_segments s JOIN streams stream ON stream.id = s.stream_id
++ JOIN users u ON u.id = s.user_id JOIN rundown_topics t ON t.id = s.topic_id
++ ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND stream.started_at >= @since
++ AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND ${VISIBLE('s')}
++ ORDER BY s.score DESC, s.said_at DESC LIMIT 40`, { since: sinceSql, viewer: null });
++ topics = selectTopics(rows);
++ } catch { topics = []; }
++ let nextStream = null;
++ if (!stats.streams && !stats.takes && !stats.new_members && !stats.new_follows && !stats.chat_lines) {
++ nextStream = await one(`SELECT a.title, a.starts_at, u.username, u.display_name FROM agent_stream_schedules a JOIN users u ON u.id = a.user_id
++ WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);
++ }
++ const across = await acrossOpenVibe(sinceMs);
++ const digest = { since, signed_in: !!uid, liveNow, streamed, stats, topics, newPeople, changes, nextStream, across };
res.set('Cache-Control', uid ? 'private, max-age=30' : 'public, max-age=60');
-- res.json({ since, signed_in: !!uid, liveNow, streamed, stats, hot });
++ res.json({ ...digest, html: renderDigest(digest) });
} catch (err) {
console.error('[Home] digest error:', err.message);
res.status(500).json({ error: 'Failed to load digest' });
ddiff --git a/test/home-digest-rundown.test.js b/test/home-digest-rundown.test.js
iindex 8a59c3e7..78299ae5 100644
---- a/test/home-digest-rundown.test.js
++++ b/test/home-digest-rundown.test.js
@@@ -2,8 +2,7 @@
/**
* The home digest ("Lately on OpenVibe") and the star pick read The Rundown, not the Arena's mic moments
* (server/home/routes.js, server/home/star-job.js):
-- * the digest counts the live takes since the window opened, and its hot lines are the best takes (score 4 and up):
-- * the quote when there is one, else the headline, linked by topic; never a hidden take or a banned streamer
++ * the digest counts live takes and shows public, topic-linked takes from streams in the window
* the star a candidate's takes over the last two weeks count toward the pick
*/
const assert = require('assert');
@@@ -44,12 +43,15 @@ const server = http.createServer(app);
await new Promise((res) => server.listen(0, '127.0.0.1', res));
try {
const d = await (await fetch(`http://127.0.0.1:${server.address().port}/api/home/digest`)).json();
-- assert.strictEqual(d.stats.takes, 4, 'every live take since the window opened (the hidden one is not)');
++ assert.strictEqual(d.stats.takes, 3, 'only visible takes by non-banned streamers are counted');
assert.ok(!('mic_moments' in d.stats), 'the Arena\'s mic moments are gone');
-- assert.deepStrictEqual(d.hot.map((h) => [h.text, h.quoted, h.slug, h.username]), [
-- ['autofill put me on support again chat', true, 'league-autofill', 'annstreams'],
-- ['Bob on his new keyboard', false, null, 'bobstreams'],
-- ], 'the best takes: the quote, else the headline; never hidden, a 3, or a banned streamer');
++ assert.deepStrictEqual(d.topics.map((h) => [h.text, h.topic, h.username]), [
++ ['autofill put me on support again chat', 'League autofill', 'annstreams'],
++ ], 'the digest shows topic-linked takes, never a random ungrouped quote or hidden take');
++ assert.ok(d.html.includes(`/rundown/e/${sa}#rd-take-${d.topics[0].id}`));
++ assert.ok(d.html.includes('What they talked about'));
++ assert.ok(d.html.includes('New here'));
++ assert.ok(!d.html.includes('Bob on his new keyboard'));
console.log('✅ the digest reads The Rundown');
} finally {
server.close();
ddiff --git a/test/home-front-page-text.test.js b/test/home-front-page-text.test.js
iindex 95822f81..e1230040 100644
---- a/test/home-front-page-text.test.js
++++ b/test/home-front-page-text.test.js
@@@ -1,15 +1,14 @@
'use strict';
/**
-- * The home page's "Lately on OpenVibe" quotes leave out a take with a slur, strong profanity or a hate topic, and show
-- * the next best one (server/home/front-page-text.js, the platform rule from openvibe-sdk/front-page); the Rundown keeps every
-- * take as said.
++ * The home digest's topic-linked takes leave out a take with a slur, strong profanity or a hate topic, and show
++ * the next best distinct topic (server/home/front-page-text.js, the platform rule from openvibe-sdk/front-page).
++ * The Rundown keeps every take as said.
*
* node test/home-front-page-text.test.js
*/
const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
const { frontPageOk } = require('../server/home/front-page-text');
++const { selectTopics } = require('../server/home/digest');
// The strip on 2026-10-24, and more of the same kind.
for (const t of [
@@@ -27,9 +26,20 @@ for (const t of [
]) assert.ok(frontPageOk(t), `fine for the front page: ${t}`);
console.log(' ✓ slurs, strong profanity and hate topics are left off; ordinary takes (and Shitake, Scunthorpe, grape) stay');
--const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'home', 'routes.js'), 'utf8');
--assert.ok(routes.includes("const { frontPageOk } = require('./front-page-text');"));
--assert.ok(routes.includes('.filter((h) => frontPageOk(h.text) && (!h.topic || frontPageOk(h.topic)))') && routes.includes('.slice(0, 3);'), 'the digest filters, then keeps three');
--assert.ok(/ORDER BY s\.score DESC, s\.said_at DESC LIMIT 15/.test(routes), 'from the best fifteen, so a filtered take has a replacement');
--console.log(' ✓ the home digest takes the three best takes that pass');
++const row = (id, topic, title, quote) => ({ id, stream_id: id, topic, title, username: 'annstreams',
++ quotes_json: quote ? JSON.stringify([{ text: quote }]) : '[]' });
++const selected = selectTopics([
++ row(1, 'League autofill', 'A safe title', 'that was some bullshit'),
++ row(2, 'he went full nazi on it', 'A safe title', 'This one sounds fine'),
++ row(3, null, 'An unlabelled take', 'This one sounds fine'),
++ row(4, 'League autofill', 'Autofill put me on support', 'We finally beat the boss on hard mode'),
++ row(5, 'League autofill', 'A duplicate topic', 'This one sounds fine'),
++ row(6, 'The tiny studio', 'The sound is right'),
++ row(7, 'Starters', 'Grape soda forever'),
++ row(8, 'Fourth topic', 'The fourth safe take'),
++]);
++assert.deepStrictEqual(selected.map((r) => r.id), [4, 6, 7], 'filter unsafe and unlabelled takes, keep the best three distinct topics');
++assert.strictEqual(selected[0].text, 'We finally beat the boss on hard mode', 'use the streamer’s quote when safe');
++assert.strictEqual(selected[1].text, 'The sound is right', 'fall back to the headline when there is no quote');
++console.log(' ✓ the home digest filters before taking three distinct topics');
console.log('home front-page text: all passed');