openvibers/openvibe.live Commits
Connect the home visit digest to the Rundown (#734)
Changes
@@@ -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%; }@@@ -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 = '';}nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };@@@ -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_idFROM streams s JOIN users u ON u.id = s.user_idWHERE 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' });@@@ -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();@@@ -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');
@@ -213,6 +213,71 @@
@@ -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 { 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; }
.digest-hot-line:hover b { text-decoration: underline; }
@media (max-width: 640px) { .digest-extra { white-space: normal; } }
@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 { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal .goal-bar { flex: 1; }
.streamer-group-goal .goal-bar { flex: 1; }
.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
@@ -1454,38 +1454,8 @@ async function loadHomeDigest() {
@@ -1454,38 +1454,8 @@ async function loadHomeDigest() {
let d;
let d;
try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }
try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }
catch { box.innerHTML = ''; box.style.display = 'none'; return; }
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 = '';
box.style.display = '';
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };
@@ -15,6 +15,8 @@ const db = require('../db/database');
@@ -15,6 +15,8 @@ const db = require('../db/database');
const { can } = require('../auth/permissions');
const { can } = require('../auth/permissions');
const media = require('../media-client');
const media = require('../media-client');
const { videoUrl } = require('../web/video-url');
const { videoUrl } = require('../web/video-url');
+const { render: renderDigest, selectTopics } = require('./digest');
+const rundownRead = require('../rundown/read');
const router = express.Router();
const router = express.Router();
const SLOGAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // must match slogan-job INTERVAL_MS
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?.
@@ -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
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
// users), the numbers, and the best Rundown takes.
// users), the numbers, and the best Rundown takes.
const { requireAuth, optionalAuth } = require('../auth/auth');
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) => {
router.get('/daily-quest', optionalAuth, async (req, res) => {
const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;
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') });
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) => {
@@ -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,
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,
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,
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
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
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));
.filter(r => !liveIds.has(r.user_id));
const streamed = [];
const streamed = [];
for (const r of streamedRows) {
for (const r of streamedRows) {
@@ -535,6 +581,13 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -535,6 +581,13 @@ router.get('/digest', optionalAuth, async (req, res) => {
}
}
streamed.sort((a, b) => (b.followed - a.followed) || (b.hours - a.hours));
streamed.sort((a, b) => (b.followed - a.followed) || (b.hours - a.hours));
streamed.splice(8);
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 one = async (sql, params) => { try { return await get(sql, params); } catch { return null; } };
const get = async (sql, params) => await db.get(sql, params);
const get = async (sql, params) => await db.get(sql, params);
const stats = {
const stats = {
@@ -544,23 +597,50 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -544,23 +597,50 @@ router.get('/digest', optionalAuth, async (req, res) => {
// rollback); a synchronous peek answers the last good count while Chat refreshes.
// 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; } })(),
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_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 {
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.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) {
} catch (err) {
console.error('[Home] digest error:', err.message);
console.error('[Home] digest error:', err.message);
res.status(500).json({ error: 'Failed to load digest' });
res.status(500).json({ error: 'Failed to load digest' });
@@ -2,8 +2,7 @@
@@ -2,8 +2,7 @@
/**
/**
* The home digest ("Lately on OpenVibe") and the star pick read The Rundown, not the Arena's mic moments
* 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):
* (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
* the star a candidate's takes over the last two weeks count toward the pick
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -44,12 +43,15 @@ const server = http.createServer(app);
@@ -44,12 +43,15 @@ const server = http.createServer(app);
await new Promise((res) => server.listen(0, '127.0.0.1', res));
await new Promise((res) => server.listen(0, '127.0.0.1', res));
try {
try {
const d = await (await fetch(`http://127.0.0.1:${server.address().port}/api/home/digest`)).json();
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.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');
console.log('✅ the digest reads The Rundown');
} finally {
} finally {
server.close();
server.close();
@@ -1,15 +1,14 @@
@@ -1,15 +1,14 @@
'use strict';
'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
* node test/home-front-page-text.test.js
*/
*/
const assert = require('assert');
const assert = require('assert');
-const fs = require('fs');
-const path = require('path');
const { frontPageOk } = require('../server/home/front-page-text');
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.
// The strip on 2026-10-24, and more of the same kind.
for (const t of [
for (const t of [
@@ -27,9 +26,20 @@ for (const t of [
@@ -27,9 +26,20 @@ for (const t of [
]) assert.ok(frontPageOk(t), `fine for the front page: ${t}`);
]) 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');
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');
console.log('home front-page text: all passed');