openvibers/openvibe.community Commits
Publish the Hot in the forum card as a frame.menu-cards array (#283)
Changes
@@@ -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 theLocker), `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 -->@@@ -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": [@@@ -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",@@@ -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"@@@ -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 }))@@@ -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 }));++ },};}@@@ -212,6 +212,17 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSORDER 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,nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -41,7 +41,7 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -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
- 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)
(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.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
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
## How it fits the network
@@ -863,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
@@ -863,7 +863,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.317.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.133.0
- openvibe-shared: v4.133.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"port": 4200,
"domain": "openvibe.community",
"domain": "openvibe.community",
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.317.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.133.0",
"shared": "openvibe-shared v4.133.0",
"capabilities": [
"capabilities": [
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1192,9 +1192,9 @@
@@ -1192,9 +1192,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -372,6 +372,14 @@ async function createApp(opts = {}) {
@@ -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 ────────────────────────────────────
// ── Machine endpoints ────────────────────────────────────
// Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
// 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 }))
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
@@ -1139,6 +1139,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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. */
/** 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)))); },
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 }));
+ },
};
};
}
}
@@ -212,6 +212,17 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
@@ -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);
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 }) {
async function setThreadFlags(db, id, { pinned, locked }) {
const sets = [], params = [];
const sets = [], params = [];
if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
@@ -351,7 +362,7 @@ async function recentPosts(db, threadId, limit = 50) {
@@ -351,7 +362,7 @@ async function recentPosts(db, threadId, limit = 50) {
module.exports = {
module.exports = {
SORTS, slugify,
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
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,
listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
lastPosts, listGroups, updateSpace, createSpace, authorStats, bumpViews,
listModerators, moderatesSpace, addModerator, removeModerator,
listModerators, moderatesSpace, addModerator, removeModerator,
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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); });