openvibers/openvibe.community Commits
Add the crawlable Community Feed page and media fields (#365)
Changes
nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/feed-v2/1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/feed-v2/1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/feed-v2/390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/feed-v2/390-light.png differnnew file mode 100644@@@ -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;@@@ -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; }@@@ -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;@@@ -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.@@@ -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'}`;@@@ -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://openvreturn router;}--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf };++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow };@@@ -88,6 +88,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.flabel: 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.fif (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwiseconst 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 };@@@ -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, nowconst 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, nowreturn 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 }) {@@@ -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 };});}nnew file mode 100644@@@ -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 };@@@ -52,7 +52,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reconst 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().reassert.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 () => {nnew file mode 100644@@@ -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&cursor=page-two"/);++console.log('feed page markup and ItemList JSON-LD passed');@@@ -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.@@@ -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));});@@@ -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`);}
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/feed-v2/1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/feed-v2/1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/feed-v2/1280-light.png differ
Binary files /dev/null and b/docs/screenshots/feed-v2/1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/feed-v2/390-dark.png differ
Binary files /dev/null and b/docs/screenshots/feed-v2/390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/feed-v2/390-light.png differ
Binary files /dev/null and b/docs/screenshots/feed-v2/390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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;
@@ -209,6 +209,9 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -209,6 +209,9 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.space-link:hover { text-decoration: none; }
.space-link:hover { text-decoration: none; }
.sort-tabs { margin: 8px 0 14px; }
.sort-tabs { margin: 8px 0 14px; }
.sort-tabs .tab { text-decoration: none; }
.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-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-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; }
.thread-main, .pulse-main { min-width: 0; flex: 1; }
@@ -63,6 +63,7 @@ const { createSubmissionService } = require('./submissions/service');
@@ -63,6 +63,7 @@ const { createSubmissionService } = require('./submissions/service');
const { createSubmissionsApi } = require('./submissions/api');
const { createSubmissionsApi } = require('./submissions/api');
const { createSubmissionPages } = require('./submissions/routes');
const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
+const { feedPage } = require('./render/feed');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createPasteService } = require('./pastes/service');
const { createPasteService } = require('./pastes/service');
@@ -796,6 +797,40 @@ async function createApp(opts = {}) {
@@ -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() }));
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) => {
app.get('/my', withUser, wrap(async (req, res) => {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
let pastes = [], total = 0, error = null;
@@ -92,6 +92,7 @@ async function buildSitemapRows() {
@@ -92,6 +92,7 @@ async function buildSitemapRows() {
const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });
const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });
add('/', null, 'hourly', '1.0');
add('/', null, 'hourly', '1.0');
add('/pastes', null, 'hourly', '0.8');
add('/pastes', null, 'hourly', '0.8');
+ add('/feed', null, 'hourly', '0.8');
add('/pastes?sort=views', null, 'daily', '0.5');
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
// 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.
// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
* becomes Pulse items (roadmap §29 / §33: provenance kept, AI never shown as a person):
* 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
* 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)
* 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)
* wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
* news.story.published "News: <topic>" → the story (public, indexable only)
@@ -37,7 +39,7 @@ const store = require('./store');
@@ -37,7 +39,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
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_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
const GAME_URL = 'https://openvibe.games/';
@@ -61,16 +63,19 @@ function itemFor(event) {
@@ -61,16 +63,19 @@ function itemFor(event) {
const title = clean(p.title, 120);
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 };
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];
const k = kinds[event.event_type];
if (!k) return 'ignored:type';
if (!k) return 'ignored:type';
if (event.source !== k[0]) return 'ignored:source';
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';
if (!url || !subjectId) return 'ignored:payload';
// blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.
// 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 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.
// 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)}`)
: k[0] === 'wiki' ? (clean(p.title, 160) || `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`)
: `News: ${clean(p.topic, 60) || 'a new story'}`;
: `News: ${clean(p.topic, 60) || 'a new story'}`;
@@ -60,7 +60,7 @@ function card(i) {
@@ -60,7 +60,7 @@ function card(i) {
site,
site,
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
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,
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: {},
counts: {},
...(i.label ? { label: i.label } : {}),
...(i.label ? { label: i.label } : {}),
};
};
@@ -230,4 +230,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -230,4 +230,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card, countsFor, channelOf };
+module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow };
@@ -88,6 +88,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -88,6 +88,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
...(i.picture_url ? { picture_url: i.picture_url } : {}),
...(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 } : {}),
...(i.text ? { text: i.text } : {}),
actor,
actor,
occurred_at: isoTime(i.occurred_at),
occurred_at: isoTime(i.occurred_at),
@@ -160,11 +163,21 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -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
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);
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');
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, {
const out = await store.upsertItem(db, {
source_service: ref.service, source_type: ref.type, source_id: ref.id,
source_service: ref.service, source_type: ref.type, source_id: ref.id,
title, url: parsed.toString(),
title, url: parsed.toString(),
actor_subject: origin === 'user' ? (v.subject || null) : null,
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]);
const projections = await authors.projectionsFor([out.item.actor_subject]);
return { item: shape(out.item, projections), created: out.created };
return { item: shape(out.item, projections), created: out.created };
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* - OpenVibe.Today: GET /api/v1/events (public, cached there) → source today / event
* - 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.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.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
* 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
* 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
@@ -38,6 +39,7 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
const networkSite = String(config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
const networkSite = String(config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
const questApi = String(config.questInternalUrl || config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
const questApi = String(config.questInternalUrl || config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
const questSite = String(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 timer = null;
let running = null;
let running = null;
@@ -148,10 +150,37 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -148,10 +150,37 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return n;
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 = [
const SOURCES = [
{ name: 'today-events', every: 10 * MINUTE, run: todayEvents },
{ name: 'today-events', every: 10 * MINUTE, run: todayEvents },
{ name: 'network-members', every: 60 * MINUTE, run: networkMembers },
{ name: 'network-members', every: 60 * MINUTE, run: networkMembers },
{ name: 'quest-badges', every: 15 * MINUTE, run: questBadges },
{ name: 'quest-badges', every: 15 * MINUTE, run: questBadges },
+ { name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },
];
];
async function remember(name, { nextRun, lastRun, error = null }) {
async function remember(name, { nextRun, lastRun, error = null }) {
@@ -85,16 +85,16 @@ async function upsertItem(db, it) {
@@ -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.
// 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.text === undefined) it = { ...it, text: had.text };
if (it.touch) {
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 {
} 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 };
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 };
return { item: await find.get(...key), created: true };
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 };
@@ -52,7 +52,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -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 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 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 },
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);
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).
// 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
@@ -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, 'live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.actor.name, 'Alex');
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 () => {
await check('a paste and a screenshot paste carry their text and first picture; the counts are Community\'s', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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&cursor=page-two"/);
+console.log('feed page markup and ItemList JSON-LD passed');
@@ -46,10 +46,18 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -46,10 +46,18 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
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');
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' },
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'] } } }));
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(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.
// 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' },
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' } });
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;
@@ -61,7 +69,7 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
r = await post(vipEv('live'));
r = await post(vipEv('live'));
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.deepStrictEqual(vipDrops, [v1.event_id]);
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.
// Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
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' },
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;
@@ -73,19 +81,19 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
const m11 = mod(11);
const m11 = mod(11);
r = await post(m11);
r = await post(m11);
assert.strictEqual(r.json.outcome, 'pulse:created');
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);
assert.strictEqual((await post(m11)).json.duplicate, true);
r = await post(mod(6));
r = await post(mod(6));
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
r = await post(mod(12));
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');
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));
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, { 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, { 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: '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 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.
// 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 store = require('../server/pulse/store');
const T = Date.parse('2026-06-01T10:00:00.000Z');
const T = Date.parse('2026-06-01T10:00:00.000Z');
@@ -109,12 +117,12 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -109,12 +117,12 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual((await shows(REPEAT)).length, 1, 'the older broadcast is untouched');
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 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'));
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(r.json.outcome, 'pulse:created', 'more than six hours later is a new broadcast');
assert.strictEqual((await shows(REPEAT)).length, 2);
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.
// 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'));
r = await post(liveAt(9005, T + 10 * 60_000, 'URL show'));
@@ -122,7 +130,7 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -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' }));
r = await post(liveAt(9006, T + 25 * 60_000, 'URL show', { actor: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }));
assert.strictEqual(r.json.outcome, 'pulse:updated');
assert.strictEqual(r.json.outcome, 'pulse:updated');
assert.strictEqual((await shows('Goosely went live: URL show')).length, 1);
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,
// 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.
// at the newest time, without deleting the rows.
@@ -24,6 +24,7 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -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 },
{ 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/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: [
if (u.includes('/badges')) return { ok: true, json: async () => ({ badges: [
{ id: 'first-post', name: 'First Post', awarded_at: badgeAt },
{ id: 'first-post', name: 'First Post', awarded_at: badgeAt },
{ id: 'old-badge', name: 'Old Badge', awarded_at: iso(Date.now() - 90 * 86_400_000) },
{ 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);
@@ -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['today-events'].items, 1, JSON.stringify(out));
assert.strictEqual(by['network-members'].items, 2);
assert.strictEqual(by['network-members'].items, 2);
assert.strictEqual(by['quest-badges'].items, 1);
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 feed = (await t.get('/api/v1/feed?limit=40')).json();
const ev = feed.items.find((x) => x.kind === 'event');
const ev = feed.items.find((x) => x.kind === 'event');
assert.strictEqual(ev.title, 'Event: Launch party');
assert.strictEqual(ev.title, 'Event: Launch party');
@@ -55,6 +57,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -55,6 +57,10 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const q = feed.items.find((x) => x.kind === 'quest');
const q = feed.items.find((x) => x.kind === 'quest');
assert.strictEqual(q.title, 'Finished a quest: First Post');
assert.strictEqual(q.title, 'Finished a quest: First Post');
assert.strictEqual(q.actor.name, 'Alex');
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(' | '));
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);
@@ -62,8 +68,8 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const quiet = await t.app.locals.pulseSources.runOnce();
const quiet = await t.app.locals.pulseSources.runOnce();
assert.deepStrictEqual(quiet, []);
assert.deepStrictEqual(quiet, []);
const status = await t.app.locals.pulseSources.status();
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));
assert.ok(status.runs.every((r) => r.next_run_at && r.last_run_at && !r.last_error));
});
});
@@ -108,6 +108,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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.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/</loc>'));
assert.ok(r.text.includes('<loc>https://openvibe.community/pastes</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']) {
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`);
assert.ok(!r.text.includes(`/p/${slug}`), `${slug} must not be in a sitemap`);
}
}