openvibers/openvibe.community Commits

Add the crawlable Community Feed page and media fields (#365)

OP OpenVibers committed 2 days ago · eba4127340 · 19 files, +177 −25

* Add server-rendered Community feed and media

* Use next Community migration number

* Add mobile feed filter overflow fix and screenshots

* Ingest public Video and Games events into Pulse

* Add cursor navigation to server-rendered Feed

* Include public Feed page in sitemap

* Merge remote-tracking branch 'origin/main' into feed-v2-f1-community

* Use next Community migration number

* Ingest public Meme entries into Pulse

Changes

ddiff --git a/docs/screenshots/feed-v2/1280-dark.png b/docs/screenshots/feed-v2/1280-dark.png
nnew file mode 100644
iindex 0000000..b4b6d42
BBinary files /dev/null and b/docs/screenshots/feed-v2/1280-dark.png differ
ddiff --git a/docs/screenshots/feed-v2/1280-light.png b/docs/screenshots/feed-v2/1280-light.png
nnew file mode 100644
iindex 0000000..b4b6d42
BBinary files /dev/null and b/docs/screenshots/feed-v2/1280-light.png differ
ddiff --git a/docs/screenshots/feed-v2/390-dark.png b/docs/screenshots/feed-v2/390-dark.png
nnew file mode 100644
iindex 0000000..92c2ad9
BBinary files /dev/null and b/docs/screenshots/feed-v2/390-dark.png differ
ddiff --git a/docs/screenshots/feed-v2/390-light.png b/docs/screenshots/feed-v2/390-light.png
nnew file mode 100644
iindex 0000000..8cf1bb2
BBinary files /dev/null and b/docs/screenshots/feed-v2/390-light.png differ
ddiff --git a/migrations/0032_feed_media.sql b/migrations/0032_feed_media.sql
nnew file mode 100644
iindex 0000000..5b32bf9
---- /dev/null
++++ b/migrations/0032_feed_media.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Feed cards can carry a thumbnail and a muted video preview (server/pulse/feed.js).
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS preview_url text COLLATE "C";
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS media_ratio double precision;
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS media_length double precision;
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 03e46cf..8f8a2f3 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -209,6 +209,9 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.space-link:hover { text-decoration: none; }
.sort-tabs { margin: 8px 0 14px; }
.sort-tabs .tab { text-decoration: none; }
++.feed-kind-tabs { max-width: 100%; overflow-x: auto; flex-wrap: nowrap; }
++body:has(.feed-kind-tabs) { overflow-x: clip; }
++html:has(.feed-kind-tabs) { overflow-x: clip; }
.thread-row, .pulse-item { display: flex; gap: 14px; align-items: flex-start; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; }
.thread-score { min-width: 2.6em; text-align: center; font-weight: 700; color: var(--text-secondary); padding-top: 2px; }
.thread-main, .pulse-main { min-width: 0; flex: 1; }
ddiff --git a/server/app.js b/server/app.js
iindex 1881b67..5b1fdfa 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -63,6 +63,7 @@ const { createSubmissionService } = require('./submissions/service');
const { createSubmissionsApi } = require('./submissions/api');
const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
++const { feedPage } = require('./render/feed');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createPasteService } = require('./pastes/service');
@@@ -796,6 +797,40 @@ async function createApp(opts = {}) {
html(res, pulsePage({ items: out.items, origin, after: req.query.after || null, nextCursor: out.next_cursor, origins: await pulse.origins() }));
}));
++ const feedTools = require('./pulse/feed');
++ const feedLiveNow = feedTools.createLiveNow({ liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });
++ app.get('/feed', viewers.middleware({ services: false }), wrap(async (req, res) => {
++ const kind = ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
++ const cursor = req.query.cursor ? String(req.query.cursor) : '';
++ const live = await feedLiveNow();
++ const items = [];
++ let after = cursor;
++ let nextCursor = null;
++ for (let pages = 0; pages < 8 && items.length < 12; pages++) {
++ const out = await pulse.list({ limit: 12, after: after || undefined });
++ const rows = out.items || [];
++ for (let index = 0; index < rows.length; index++) {
++ const item = rows[index];
++ const card = feedTools.card(item);
++ if (card.kind === 'live') {
++ card.live_now = live.has(feedTools.channelOf(card.url).toLowerCase());
++ if (!card.live_now) card.kind = 'stream';
++ }
++ if (!kind || card.kind === kind) items.push(card);
++ if (items.length >= 12) {
++ nextCursor = index < rows.length - 1
++ ? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
++ : (out.next_cursor || null);
++ break;
++ }
++ }
++ if (items.length >= 12 || !out.next_cursor) break;
++ after = out.next_cursor;
++ nextCursor = after;
++ }
++ html(res, feedPage({ items, kind, cursor: cursor || null, nextCursor }));
++ }));
++
app.get('/my', withUser, wrap(async (req, res) => {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
ddiff --git a/server/discovery.js b/server/discovery.js
iindex f81862c..3a5d071 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -92,6 +92,7 @@ async function buildSitemapRows() {
const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });
add('/', null, 'hourly', '1.0');
add('/pastes', null, 'hourly', '0.8');
++ add('/feed', null, 'hourly', '0.8');
add('/pastes?sort=views', null, 'daily', '0.5');
// No /p/:slug rows: a paste is link-shared content and is noindex (SEO-29), so it must not be
// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex 5ab2f1d..7a47df4 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -5,6 +5,8 @@
* becomes Pulse items (roadmap §29 / §33: provenance kept, AI never shown as a person):
*
* live.stream.started "<channel> went live: <title>" → the channel page, actor the streamer
++ * mediahub.video.published the public video upload
++ * games.world.published the creator's public world
* blog.post.published "New post on the <blog> blog" → the post (public, indexable only)
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
@@@ -37,7 +39,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'wiki.page.updated', 'chat.room.message.created', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);
++const TOPICS = Object.freeze(['live.stream.started', 'mediahub.video.published', 'games.world.published', 'blog.post.published', 'wiki.page.published', 'wiki.page.updated', 'chat.room.message.created', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);
const GAME_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
@@@ -61,16 +63,19 @@ function itemFor(event) {
const title = clean(p.title, 120);
return { source_service: 'live', source_type: 'stream', source_id: subjectId, title: title ? `${name} went live: ${title}` : `${name} went live`, url, actor_subject: subjectOf(ch.subject), origin: 'user', occurred_at: p.started_at || at };
}
-- const kinds = { 'blog.post.published': ['blog', 'post'], 'wiki.page.published': ['wiki', 'page'], 'wiki.page.updated': ['wiki', 'page'], 'news.story.published': ['news', 'story'] };
++ const kinds = { 'mediahub.video.published': ['media-hub', 'video'], 'games.world.published': ['games', 'world'], 'blog.post.published': ['blog', 'post'], 'wiki.page.published': ['wiki', 'page'], 'wiki.page.updated': ['wiki', 'page'], 'news.story.published': ['news', 'story'] };
const k = kinds[event.event_type];
if (!k) return 'ignored:type';
if (event.source !== k[0]) return 'ignored:source';
-- if (!publicIndexable(p)) return 'ignored:not_public';
-- const url = httpsUrl(p.canonical_url);
++ if (event.event_type === 'mediahub.video.published' || event.event_type === 'games.world.published') {
++ if (event.visibility !== 'public') return 'ignored:not_public';
++ } else if (!publicIndexable(p)) return 'ignored:not_public';
++ const url = httpsUrl(p.canonical_url || p.url);
if (!url || !subjectId) return 'ignored:payload';
// blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.
const blogName = clean(p.blog && typeof p.blog === 'object' ? p.blog.handle : p.blog, 60);
-- const title = k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog')
++ const title = k[0] === 'media-hub' || k[0] === 'games' ? clean(p.title, 160)
++ : k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog')
// A wiki page by its title when the event carries one (contracts 0.366.0), else by where it lives.
: k[0] === 'wiki' ? (clean(p.title, 160) || `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`)
: `News: ${clean(p.topic, 60) || 'a new story'}`;
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex ca287f5..68c9c11 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -60,7 +60,7 @@ function card(i) {
site,
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
-- media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ratio: 16 / 9 } : null,
++ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(Number(i.media_length) > 0 ? { length: Number(i.media_length) } : {}) } : null,
counts: {},
...(i.label ? { label: i.label } : {}),
};
@@@ -230,4 +230,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
}
--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf };
++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow };
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex ccb79b8..d7f6ce7 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -88,6 +88,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
...(i.picture_url ? { picture_url: i.picture_url } : {}),
++ ...(i.preview_url ? { preview_url: i.preview_url } : {}),
++ ...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
++ ...(i.media_length ? { media_length: i.media_length } : {}),
...(i.text ? { text: i.text } : {}),
actor,
occurred_at: isoTime(i.occurred_at),
@@@ -160,11 +163,21 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);
if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');
++ const media = body.media && typeof body.media === 'object' && !Array.isArray(body.media) ? body.media : {};
++ const mediaUrl = (v) => { try { const x = new URL(String(v)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
++ const thumb = mediaUrl(media.thumb);
++ const preview = media.preview == null ? null : mediaUrl(media.preview);
++ const ratio = media.ratio == null ? null : Number(media.ratio);
++ const length = media.length == null ? null : Number(media.length);
++ if (media.thumb != null && !thumb) fail(400, 'pulse.invalid_media', 'media.thumb must be an HTTPS Media URL');
++ if (media.preview != null && !preview) fail(400, 'pulse.invalid_media', 'media.preview must be an HTTPS URL');
++ if (ratio != null && (!Number.isFinite(ratio) || ratio <= 0 || ratio > 20)) fail(400, 'pulse.invalid_media', 'media.ratio must be between 0 and 20');
++ if (length != null && (!Number.isFinite(length) || length < 0 || length > 86400)) fail(400, 'pulse.invalid_media', 'media.length must be between 0 and 86400 seconds');
const out = await store.upsertItem(db, {
source_service: ref.service, source_type: ref.type, source_id: ref.id,
title, url: parsed.toString(),
actor_subject: origin === 'user' ? (v.subject || null) : null,
-- origin, occurred_at: sqlTime(when),
++ origin, occurred_at: sqlTime(when), picture_url: thumb, preview_url: preview, media_ratio: ratio, media_length: length,
});
const projections = await authors.projectionsFor([out.item.actor_subject]);
return { item: shape(out.item, projections), created: out.created };
ddiff --git a/server/pulse/sources.js b/server/pulse/sources.js
iindex 3734060..921a304 100644
---- a/server/pulse/sources.js
++++ b/server/pulse/sources.js
@@@ -8,6 +8,7 @@
* - OpenVibe.Today: GET /api/v1/events (public, cached there) → source today / event
* - OpenVibe.Network: GET /api/v1/stats (public, CORS-open) → one grouped member row per day, "5 people joined today"
* - OpenVibe.Quest: GET /api/v1/profiles/:subject/badges for people recently active in Pulse → "Finished a quest: …"
++ * - OpenVibe.Meme: GET /api/v1/entries (public) → newly published meme entries
*
* Due-ness is kept in the database (pulse_source_runs): a run records the next time it is due, so a restart does not
* skip a source and two processes do not both fetch at once (the second simply finds it not due). A minute timer only
@@@ -38,6 +39,7 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
const networkSite = String(config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
const questApi = String(config.questInternalUrl || config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
const questSite = String(config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
++ const memeApi = String(config.memeInternalUrl || config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '');
let timer = null;
let running = null;
@@@ -148,10 +150,37 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return n;
}
++ /** Meme's public entries → one Feed card each; the API excludes hidden and unlisted entries. */
++ async function memeEntries() {
++ let cursor = null, created = 0;
++ for (let page = 0; page < 4; page++) {
++ const url = new URL('/api/v1/entries', memeApi);
++ url.searchParams.set('limit', '50');
++ if (cursor) url.searchParams.set('cursor', cursor);
++ const out = await getJson(url.toString());
++ for (const e of (Array.isArray(out.entries) ? out.entries : [])) {
++ const slug = clean(e && e.slug, 100);
++ const title = clean(e && e.title, 300);
++ if (!slug || !/^[A-Za-z0-9-]+$/.test(slug) || !title) continue;
++ const url = `${String(config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '')}/m/${encodeURIComponent(slug)}`;
++ const r = await store.upsertItem(db, {
++ source_service: 'meme', source_type: 'meme', source_id: slug, title, url,
++ actor_subject: null, origin: 'system', occurred_at: sqlFrom(e.published_at || e.created_at, sqlTime(now())),
++ text: clean(e.summary || e.description, 280),
++ });
++ if (r.created) created++;
++ }
++ cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;
++ if (!cursor) break;
++ }
++ return created;
++ }
++
const SOURCES = [
{ name: 'today-events', every: 10 * MINUTE, run: todayEvents },
{ name: 'network-members', every: 60 * MINUTE, run: networkMembers },
{ name: 'quest-badges', every: 15 * MINUTE, run: questBadges },
++ { name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },
];
async function remember(name, { nextRun, lastRun, error = null }) {
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
iindex 9fb09f8..c1f3ea6 100644
---- a/server/pulse/store.js
++++ b/server/pulse/store.js
@@@ -85,16 +85,16 @@ async function upsertItem(db, it) {
// what the first record said. A source that sends no text (undefined) keeps the text already there.
if (it.text === undefined) it = { ...it, text: had.text };
if (it.touch) {
-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, app_name = ?, text = ?, occurred_at = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
-- .run(it.title, it.url, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), it.occurred_at, ...key);
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, occurred_at = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
++ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), it.occurred_at, ...key);
} else {
-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, app_name = ?, text = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
-- .run(it.title, it.url, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), ...key);
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
++ .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), ...key);
}
return { item: await find.get(...key), created: false };
}
-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, app_name, text)
-- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300));
++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300));
return { item: await find.get(...key), created: true };
});
}
ddiff --git a/server/render/feed.js b/server/render/feed.js
nnew file mode 100644
iindex 0000000..d36440e
---- /dev/null
++++ b/server/render/feed.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++const ld = require('./jsonld');
++const { renderPage } = require('./layout');
++const { escapeHtml: esc } = require('./highlight');
++const { card } = require('../pulse/feed');
++
++const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'Videos'], ['pic', 'Pics'], ['post', 'Posts'], ['meme', 'Memes'], ['game', 'Games'], ['update', 'Updates']];
++const href = (kind, cursor = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };
++
++function feedPage({ items = [], kind = '', cursor = null, nextCursor = null } = {}) {
++ const cards = items.map((item) => item && item.kind ? item : card(item));
++ const chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${href(id)}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');
++ const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Feed</span></nav><h1>Feed</h1><p class="muted">What's happening across OpenVibe.</p></header>
++<nav class="tabs sort-tabs feed-kind-tabs" aria-label="Feed type">${chips}</nav>
++<ol class="thread-list pulse-list">${cards.map((c) => `<li class="pulse-item"><span class="badge pulse-source">${esc(c.site_name)} · ${esc(c.kind)}</span><div class="pulse-main"><a class="thread-title" href="${esc(c.url)}">${esc(c.title)}</a>${c.text ? `<p>${esc(c.text)}</p>` : ''}${c.media ? `<img src="${esc(c.media.thumb)}" alt="" loading="lazy" decoding="async">` : ''}<p class="thread-meta">${c.actor ? `${esc(c.actor.name || '')} · ` : ''}<time datetime="${esc(c.at || '')}">${esc(c.at || '')}</time></p></div></li>`).join('')}</ol>
++${cursor || nextCursor ? `<nav class="pager" aria-label="Feed pages">${cursor ? `<a href="${esc(href(kind))}">Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(kind, nextCursor))}">Older</a>` : '<span></span>'}</nav>` : ''}`;
++ const itemsLd = cards.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'OpenVibe Feed', url: 'https://openvibe.community/feed', itemListElement: cards.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.title, url: c.url })) }] : [];
++ return renderPage({ title: `Feed${kind ? ` — ${KINDS.find(([id]) => id === kind)?.[1] || kind}` : ''}`, description: 'New clips, videos, pictures, posts, live streams and updates from across OpenVibe.', canonicalPath: href(kind), robots: cursor || !cards.length ? 'noindex,follow' : 'index,follow', active: 'pulse', jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Feed', url: '/feed' }]), ...itemsLd], body });
++}
++
++module.exports = { feedPage };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex 825b7b4..b2bc414 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -52,7 +52,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
const textPaste = (await t.app.locals.pastes.createText(asAlex, { title: 'Public one', content: 'The first line of a public paste.' })).paste;
const shot = (await t.app.locals.pastes.createScreenshot(asAlex, { title: 'A shot', content: 'A tiny cat on a screen' }, { buffer: PNG, originalname: 'cat.png' })).paste;
const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },
-- body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' }) });
++ body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1', media: { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 } }) });
assert.strictEqual(clip.status, 201, clip.text);
// One fixture for every remaining kind Pulse can carry (the other services' items are pushed, never read per request).
@@@ -92,6 +92,16 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(c.site, 'live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.actor.name, 'Alex');
++ assert.deepStrictEqual(c.media, { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 });
++ });
++
++ await check('GET /feed is server rendered, kind chips keep the page crawlable and Older carries a cursor', async () => {
++ const r = await t.get('/feed?kind=video');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('A video'), 'card text is in the response HTML');
++ assert.ok(r.text.includes('https://openvibe.video/v/1'), 'card link is in the response HTML');
++ assert.ok(r.text.includes('schema.org') && r.text.includes('ItemList'), 'ItemList JSON-LD is present');
++ assert.ok(r.text.includes('Older'), 'the page has a crawlable older-page link');
});
await check('a paste and a screenshot paste carry their text and first picture; the counts are Community\'s', async () => {
ddiff --git a/test/feed-page.test.js b/test/feed-page.test.js
nnew file mode 100644
iindex 0000000..f64ef8a
---- /dev/null
++++ b/test/feed-page.test.js
@@@ -0,0 +1,14 @@
++'use strict';
++
++const assert = require('assert');
++const { feedPage } = require('../server/render/feed');
++
++const page = feedPage({ kind: 'video', cursor: 'page-one', nextCursor: 'page-two', items: [{ source: { service: 'media-hub', type: 'video' }, id: 3, url: 'https://openvibe.video/watch/3', title: 'A video', text: 'Fresh upload', occurred_at: '2026-11-10 12:00:00', picture_url: 'https://openvibe.media/thumb.webp', actor: { display_name: 'Ada' } }] });
++assert.match(page, /href="\/feed\?kind=video" aria-current="page"/);
++assert.match(page, /A video/);
++assert.match(page, /Fresh upload/);
++assert.match(page, /https:\/\/openvibe\.video\/watch\/3/);
++assert.match(page, /"@type":"ItemList"/);
++assert.match(page, /https:\/\/openvibe\.media\/thumb\.webp/);
++assert.match(page, /href="\/feed\?kind=video&amp;cursor=page-two"/);
++console.log('feed page markup and ItemList JSON-LD passed');
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
iindex ab368b3..597d90a 100644
---- a/test/pulse-consumer.test.js
++++ b/test/pulse-consumer.test.js
@@@ -46,10 +46,18 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');
++ r = await post(evt({ event_type: 'mediahub.video.published', source: 'media-hub', subject: { type: 'video', id: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
++ payload: { upload: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: { type: 'user', id: USR }, title: 'A public video', url: 'https://openvibe.video/u/vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', published_at: new Date().toISOString() } }));
++ assert.strictEqual(r.json.outcome, 'pulse:created');
++ assert.deepStrictEqual([(await items())[2].source_service, (await items())[2].source_type, (await items())[2].title], ['media-hub', 'video', 'A public video']);
++ r = await post(evt({ event_type: 'games.world.published', source: 'games', subject: { type: 'world', id: 'w0123456789' },
++ payload: { world: 'w0123456789', owner: { type: 'user', id: USR }, title: 'Beacon Hill', url: 'https://openvibe.games/play?world=w0123456789', published_at: new Date().toISOString() } }));
++ assert.strictEqual(r.json.outcome, 'pulse:created');
++ assert.deepStrictEqual([(await items())[3].source_service, (await items())[3].source_type, (await items())[3].title], ['games', 'world', 'Beacon Hill']);
r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },
payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));
assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');
-- assert.strictEqual((await items()).length, 2);
++ assert.strictEqual((await items()).length, 4);
// VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.
const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },
payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });
@@@ -61,7 +69,7 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
r = await post(vipEv('live'));
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.deepStrictEqual(vipDrops, [v1.event_id]);
-- assert.strictEqual((await items()).length, 2, 'no Pulse item for a membership');
++ assert.strictEqual((await items()).length, 4, 'no Pulse item for a membership');
// Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },
@@@ -73,19 +81,19 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
const m11 = mod(11);
r = await post(m11);
assert.strictEqual(r.json.outcome, 'pulse:created');
-- assert.deepStrictEqual((await items())[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
++ assert.deepStrictEqual((await items())[4], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
assert.strictEqual((await post(m11)).json.duplicate, true);
r = await post(mod(6));
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
r = await post(mod(12));
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');
r = await post(mod(15));
-- assert.strictEqual((await items())[3].title, 'Reached level 15 in Scraplandia');
++ assert.strictEqual((await items())[5].title, 'Reached level 15 in Scraplandia');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');
assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');
-- assert.strictEqual((await items()).length, 4);
++ assert.strictEqual((await items()).length, 6);
// A dropped connection is a new Live session (a new subject id), but the stream is one broadcast.
const store = require('../server/pulse/store');
const T = Date.parse('2026-06-01T10:00:00.000Z');
@@@ -109,12 +117,12 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual((await shows(REPEAT)).length, 1, 'the older broadcast is untouched');
assert.strictEqual((await shows('Goosely went live: New show')).length, 1, 'a different title is a new item');
-- assert.strictEqual((await items()).length, 6);
++ assert.strictEqual((await items()).length, 8);
r = await post(liveAt(9004, T + 7 * 3_600_000, 'Repeat show'));
assert.strictEqual(r.json.outcome, 'pulse:created', 'more than six hours later is a new broadcast');
assert.strictEqual((await shows(REPEAT)).length, 2);
-- assert.strictEqual((await items()).length, 7);
++ assert.strictEqual((await items()).length, 9);
// The channel URL is the identity too, when the event names another acting subject for it.
r = await post(liveAt(9005, T + 10 * 60_000, 'URL show'));
@@@ -122,7 +130,7 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
r = await post(liveAt(9006, T + 25 * 60_000, 'URL show', { actor: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }));
assert.strictEqual(r.json.outcome, 'pulse:updated');
assert.strictEqual((await shows('Goosely went live: URL show')).length, 1);
-- assert.strictEqual((await items()).length, 8);
++ assert.strictEqual((await items()).length, 10);
// Duplicates already in the table: the feed read shows one line per channel+title per six hours,
// at the newest time, without deleting the rows.
ddiff --git a/test/pulse-sources.test.js b/test/pulse-sources.test.js
iindex 2cc03b7..72ac49a 100644
---- a/test/pulse-sources.test.js
++++ b/test/pulse-sources.test.js
@@@ -24,6 +24,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
{ id: 'tev_private', kind: 'event', title: 'Private', visibility: 'followers', status: 'scheduled', owner_subject: null },
] }) };
if (u.includes('/api/v1/stats')) return { ok: true, json: async () => ({ accounts: { total: 1234, daily: [{ day: today, n: 5 }, { day: yesterday, n: 3 }, { day: '2026-01-01', n: 0 }] } }) };
++ if (u.includes('/api/v1/entries')) return { ok: true, json: async () => ({ entries: [{ slug: 'open-source-meme', title: 'Open source meme', summary: 'A public entry', published_at: iso(Date.now() - 120_000) }], next_cursor: null }) };
if (u.includes('/badges')) return { ok: true, json: async () => ({ badges: [
{ id: 'first-post', name: 'First Post', awarded_at: badgeAt },
{ id: 'old-badge', name: 'Old Badge', awarded_at: iso(Date.now() - 90 * 86_400_000) },
@@@ -44,6 +45,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
assert.strictEqual(by['today-events'].items, 1, JSON.stringify(out));
assert.strictEqual(by['network-members'].items, 2);
assert.strictEqual(by['quest-badges'].items, 1);
++ assert.strictEqual(by['meme-entries'].items, 1);
const feed = (await t.get('/api/v1/feed?limit=40')).json();
const ev = feed.items.find((x) => x.kind === 'event');
assert.strictEqual(ev.title, 'Event: Launch party');
@@@ -55,6 +57,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const q = feed.items.find((x) => x.kind === 'quest');
assert.strictEqual(q.title, 'Finished a quest: First Post');
assert.strictEqual(q.actor.name, 'Alex');
++ const meme = feed.items.find((x) => x.kind === 'meme');
++ assert.strictEqual(meme.title, 'Open source meme');
++ assert.strictEqual(meme.text, 'A public entry');
++ assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');
assert.ok(!feed.items.some((x) => /Cancelled|Private|Old Badge/.test(x.title)), feed.items.map((x) => x.title).join(' | '));
});
@@@ -62,8 +68,8 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const quiet = await t.app.locals.pulseSources.runOnce();
assert.deepStrictEqual(quiet, []);
const status = await t.app.locals.pulseSources.status();
-- assert.deepStrictEqual(status.sources.sort(), ['network-members', 'quest-badges', 'today-events']);
-- assert.strictEqual(status.runs.length, 3);
++ assert.deepStrictEqual(status.sources.sort(), ['meme-entries', 'network-members', 'quest-badges', 'today-events']);
++ assert.strictEqual(status.runs.length, 4);
assert.ok(status.runs.every((r) => r.next_run_at && r.last_run_at && !r.last_error));
});
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 7855fa3..c68f1c5 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -108,6 +108,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(r.text.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
assert.ok(r.text.includes('<loc>https://openvibe.community/</loc>'));
assert.ok(r.text.includes('<loc>https://openvibe.community/pastes</loc>'));
++ assert.ok(r.text.includes('<loc>https://openvibe.community/feed</loc>'));
for (const slug of ['amber-fox-42', 'fair-moon-23', 'dark-owl-55', 'grim-vale-61']) {
assert.ok(!r.text.includes(`/p/${slug}`), `${slug} must not be in a sitemap`);
}