openvibers/openvibe.community Commits

Publish the Hot in the forum card as a frame.menu-cards array (#283)

OP OpenVibers committed 6 days ago · 6ce7592e0f · 9 files, +225 −9

* Publish the Hot in the forum card as a frame.menu-cards array

* Merge main into the menu card branch

Changes

ddiff --git a/README.md b/README.md
iindex b8dbbae..c33e08f 100644
---- a/README.md
++++ b/README.md
@@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
--- `openvibe-contracts` v0.312.0, `openvibe-sdk` v0.89.0 (events outbox, per-actor limits, no-looks, share and the
++- `openvibe-contracts` v0.317.0, `openvibe-sdk` v0.89.0 (events outbox, per-actor limits, no-looks, share and the
Locker), `openvibe-shared` v4.133.0 (the Share sheet's Locked part and `ov-locker.js`), pinned by release tarball
## How it fits the network
@@@ -863,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
--- openvibe-contracts: v0.312.0
++- openvibe-contracts: v0.317.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.133.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 674a34a..f264dab 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"domain": "openvibe.community",
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.317.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.133.0",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex e909ed1..3d100ec 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0"
@@@ -1192,9 +1192,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.317.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
++ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b4dee94..779969f 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex 60b0214..4550167 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -372,6 +372,14 @@ 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.
++ {
++ const { createMenuCard } = require('./menu-card');
++ const menuCard = createMenuCard({ load: async () => await forum.hotThisWeek(6) });
++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
++ }
++
// ── Machine endpoints ────────────────────────────────────
// Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 8f450c3..5165c89 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -1139,6 +1139,12 @@ 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 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);
++ return (await store.hotThisWeek(db, { since, limit })).map((r) => ({ ...r, replies: Number(r.replies) || 0 }));
++ },
};
}
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex b973d9a..77f2272 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -212,6 +212,17 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
}
++/** 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
++ 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
++ ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
++}
++
async function setThreadFlags(db, id, { pinned, locked }) {
const sets = [], params = [];
if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
@@@ -351,7 +362,7 @@ async function recentPosts(db, threadId, limit = 50) {
module.exports = {
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
-- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setThreadLabels, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
++ getThread, getThreadBySlug, createThread, listThreads, recentThreads, hotThisWeek, setThreadFlags, setThreadMembersOnly, setThreadLabels, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
listModerators, moderatesSpace, addModerator, removeModerator,
ddiff --git a/server/menu-card.js b/server/menu-card.js
nnew file mode 100644
iindex 0000000..d9a9b5f
---- /dev/null
++++ b/server/menu-card.js
@@@ -0,0 +1,87 @@
++'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.
++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;
++
++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
++function plain(value, max) {
++ return String(value == null ? '' : value)
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, 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()) {
++ const items = [];
++ for (const row of Array.isArray(rows) ? rows : []) {
++ if (!row || !row.space_slug || !row.slug) 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)}` });
++ if (items.length >= LIMIT) break;
++ }
++ return {
++ id: CARD_ID,
++ site: SITE,
++ title: 'Hot in the forum',
++ kind: 'rows',
++ items,
++ more: { label: 'Visit the forum', url: `${BASE}/forum` },
++ updated_at: new Date(at).toISOString(),
++ ttl_s: CARD_TTL_S,
++ };
++}
++
++/** The route. `load` returns forum.hotThisWeek() rows; `now` is injectable for the test. */
++function createMenuCard({ load, now = Date.now } = {}) {
++ let cached = null;
++ let cachedAt = 0;
++ let pending = null;
++
++ async function card() {
++ const at = now();
++ if (cached && at - cachedAt < CACHE_MS) return cached;
++ if (pending) return pending;
++ pending = (async () => {
++ try {
++ cached = cardFrom(await load(), at);
++ cachedAt = at;
++ } catch {
++ // Keep the last good card rather than publish an empty one on a database hiccup.
++ if (!cached) { cached = cardFrom([], at); cachedAt = at; }
++ }
++ return cached;
++ })().finally(() => { pending = null; });
++ return pending;
++ }
++
++ async function handler(_req, res) {
++ const body = await card();
++ res.set('Cache-Control', 'public, max-age=60')
++ .set('Access-Control-Allow-Origin', '*')
++ .set('Cross-Origin-Resource-Policy', 'cross-origin')
++ .json([body]);
++ }
++
++ return { card, handler, cardFrom, cacheMs: CACHE_MS };
++}
++
++module.exports = { createMenuCard, cardFrom, threadPath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
nnew file mode 100644
iindex 0000000..1c17554
---- /dev/null
++++ b/test/menu-card.test.js
@@@ -0,0 +1,104 @@
++'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.
++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 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 });
++
++// ── The card's shape and words ───────────────────────────────────────────
++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));
++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
++assert.strictEqual(card.id, 'hot-threads');
++assert.strictEqual(card.site, 'community');
++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' },
++]);
++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.
++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(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(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
++
++// ── The five-minute cache and the route's headers ───────────────────────
++(async () => {
++ let t = NOW;
++ let loads = 0;
++ const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });
++ const first = await menu.card();
++ assert.strictEqual(loads, 1);
++ 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');
++
++ const headers = {};
++ let body = null;
++ const res = {
++ set(k, v) { headers[k] = v; return this; },
++ json(v) { body = v; return this; },
++ };
++ await menu.handler({}, res);
++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
++ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');
++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');
++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
++ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);
++
++ // A failing database keeps the last good card rather than publishing an empty list.
++ let fail = false;
++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [row()]; }, now: () => t });
++ const good = await flaky.card();
++ fail = true;
++ 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 ──
++ const db = await testDb();
++ try {
++ await db.prepare(`INSERT INTO spaces (id, slug, name, visibility) OVERRIDING SYSTEM VALUE VALUES
++ (9001, 'menu-general', 'General', 'public'), (9002, 'menu-insiders', 'Insiders', 'members'),
++ (9003, 'menu-mod', 'Mods', 'staff'), (9004, 'menu-vip', 'VIP', 'public')`).run();
++ await db.prepare("UPDATE spaces SET members_only_owner = 'usr_vip' WHERE id = 9004").run();
++ 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();
++ 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();
++ 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']);
++ } finally {
++ await db.close();
++ }
++
++ console.log('menu card: shape, contract, hot replies query, plain text, 5 min cache, CORS and last-good card');
++})().catch((err) => { console.error(err); process.exit(1); });