openvibers/openvibe.community Commits

Menu: newest forum threads when nothing is hot (#291)

OP OpenVibers committed 6 days ago · f9c09784eb · 5 files, +148 −39

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 2245d8e..94afdae 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -372,11 +372,15 @@ async function createApp(opts = {}) {
},
}));
-- // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, from the
-- // forum's own public query (server/forum/store.js hotThisWeek) so gated and members-only spaces stay out.
++ // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, else its newest
++ // public threads. Both come from the forum's own public queries (server/forum/store.js) so gated and
++ // members-only spaces, deleted threads and 18+ threads stay out.
{
const { createMenuCard } = require('./menu-card');
-- const menuCard = createMenuCard({ load: async () => await forum.hotThisWeek(6) });
++ const menuCard = createMenuCard({
++ load: async () => await forum.hotThisWeek(6),
++ loadNewest: async () => await forum.newestPublic(12),
++ });
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
}
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 5165c89..38e603c 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -1140,6 +1140,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },
++ /** The newest threads in public, open spaces, with author names, for the network menu card's fallback. */
++ async newestPublic(limit = 12) {
++ const rows = await store.recentThreads(db, { visibilities: ['public'], limit });
++ const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));
++ return rows.map((r) => ({ ...r, author: authors.author(r.author_subject, r.origin, projections) }));
++ },
++
/** The six threads with the most live replies in the last seven days, for the network menu card. */
async hotThisWeek(limit = 6) {
const since = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 77f2272..5566c2f 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -214,12 +214,12 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
/** The threads with the most live replies since `since` (YYYY-MM-DD HH:MM:SS, UTC), public forum only. */
async function hotThisWeek(db, { since, limit = 6 } = {}) {
-- return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_at
++ return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, t.content_labels, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_at
FROM threads t JOIN spaces s ON s.id = t.space_id
JOIN posts p ON p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL AND p.created_at >= ?
WHERE t.deleted_at IS NULL AND t.members_only_owner IS NULL
AND s.visibility = 'public' AND s.members_only_owner IS NULL
-- GROUP BY t.id, t.slug, t.title, s.slug
++ GROUP BY t.id, t.slug, t.title, s.slug, t.content_labels
ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
}
ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex d9a9b5f..226d368 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -1,17 +1,23 @@
'use strict';
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
//
--// Community publishes "Hot in the forum": the six threads with the most live replies in the last seven days,
--// each linking to its thread under /forum/<space>/<thread>. The rows come from the forum's own query over
--// public spaces (server/forum/store.js hotThisWeek), so members-only and staff spaces, gated threads and
--// deleted posts never reach the card. With no replies this week the card is still served, with no items, and
--// Network skips it. The file is CORS *, cached for five minutes; a failed database keeps the last good card.
++// Community publishes "Hot in the forum": the threads with the most live replies in the last seven days, each
++// linking to its thread under /forum/<space>/<thread>. When nothing is hot, the card shows the newest threads in
++// public spaces and says so: "New in the forum". The rows come from the forum's own queries over public spaces
++// (server/forum/store.js hotThisWeek and recentThreads), so members-only and staff spaces, gated threads and
++// deleted posts never reach the card; a thread marked 18+ is left off, because the menu is on every site.
++// With no public threads at all the card is still served, with no items, and Network skips it. The file is
++// CORS *, cached for five minutes; a failed database keeps the last good card.
++const labels = require('./forum/labels');
++
const BASE = 'https://openvibe.community';
const CARD_ID = 'hot-threads';
const SITE = 'community';
const CARD_TTL_S = 300;
const CACHE_MS = CARD_TTL_S * 1000;
--const LIMIT = 6;
++const LIMIT = 5;
++const HOT_TITLE = 'Hot in the forum';
++const NEW_TITLE = 'New in the forum';
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
function plain(value, max) {
@@@ -26,22 +32,53 @@ function plain(value, max) {
/** The thread's address, the same /forum/<space>/<thread> the forum renders. */
const threadPath = (space, thread) => `/forum/${encodeURIComponent(String(space))}/${encodeURIComponent(String(thread))}`;
--/** The card itself. `rows` are forum.hotThisWeek() rows: { title, slug, space_slug, replies }. */
--function cardFrom(rows, at = Date.now()) {
++/** "just now", "12m ago", "3h ago" or "2d ago" for a thread's own time; empty when unknown. */
++function ago(value, now) {
++ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
++ if (!Number.isFinite(at)) return '';
++ const mins = Math.max(0, Math.floor((now - at) / 60_000));
++ if (mins < 1) return 'just now';
++ if (mins < 60) return `${mins}m ago`;
++ const hours = Math.floor(mins / 60);
++ if (hours < 24) return `${hours}h ago`;
++ return `${Math.floor(hours / 24)}d ago`;
++}
++
++/** A thread the menu may show: public, not deleted, not members-only, not marked 18+. */
++function showable(row) {
++ if (!row || !row.space_slug || !row.slug) return false;
++ if (row.deleted_at || row.members_only_owner) return false;
++ if (row.visibility && row.visibility !== 'public') return false;
++ return !labels.labelsOf(row).labels.includes('18+');
++}
++
++/**
++ * The card itself. `rows` are forum.hotThisWeek() rows ({ title, slug, space_slug, replies, last_at }) or
++ * forum.newestPublic() rows when nothing is hot ({ title, slug, space_slug, author, created_at }). `newest`
++ * says which list it is, so the title and the meta line match what the reader is looking at.
++ */
++function cardFrom(rows, at = Date.now(), { newest = false } = {}) {
const items = [];
for (const row of Array.isArray(rows) ? rows : []) {
-- if (!row || !row.space_slug || !row.slug) continue;
++ if (!showable(row)) continue;
const title = plain(row.title, 80);
if (!title) continue;
-- const n = Number(row.replies) || 0;
-- if (n < 1) continue;
-- items.push({ title, meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, url: `${BASE}${threadPath(row.space_slug, row.slug)}` });
++ let meta;
++ if (newest) {
++ const who = row.author ? plain(row.author.display_name || row.author.username, 60) : '';
++ meta = [who || plain(row.space_slug, 60), ago(row.created_at, at)].filter(Boolean).join(' · ');
++ } else {
++ const n = Number(row.replies) || 0;
++ if (n < 1) continue;
++ meta = [plain(row.space_slug, 60), `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, ago(row.last_at, at)].filter(Boolean).join(' · ');
++ }
++ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${threadPath(row.space_slug, row.slug)}` });
if (items.length >= LIMIT) break;
}
return {
id: CARD_ID,
site: SITE,
-- title: 'Hot in the forum',
++ title: newest ? NEW_TITLE : HOT_TITLE,
kind: 'rows',
items,
more: { label: 'Visit the forum', url: `${BASE}/forum` },
@@@ -50,8 +87,11 @@ function cardFrom(rows, at = Date.now()) {
};
}
--/** The route. `load` returns forum.hotThisWeek() rows; `now` is injectable for the test. */
--function createMenuCard({ load, now = Date.now } = {}) {
++/**
++ * The route. `load()` returns forum.hotThisWeek() rows; when it has nothing to show, `loadNewest()` returns
++ * forum.newestPublic() rows. `now` is injectable for the test.
++ */
++function createMenuCard({ load, loadNewest = null, now = Date.now } = {}) {
let cached = null;
let cachedAt = 0;
let pending = null;
@@@ -62,7 +102,8 @@ function createMenuCard({ load, now = Date.now } = {}) {
if (pending) return pending;
pending = (async () => {
try {
-- cached = cardFrom(await load(), at);
++ const hot = cardFrom(await load(), at);
++ cached = hot.items.length || !loadNewest ? hot : cardFrom(await loadNewest(), at, { newest: true });
cachedAt = at;
} catch {
// Keep the last good card rather than publish an empty one on a database hiccup.
@@@ -84,4 +125,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
--module.exports = { createMenuCard, cardFrom, threadPath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
++module.exports = { createMenuCard, cardFrom, showable, threadPath, plain, ago, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT, HOT_TITLE, NEW_TITLE };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
iindex 1c17554..bf78bad 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -1,19 +1,27 @@
'use strict';
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json
--// serves "Hot in the forum" — the threads with the most live replies this week, each linking to its /forum page.
--// The array validates against frame.menu-cards@1, carries only public forum threads, is CORS * and cached for
--// five minutes. The SQL is exercised on a real (PGlite) database too: old, deleted, gated and members-only
--// replies never count.
++// serves "Hot in the forum" — the threads with the most live replies this week — and falls back to "New in the
++// forum", the newest public threads, when nothing is hot. Each links to its /forum page. The array validates
++// against frame.menu-cards@1, carries only public threads, never 18+ ones, is CORS * and cached for five minutes.
++// The SQL is exercised on a real (PGlite) database too: old, deleted, gated, members-only and 18+ rows never show.
const assert = require('assert');
const contracts = require('openvibe-contracts');
const { createMenuCard, cardFrom, threadPath, CARD_TTL_S } = require('../server/menu-card');
const { testDb } = require('./helpers/db');
--const { hotThisWeek } = require('../server/forum/store');
++const { hotThisWeek, recentThreads } = require('../server/forum/store');
const NOW = Date.parse('2026-11-07T12:00:00Z');
--const row = (over = {}) => ({ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12, ...over });
++const row = (over = {}) => ({
++ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12,
++ last_at: '2026-11-07T10:00:00.000Z', ...over,
++});
++const fresh = (over = {}) => ({
++ title: 'A new thread', slug: 'a-new-thread', space_slug: 'general', replies: 0,
++ created_at: '2026-11-07T10:00:00.000Z', author: { display_name: 'Ada' },
++ content_labels: { labels: [], suggested: [] }, ...over,
++});
--// ── The card's shape and words ───────────────────────────────────────────
++// ── Hot card: shape, words and contract ─────────────────────────────────
const card = cardFrom([row(), row({ title: '<b>Rye</b>\nbread', slug: 'rye-bread', space_slug: 'cooking', replies: 1 })], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
assert.ok(checked.valid, JSON.stringify(checked.errors));
@@@ -24,35 +32,74 @@ assert.strictEqual(card.title, 'Hot in the forum');
assert.strictEqual(card.kind, 'rows');
assert.strictEqual(card.ttl_s, CARD_TTL_S);
assert.deepStrictEqual(card.items, [
-- { title: 'A busy thread', meta: '12 replies', url: 'https://openvibe.community/forum/general/a-busy-thread' },
-- { title: 'Rye bread', meta: '1 reply', url: 'https://openvibe.community/forum/cooking/rye-bread' },
++ { title: 'A busy thread', meta: 'general · 12 replies · 2h ago', url: 'https://openvibe.community/forum/general/a-busy-thread' },
++ { title: 'Rye bread', meta: 'cooking · 1 reply · 2h ago', url: 'https://openvibe.community/forum/cooking/rye-bread' },
]);
assert.deepStrictEqual(card.more, { label: 'Visit the forum', url: 'https://openvibe.community/forum' });
assert.ok(card.title.length <= 60);
for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.community/forum/'));
--// Rows with no replies, no address or no title never enter the card, and at most six threads are sent.
++// ── The fallback: newest public threads, with the author and time ───────
++const newest = cardFrom([
++ fresh(),
++ fresh({ title: 'No name given', slug: 'no-name', space_slug: 'help', author: null }),
++ fresh({ title: 'An adult thread', slug: 'adult', content_labels: { labels: ['18+'], suggested: [] } }),
++], NOW, { newest: true });
++assert.strictEqual(newest.title, 'New in the forum');
++assert.deepStrictEqual(newest.items, [
++ { title: 'A new thread', meta: 'Ada · 2h ago', url: 'https://openvibe.community/forum/general/a-new-thread' },
++ { title: 'No name given', meta: 'help · 2h ago', url: 'https://openvibe.community/forum/help/no-name' },
++]);
++assert.ok(contracts.validate('frame.menu-cards@1', [newest]).valid);
++
++// Rows with no address, no title, no replies (hot only), private/deleted or 18+ never enter the card, and at most
++// five threads are sent.
assert.deepStrictEqual(cardFrom([row({ replies: 0 })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ space_slug: null })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ title: ' ' })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([row({ deleted_at: '2026-11-07T11:00:00Z' })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([row({ members_only_owner: 'usr_vip' })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([row({ visibility: 'members' })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([row({ content_labels: { labels: ['18+'], suggested: [] } })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([fresh({ deleted_at: '2026-11-07T11:00:00Z' })], NOW, { newest: true }).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
const many = Array.from({ length: 9 }, (_, i) => row({ slug: `t${i}`, title: `Thread ${i}` }));
--assert.strictEqual(cardFrom(many, NOW).items.length, 6);
++assert.strictEqual(cardFrom(many, NOW).items.length, 5);
assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
--// ── The five-minute cache and the route's headers ───────────────────────
++// ── The five-minute cache, the fallback switch and the route's headers ──
(async () => {
let t = NOW;
let loads = 0;
-- const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });
++ let newestLoads = 0;
++ const menu = createMenuCard({
++ load: async () => { loads++; return [row()]; },
++ loadNewest: async () => { newestLoads++; return [fresh()]; },
++ now: () => t,
++ });
const first = await menu.card();
assert.strictEqual(loads, 1);
++ assert.strictEqual(newestLoads, 0, 'the newest list is not read while something is hot');
assert.strictEqual(await menu.card(), first);
assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
t += CARD_TTL_S * 1000 + 1;
await menu.card();
assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
++ // Nothing hot: the card is the newest public threads, titled "New in the forum".
++ let hot = [];
++ const quiet = createMenuCard({ load: async () => hot, loadNewest: async () => [fresh()], now: () => t });
++ const fallback = await quiet.card();
++ assert.strictEqual(fallback.title, 'New in the forum');
++ assert.strictEqual(fallback.items[0].title, 'A new thread');
++ assert.strictEqual(fallback.items[0].meta, 'Ada · 2h ago');
++ // An empty site stays empty (Network skips the card), still with the fallback's honest title.
++ const gone = createMenuCard({ load: async () => [], loadNewest: async () => [], now: () => t });
++ const emptySite = await gone.card();
++ assert.deepStrictEqual(emptySite.items, []);
++ assert.strictEqual(emptySite.title, 'New in the forum');
++ assert.ok(contracts.validate('frame.menu-cards@1', [emptySite]).valid);
++
const headers = {};
let body = null;
const res = {
@@@ -74,7 +121,7 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
t += CARD_TTL_S * 1000 + 1;
assert.strictEqual(await flaky.card(), good);
-- // ── The query: this week's live replies in public, non-gated threads only ──
++ // ── The queries: public, non-gated, non-deleted threads only ──
const db = await testDb();
try {
await db.prepare(`INSERT INTO spaces (id, slug, name, visibility) OVERRIDING SYSTEM VALUE VALUES
@@@ -84,21 +131,31 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
await db.prepare(`INSERT INTO threads (id, space_id, slug, title, members_only_owner) OVERRIDING SYSTEM VALUE VALUES
(9001, 9001, 'busy', 'Busy thread', NULL), (9002, 9001, 'warm', 'Warm thread', NULL),
(9003, 9002, 'members', 'Members thread', NULL), (9004, 9004, 'vip-space', 'VIP space thread', NULL),
-- (9005, 9001, 'gated', 'Gated thread', 'usr_vip')`).run();
++ (9005, 9001, 'gated', 'Gated thread', 'usr_vip'), (9006, 9001, 'deleted', 'Deleted thread', NULL)`).run();
++ await db.prepare("UPDATE threads SET deleted_at = '2026-11-07 11:00:00' WHERE id = 9006").run();
await db.prepare(`INSERT INTO posts (id, thread_id, body_markdown, is_opening, created_at) OVERRIDING SYSTEM VALUE VALUES
(9001, 9001, 'opening', 1, '2026-11-06 10:00:00'), (9002, 9001, 'one', 0, '2026-11-06 10:05:00'), (9003, 9001, 'two', 0, '2026-11-06 10:06:00'),
(9004, 9002, 'opening', 1, '2026-11-06 10:00:00'), (9005, 9002, 'one', 0, '2026-11-06 10:05:00'),
(9006, 9003, 'one', 0, '2026-11-06 10:05:00'), (9007, 9004, 'one', 0, '2026-11-06 10:05:00'), (9008, 9005, 'one', 0, '2026-11-06 10:05:00'),
(9009, 9001, 'old', 0, '2026-10-01 10:00:00'), (9010, 9001, 'deleted', 0, '2026-11-06 10:07:00')`).run();
await db.prepare("UPDATE posts SET deleted_at = '2026-11-06 11:00:00' WHERE id = 9010").run();
++ // A thread can be marked 18+ after it was opened; the menu may not carry it even when it is busy.
++ await db.prepare(`UPDATE threads SET content_labels = '{"labels":["18+"],"suggested":[]}'::jsonb WHERE id = 9001`).run();
const rows = await hotThisWeek(db, { since: '2026-11-01 00:00:00', limit: 6 });
assert.deepStrictEqual(rows.map((r) => [r.slug, Number(r.replies)]), [['busy', 2], ['warm', 1]]);
const served = cardFrom(rows, NOW);
assert.ok(contracts.validate('frame.menu-cards@1', [served]).valid);
-- assert.deepStrictEqual(served.items.map((i) => i.meta), ['2 replies', '1 reply']);
++ assert.deepStrictEqual(served.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the card');
++ assert.match(served.items[0].meta, /^menu-general · 1 reply · /);
++
++ const newestRows = await recentThreads(db, { visibilities: ['public'], limit: 12 });
++ assert.deepStrictEqual(newestRows.map((r) => r.slug).sort(), ['busy', 'warm'],
++ 'members-only spaces and threads, and deleted threads, never reach the newest list');
++ const newestCard = cardFrom(newestRows, NOW, { newest: true });
++ assert.deepStrictEqual(newestCard.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the fallback too');
} finally {
await db.close();
}
-- console.log('menu card: shape, contract, hot replies query, plain text, 5 min cache, CORS and last-good card');
++ console.log('menu card: hot/newest fallback, contract, 18+ and public queries, plain text, 5 min cache, CORS and last-good card');
})().catch((err) => { console.error(err); process.exit(1); });