openvibers/openvibe.community Commits

Add feed search and examples (#366)

OP OpenVibers committed 2 days ago · fb1ec59639 · 15 files, +491 −53

* Add feed search and examples

* Expand feed activity, folding and search

* Keep public Rundown timestamps and audience exact

* Ingest public pictures, recordings, achievements and spaces

* Gate followers feed items to author and followers

* Add searchable feed page and current examples

* Keep older public Live content in the feed

* Give pastes their own feed kind

* Discover all public Play achievements

Changes

ddiff --git a/migrations/0033_feed_events.sql b/migrations/0033_feed_events.sql
nnew file mode 100644
iindex 0000000..5a0405e
---- /dev/null
++++ b/migrations/0033_feed_events.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS starts_at text COLLATE "C";
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS actor_name text COLLATE "C";
++ALTER TABLE pulse_items DROP CONSTRAINT IF EXISTS pulse_items_visibility_check;
++ALTER TABLE pulse_items ADD CONSTRAINT pulse_items_visibility_check CHECK (visibility IN ('public', 'followers'));
++
++CREATE TABLE IF NOT EXISTS pulse_profile_revision (
++ subject text PRIMARY KEY,
++ revision bigint NOT NULL
++);
ddiff --git a/server/app.js b/server/app.js
iindex a621500..f01df29 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -800,14 +800,16 @@ async function createApp(opts = {}) {
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 kind = ['live', 'clip', 'video', 'pic', 'post', 'paste', 'meme', 'game', 'update'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
++ const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
const cursor = req.query.cursor ? String(req.query.cursor) : '';
const live = await feedLiveNow();
const items = [];
++ const seen = [];
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 out = await pulse.list({ limit: 12, after: after || undefined }, req.viewer);
const rows = out.items || [];
for (let index = 0; index < rows.length; index++) {
const item = rows[index];
@@@ -816,7 +818,8 @@ async function createApp(opts = {}) {
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);
++ seen.push(card);
++ if ((!kind || card.kind === kind) && feedTools.searchMatches(card, q)) items.push({ row: item, card });
if (items.length >= 12) {
nextCursor = index < rows.length - 1
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
@@@ -828,7 +831,7 @@ async function createApp(opts = {}) {
after = out.next_cursor;
nextCursor = after;
}
-- html(res, feedPage({ items, kind, cursor: cursor || null, nextCursor }));
++ html(res, feedPage({ items: feedTools.foldBursts(items), kind, q, examples: feedTools.searchExamples(seen), cursor: cursor || null, nextCursor }));
}));
app.get('/my', withUser, wrap(async (req, res) => {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0dd938e..280262d 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -40,6 +40,7 @@ const EXPORTS = [
['post_reactions.json', 'post_reactions', 'subject_id'], ['section_reactions.json', 'thread_section_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
['space_moderators.json', 'space_moderators', 'subject_id'], ['quest_checks.json', 'pulse_quest_people', 'subject'],
++ ['profile_feed_revision.json', 'pulse_profile_revision', 'subject'],
];
async function exportPart(db, subject) {
@@@ -49,7 +50,7 @@ async function exportPart(db, subject) {
if (!await hasColumn(db, table, col)) continue;
// Newest first: by id where the table has one, else by its timestamp.
const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at'
-- : await hasColumn(db, table, 'checked_at') ? 'checked_at' : 'updated_at';
++ : await hasColumn(db, table, 'checked_at') ? 'checked_at' : await hasColumn(db, table, 'updated_at') ? 'updated_at' : col;
const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
if (!rows.length) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
@@@ -178,7 +179,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
add(erased, 'submissions', (await db.prepare(`DELETE FROM submissions WHERE author_subject IN ${S}`).run(...subjects)).changes);
await db.prepare(`UPDATE submissions SET reviewer_subject = NULL WHERE reviewer_subject IN ${S}`).run(...subjects);
}
-- for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_quest_people', `subject IN ${S}`, 'quest_checks'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
++ for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_quest_people', `subject IN ${S}`, 'quest_checks'],
++ ['pulse_profile_revision', `subject IN ${S}`, 'profile_feed_revision'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {
if (!await hasTable(db, table)) continue;
ddiff --git a/server/openapi.js b/server/openapi.js
iindex e159247..da9485e 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -374,7 +374,7 @@ route('post', '/api/v1/pulse/items', {
} }, { type: 'object', required: ['ref', 'title', 'url'], properties: {
ref: { type: 'object', required: ['service', 'type', 'id'], properties: { service: str(), type: str(), id: str() } },
title: str(), url: str({ format: 'uri' }), origin: { type: 'string', enum: ['user', 'ai', 'system'] },
-- occurred_at: str({ format: 'date-time' }), visibility: { type: 'string', enum: ['public'] },
++ occurred_at: str({ format: 'date-time' }), visibility: { type: 'string', enum: ['public', 'followers'] },
} }] }),
responses: { 200: json('Already there', { type: 'object', properties: { item: ref('PulseItem'), created: { const: false } } }),
201: json('Created', { type: 'object', properties: { item: ref('PulseItem'), created: { const: true } } }), ...errors() },
@@@ -389,17 +389,27 @@ route('get', '/api/v1/feed', {
operationId: 'getFeed', summary: 'The menu\'s Feed: one stream from Pulse, newest first', tags: ['feed'], security: ANONYMOUS,
description: 'Public. A kind, site or word filters the stream; following=1 is the signed-in person\'s Network follows. Cards carry Community\'s views, likes and comments where it knows them.',
parameters: [
-- query('kind', { type: 'string', enum: ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member'] }),
++ query('kind', { type: 'string', enum: ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode'] }),
query('site', str({ maxLength: 40 }), 'A source service (live, community, wiki, quest, chat, today, network…)'),
query('q', str({ maxLength: 80 }), 'Words in the title or text'), query('following', { type: 'string', enum: ['1', 'true'] }),
query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40, default: 20 })],
responses: { 200: json('Cards and the next cursor', { type: 'object', required: ['items', 'next_cursor'], properties: {
items: arrayOf({ type: 'object', required: ['id', 'kind', 'url', 'title', 'text', 'site', 'site_name', 'counts'], properties: {
id: str(), kind: str(), url: str(), title: str({ maxLength: 300 }), text: str({ maxLength: 280 }), at: nullable('string', { format: 'date-time' }),
-- site: str(), site_name: str(), media: nullable('object'), counts: { type: 'object' },
++ site: str(), site_name: str(), media: nullable('object'), counts: { type: 'object' }, starts_at: str({ format: 'date-time' }), group: { type: 'object' }, visibility: { type: 'string', enum: ['followers'] },
actor: nullable('object', { properties: { name: nullable('string'), avatar: nullable('string') } }) } }),
next_cursor: nullable('string') } }), ...errors([400, 401, 429]) },
});
++route('get', '/api/v1/feed/search', {
++ operationId: 'searchFeed', summary: 'Search feed activity by words, person, site, kind and date', tags: ['feed'], security: ANONYMOUS,
++ parameters: [query('q', str({ maxLength: 80 }), 'For example, clips from today or a person\'s name'),
++ query('cursor', str(), 'The next_cursor from the previous search page'), query('limit', { type: 'integer', minimum: 1, maximum: 40, default: 20 })],
++ responses: { 200: json('Matching feed cards', { type: 'object', properties: { items: arrayOf({ type: 'object' }), next_cursor: nullable('string') } }), ...errors([400, 429]) },
++});
++route('get', '/api/v1/feed/examples', {
++ operationId: 'feedSearchExamples', summary: 'Search examples based on current feed items', tags: ['feed'], security: ANONYMOUS,
++ responses: { 200: json('Suggested searches', { type: 'object', properties: { examples: arrayOf({ type: 'object', properties: { text: str(), reason: str() } }) } }), ...errors([429]) },
++});
route('post', '/api/v1/me/status', {
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex 7a47df4..f165a11 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -39,7 +39,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
--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 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.user.updated', '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/';
@@@ -109,6 +109,31 @@ function gameProgressOf(event) {
return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };
}
++/** Network's profile event carries a changed list; only an avatar edit can make an avatar card. */
++async function applyAvatar(db, event) {
++ if (event.source !== 'network') return 'ignored:source';
++ const p = event.payload || {};
++ const who = subjectOf(p.subject);
++ const revision = Number(p.revision);
++ if (!who || !Number.isSafeInteger(revision) || revision < 1) return 'ignored:payload';
++ const old = await db.prepare('SELECT revision FROM pulse_profile_revision WHERE subject = ?').get(who);
++ if (old && Number(old.revision) >= revision) return 'ignored:old_revision';
++ await db.prepare(`INSERT INTO pulse_profile_revision (subject, revision) VALUES (?, ?)
++ ON CONFLICT(subject) DO UPDATE SET revision = excluded.revision`).run(who, revision);
++ if (p.banned) { await store.removeItem(db, 'network', 'avatar', who); return 'avatar:removed'; }
++ if (!Array.isArray(p.changed) || !p.changed.includes('avatar_url')) return 'profile:updated';
++ await store.removeItem(db, 'network', 'avatar', who);
++ if (p.banned || !p.avatar_url) return 'avatar:removed';
++ const avatar = httpsUrl(p.avatar_url);
++ const username = clean(p.username, 64);
++ if (!avatar || !/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) return 'ignored:payload';
++ await store.upsertItem(db, { source_service: 'network', source_type: 'avatar', source_id: who,
++ title: `${clean(p.display_name || username, 80)} uploaded a new avatar`,
++ url: `https://openvibe.network/@${encodeURIComponent(username)}`, picture_url: avatar,
++ actor_subject: who, origin: 'user', occurred_at: event.occurred_at || event.timestamp || new Date().toISOString() });
++ return 'pulse:created';
++}
++
/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
async function applyGameProgress(db, g) {
const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
@@@ -210,6 +235,17 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
++ if (event.event_type === 'network.user.updated') {
++ try {
++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyAvatar(db, event) }));
++ if (r.duplicate) stats.duplicates++; else stats.applied++;
++ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
++ } catch (err) {
++ stats.failed++;
++ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
++ return problem(500, 'community.event_failed', 'processing failed; it will be retried');
++ }
++ }
if (event.event_type === 'chat.room.message.created') {
const c = chatMessageOf(event);
if (typeof c === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: c }); }
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex 68c9c11..e729005 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -22,8 +22,8 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');
--const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member'];
--const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };
++const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode'];
++const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };
const MAX_PAGE_READS = 8;
const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
@@@ -33,6 +33,13 @@ function kindOf(i) {
const s = String((i.source && i.source.service) || '');
const t = String((i.source && i.source.type) || '');
if (/^(live|stream|went_live|broadcast)$/.test(t) || (s === 'live' && /\b(went live|is live|live now)\b/i.test(i.title || ''))) return 'live';
++ if (t === 'avatar') return 'avatar';
++ if (t === 'banner') return 'banner';
++ if (t === 'follow') return 'follow';
++ if (t === 'site') return 'site';
++ if (t === 'space') return 'space';
++ if (t === 'paste') return 'paste';
++ if (t === 'episode') return 'episode';
if (t === 'clip' || t === 'moment') return 'clip';
if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
@@@ -49,7 +56,7 @@ function kindOf(i) {
function card(i) {
const site = String((i.source && i.source.service) || 'community');
-- const a = i.actor;
++ const a = i.actor || (i.actor_name ? { display_name: i.actor_name } : null);
return {
id: `p:${i.id}`,
kind: kindOf(i),
@@@ -57,11 +64,13 @@ function card(i) {
title: clean(i.title, 300),
text: clean(i.text, 280),
at: isoTime(i.occurred_at) || i.occurred_at || null,
++ ...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),
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), ...(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.visibility === 'followers' ? { visibility: 'followers' } : {}),
...(i.label ? { label: i.label } : {}),
};
}
@@@ -69,6 +78,25 @@ function card(i) {
const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');
const typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');
const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');
++const BURST_MS = 15 * 60_000;
++const BURST_WORD = { pic: 'photos', clip: 'clips', video: 'videos', game: 'achievements', quest: 'quests', avatar: 'profile updates' };
++function foldBursts(pairs) {
++ const groups = [];
++ for (const p of pairs) {
++ const actor = p.row.actor_subject || (p.row.actor && p.row.actor.subject) || (p.row.actor_name && `name:${p.row.actor_name}`);
++ const at = Date.parse(p.card.at || '');
++ const prior = actor && Number.isFinite(at) && BURST_WORD[p.card.kind] && groups.find((g) =>
++ g.actor === actor && g.card.kind === p.card.kind && g.site === p.card.site && Math.abs(g.at - at) <= BURST_MS);
++ if (prior) { prior.cards.push(p.card); continue; }
++ groups.push({ actor, at, site: p.card.site, card: p.card, cards: [p.card] });
++ }
++ return groups.map((g) => {
++ if (g.cards.length < 2) return g.card;
++ const name = g.card.actor && g.card.actor.name || 'Someone';
++ return { ...g.card, title: `${name} added ${g.cards.length} ${BURST_WORD[g.card.kind]}`,
++ group: { count: g.cards.length, items: g.cards.map((c) => ({ id: c.id, url: c.url, title: c.title, media: c.media })) } };
++ });
++}
/** Community's own views, likes and comments, in a few batched reads (never one query per card). */
async function countsFor(db, rows) {
@@@ -159,6 +187,32 @@ function createLiveNow({ liveUrl, fetchImpl }) {
}
const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };
++function searchMatches(item, query) {
++ const q = clean(query, 80).toLowerCase();
++ if (!q) return true;
++ const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode' };
++ const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);
++ const kind = kindWord && aliases[kindWord];
++ if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;
++ const day = String(item.at || '').slice(0, 10);
++ if (/\btoday\b/.test(q) && day !== new Date().toISOString().slice(0, 10)) return false;
++ if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;
++ const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);
++ if (date && day !== date[0]) return false;
++ const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();
++ return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name]
++ .some((v) => String(v || '').toLowerCase().includes(words));
++}
++
++function searchExamples(cards) {
++ const examples = [];
++ const add = (text, reason) => { if (!examples.some((x) => x.text === text)) examples.push({ text, reason }); };
++ for (const c of cards) if (c.actor && c.actor.name) add(c.actor.name, 'person');
++ for (const kind of [...new Set(cards.map((c) => c.kind))]) add(`new ${kind === 'pic' ? 'photos' : `${kind}s`}`, 'kind');
++ if (cards.some((c) => c.kind === 'clip' && String(c.at || '').slice(0, 10) === new Date().toISOString().slice(0, 10))) add('clips from today', 'date and kind');
++ return examples.slice(0, 8);
++}
++
function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
const router = express.Router();
router.use(contracts.http.middleware());
@@@ -202,7 +256,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
mark([c], live);
if (kind && c.kind !== kind) continue;
if (site && c.site !== site) continue;
-- if (q && !(c.title.toLowerCase().includes(q) || c.text.toLowerCase().includes(q))) continue;
++ if (q && !(c.title.toLowerCase().includes(q) || c.text.toLowerCase().includes(q)
++ || String(c.actor && c.actor.name || '').toLowerCase().includes(q)
++ || c.site_name.toLowerCase().includes(q) || c.kind.includes(q))) continue;
picked.push({ row: i, card: c });
if (picked.length >= pulseWant) {
// Resume after the last consumed row, including when Pulse's final page was only partly read.
@@@ -219,15 +275,50 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
// The first page carries trending pictures and videos, one after every three Pulse items.
const items = [];
let k = 0;
-- picked.forEach((p, n) => {
-- items.push(p.card);
-- if ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) items.push(t[k++]);
++ foldBursts(picked).forEach((c, n) => {
++ items.push(c);
++ if ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) {
++ const extra = t[k++];
++ if (!items.some((x) => x.url === extra.url)) items.push(extra);
++ }
});
-- while (items.length < want && k < t.length) items.push(t[k++]);
++ const seen = new Set(items.map((c) => c.url));
++ while (items.length < want && k < t.length) { const c = t[k++]; if (!seen.has(c.url)) { items.push(c); seen.add(c.url); } }
return { items, next_cursor: next };
}));
++ router.get('/search', run(async (req) => {
++ const q = clean(req.query.q, 80).toLowerCase();
++ if (!q) fail(400, 'feed.search_required', 'Enter a search term');
++ const live = await liveNow();
++ const matched = [];
++ let cursor = clean(req.query.cursor, 500), next = null;
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
++ for (let pages = 0; pages < MAX_PAGE_READS && matched.length < limit; pages++) {
++ const page = await pulse.list({ after: cursor || undefined, limit: 100 }, req.viewer);
++ next = page.next_cursor || null;
++ const rows = page.items || [];
++ for (let index = 0; index < rows.length; index++) {
++ const item = card(rows[index]);
++ mark([item], live);
++ if (!searchMatches(item, q)) continue;
++ matched.push({ row: rows[index], card: item });
++ if (matched.length >= limit) {
++ if (index < rows.length - 1) next = encodeCursor([sqlTime(rows[index].occurred_at), rows[index].id]);
++ break;
++ }
++ }
++ if (!next) break;
++ cursor = next;
++ }
++ return { items: foldBursts(matched), next_cursor: next };
++ }));
++ router.get('/examples', run(async (req) => {
++ const page = await pulse.list({ limit: 100 }, req.viewer);
++ const cards = (page.items || []).map(card);
++ return { examples: searchExamples(cards) };
++ }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
}
--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow };
++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, searchMatches, searchExamples, foldBursts };
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex d7f6ce7..7c8d3d4 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -85,6 +85,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
title: i.title,
url: i.url,
origin: i.origin,
++ ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
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 } : {}),
@@@ -92,6 +93,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
...(i.media_length ? { media_length: i.media_length } : {}),
...(i.text ? { text: i.text } : {}),
++ ...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),
++ ...(i.actor_name ? { actor_name: i.actor_name } : {}),
actor,
occurred_at: isoTime(i.occurred_at),
};
@@@ -151,7 +154,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
if (!ref || typeof ref !== 'object' || !contracts.validate('common.entity-ref@1', ref).valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
const own = serviceNameOf(v.service);
if (ref.service !== own) fail(403, 'pulse.foreign_ref', `A service publishes Pulse items about its own entities (${own}), not ${ref.service}'s`);
-- if (body.visibility != null && body.visibility !== 'public') fail(400, 'pulse.not_public', 'Only public activity enters Pulse');
++ if (body.visibility != null && !['public', 'followers'].includes(body.visibility)) fail(400, 'pulse.not_public', 'Only public or followers activity enters Pulse');
const title = String(body.title == null ? '' : body.title).replace(/\s+/g, ' ').trim();
if (!title || title.length > 300) fail(400, 'pulse.invalid_title', 'title is required (at most 300 characters)');
const url = String(body.url || '');
@@@ -161,6 +164,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
let origin = body.origin == null ? v.origin : body.origin;
if (!ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
++ if (body.visibility === 'followers' && (origin !== 'user' || !v.subject)) fail(400, 'pulse.followers_subject_required', 'Followers activity needs the person who shared it');
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 : {};
@@@ -177,7 +181,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
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), picture_url: thumb, preview_url: preview, media_ratio: ratio, media_length: length,
++ origin, visibility: body.visibility || 'public', 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 };
@@@ -250,9 +254,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
|| ![0, 1].includes(before[1]) || typeof before[2] !== 'string' || !Number.isSafeInteger(before[3]))) {
fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');
}
-- const followed = mine ? await followedSubjects(viewer) : [];
++ const followed = viewer && viewer.kind === 'user' && viewer.token ? await followedSubjects(viewer) : [];
const hour = mine ? (before ? before[0] : sqlTime(Date.now() - 60 * 60_000)) : null;
-- const { rows, hasMore } = await store.listItems(db, { origin, before: before && (mine ? before.slice(1) : [0, ...before]), limit, followed, hour });
++ const { rows, hasMore } = await store.listItems(db, { origin, before: before && (mine ? before.slice(1) : [0, ...before]), limit, followed, viewerSubject: viewer && viewer.kind === 'user' ? viewer.subject : null, hour });
const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
const last = rows[rows.length - 1];
return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last
ddiff --git a/server/pulse/sources.js b/server/pulse/sources.js
iindex 921a304..5c7da73 100644
---- a/server/pulse/sources.js
++++ b/server/pulse/sources.js
@@@ -27,8 +27,8 @@ const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().s
const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
const subject = (v) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(v || '')) ? String(v) : null);
const sqlFrom = (v, fallback) => {
-- const t = Date.parse(String(v || ''));
-- return Number.isFinite(t) ? sqlTime(t) : fallback;
++ const normalized = store.pulseTime(v);
++ return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(String(normalized || '')) ? normalized : fallback;
};
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
@@@ -40,6 +40,10 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
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(/\/+$/, '');
++ const picsApi = String(config.picsUrl || 'https://openvibe.pics').replace(/\/+$/, '');
++ const liveApi = String(config.liveUrl || 'https://openvibe.live').replace(/\/+$/, '');
++ const spaceApi = String(config.spaceUrl || 'https://openvibe.space').replace(/\/+$/, '');
++ const playApi = String(config.playUrl || 'https://play.openvibe.network').replace(/\/+$/, '');
let timer = null;
let running = null;
@@@ -52,21 +56,24 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
/** OpenVibe.Today's public calendar → one event row per scheduled stream or event. */
async function todayEvents() {
const at = now();
-- const from = new Date(at - 2 * 60 * MINUTE).toISOString();
++ const from = new Date(at).toISOString();
const to = new Date(at + 60 * DAY).toISOString();
const out = await getJson(`${todayApi}/api/v1/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=100`);
let n = 0;
for (const e of (Array.isArray(out.events) ? out.events : [])) {
if (!e || typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || !e.title.trim()) continue;
if (e.visibility !== 'public' || e.status === 'cancelled') continue;
++ const starts = Date.parse(String(e.starts_at || ''));
++ if (!Number.isFinite(starts) || starts <= at) continue;
const owner = subject(e.owner_subject);
-- const text = clean(e.description, 280) || (e.starts_at ? `Starts ${String(e.starts_at).replace('T', ' ').slice(0, 16)} UTC` : '');
++ const whenLabel = `Starts ${new Date(starts).toISOString().replace('T', ' ').slice(0, 16)} UTC`;
++ const text = [whenLabel, clean(e.description, 220)].filter(Boolean).join(' · ');
const when = sqlFrom(e.created_at, sqlTime(at));
const out2 = await store.upsertItem(db, {
source_service: 'today', source_type: 'event', source_id: e.id,
-- title: `${e.kind === 'stream' ? 'Live' : 'Event'}: ${clean(e.title, 140)}`,
++ title: `Coming up: ${clean(e.title, 140)}`,
url: https(e.url) || `${todaySite}/e/${encodeURIComponent(e.id)}`,
-- actor_subject: owner, origin: owner ? 'user' : 'system', occurred_at: when, text,
++ actor_subject: owner, origin: owner ? 'user' : 'system', occurred_at: when, starts_at: e.starts_at, text,
});
if (out2.created) n++;
}
@@@ -150,6 +157,39 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return n;
}
++ /** Play's anonymous feed contains every person's public unlock across production games. */
++ async function playAchievements() {
++ let cursor = null;
++ let created = 0;
++ const seen = new Set();
++ for (let page = 0; page < 100; page++) {
++ const url = new URL('/api/v1/achievements/feed', playApi);
++ url.searchParams.set('limit', '100');
++ if (cursor) url.searchParams.set('cursor', cursor);
++ const out = await getJson(url.toString());
++ for (const a of (Array.isArray(out.items) ? out.items : [])) {
++ const who = subject(a && a.player);
++ const game = clean(a && a.title, 64);
++ const id = clean(a && a.id, 80);
++ const at = sqlFrom(a && a.unlocked_at, null);
++ if (!who || !/^[a-z0-9-]{2,40}$/.test(game) || !/^[a-z0-9_-]{1,64}$/.test(id) || !at) continue;
++ const sourceId = `${who}:${game}:${id}`;
++ seen.add(sourceId);
++ const r = await store.upsertItem(db, { source_service: 'games', source_type: 'achievement', source_id: sourceId,
++ title: `Unlocked ${clean(a.name, 120)} in ${clean(a.title_name || game, 80)}`,
++ url: `https://openvibe.games/play?game=${encodeURIComponent(game)}`, actor_subject: who, origin: 'user', occurred_at: at,
++ text: clean(a.description, 280) });
++ if (r.created) created++;
++ }
++ cursor = typeof out.next === 'string' && out.next ? out.next : null;
++ if (!cursor) break;
++ }
++ if (cursor) throw new Error('Play achievement feed exceeded 100 pages; retaining prior cards');
++ const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'games' AND source_type = 'achievement'").all();
++ for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'games', 'achievement', row.source_id);
++ return created;
++ }
++
/** Meme's public entries → one Feed card each; the API excludes hidden and unlisted entries. */
async function memeEntries() {
let cursor = null, created = 0;
@@@ -176,11 +216,150 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return created;
}
++ /** Pics' gallery is opt-in and checks both the owner's public share and the active picture row. */
++ async function publicPictures() {
++ const seen = new Set();
++ let cursor = null, created = 0;
++ for (let page = 0; page < 50; page++) {
++ const url = new URL('/api/v1/gallery', picsApi);
++ url.searchParams.set('sort', 'new');
++ url.searchParams.set('limit', '30');
++ if (cursor) url.searchParams.set('cursor', cursor);
++ const out = await getJson(url.toString());
++ for (const p of (Array.isArray(out.pictures) ? out.pictures : [])) {
++ const id = clean(p && p.id, 80);
++ const links = p && p.links || {};
++ if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !p.listed || !https(links.page) || !https(links.thumb)) continue;
++ seen.add(id);
++ const r = await store.upsertItem(db, { source_service: 'pics', source_type: 'picture', source_id: id,
++ title: clean(p.title, 160) || 'Picture on OpenVibe.Pics', url: https(links.page), picture_url: https(links.thumb),
++ actor_name: clean(p.author, 80), actor_subject: null, origin: 'user',
++ occurred_at: sqlFrom(p.published_at, sqlTime(now())), text: clean(p.description, 280),
++ media_ratio: Number(p.width) > 0 && Number(p.height) > 0 ? Number(p.width) / Number(p.height) : null });
++ if (r.created) created++;
++ }
++ cursor = typeof out.next === 'string' && out.next ? out.next : null;
++ if (!cursor) break;
++ }
++ if (cursor) throw new Error('Pics gallery exceeded 50 pages; retaining prior pictures');
++ const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'pics' AND source_type = 'picture'").all();
++ for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'pics', 'picture', row.source_id);
++ return created;
++ }
++
++ /** The anonymous Rundown browse response contains only episodes visible to everyone. */
++ async function rundownEpisodes() {
++ let before = null, created = 0;
++ const seen = new Set();
++ for (let page = 0; page < 50; page++) {
++ const url = new URL('/api/rundown/streams', liveApi);
++ if (before) url.searchParams.set('before', before);
++ const out = await getJson(url.toString());
++ for (const s of (Array.isArray(out.streams) ? out.streams : [])) {
++ if (!s || s.live || !s.episode || s.episode.audience !== 'public' || !Number.isSafeInteger(Number(s.stream_id)) || Number(s.stream_id) < 1) continue;
++ const id = String(s.stream_id);
++ const title = clean(s.episode.title || s.title, 160);
++ if (!title) continue;
++ seen.add(id);
++ const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);
++ const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,
++ title: `Rundown: ${title}`, url: `${liveApi}/rundown/e/${id}`,
++ actor_subject: prior && prior.actor_subject || null, origin: prior && prior.actor_subject ? 'user' : 'system',
++ occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),
++ text: clean(s.episode.logline, 280), picture_url: https(s.thumbnail_url) });
++ if (r.created) created++;
++ }
++ before = typeof out.next === 'string' && out.next ? out.next : null;
++ if (!before) break;
++ }
++ if (before) throw new Error('Rundown pagination exceeded 50 pages; retaining prior episodes');
++ const stale = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode'").all();
++ for (const row of stale) if (!seen.has(row.source_id)) await store.removeItem(db, 'live', 'episode', row.source_id);
++ return created;
++ }
++
++ /** Live's Content API has already checked public Media rows, banned owners and playable VODs. */
++ async function publicContent() {
++ const seen = new Set();
++ let cursor = null, created = 0;
++ for (let page = 0; page < 100; page++) {
++ const url = new URL('/api/content/feed', liveApi);
++ url.searchParams.set('sort', 'new');
++ url.searchParams.set('window', 'all');
++ url.searchParams.set('limit', '30');
++ if (cursor) url.searchParams.set('cursor', cursor);
++ const out = await getJson(url.toString());
++ if (out.partial || (out.sources && (out.sources.vods !== 'ok' || out.sources.clips !== 'ok'))) throw new Error('Live Content feed was partial; retaining prior cards');
++ for (const v of (Array.isArray(out.items) ? out.items : [])) {
++ if (!v || !['vod', 'clip'].includes(v.kind) || !https(v.href)) continue;
++ const id = clean(v.id, 80);
++ if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
++ const type = v.kind === 'vod' ? 'recording' : 'clip';
++ seen.add(`${type}:${id}`);
++ const at = sqlFrom(v.created_at, null);
++ if (!at) continue;
++ const channel = clean(v.channel && v.channel.username, 64);
++ let prior = null;
++ if (type === 'recording' && channel) {
++ prior = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'
++ AND lower(url) = lower(?) AND occurred_at BETWEEN ? AND ? ORDER BY occurred_at DESC LIMIT 1`)
++ .get(`${liveApi}/@${encodeURIComponent(channel)}`, sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) - 60 * MINUTE), sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) + 60 * MINUTE));
++ }
++ const r = await store.upsertItem(db, { source_service: 'live', source_type: type, source_id: id,
++ title: `${type === 'recording' ? 'Recording: ' : ''}${clean(v.title, 150) || (type === 'recording' ? 'A stream' : 'A clip')}`,
++ url: https(v.href), picture_url: https(v.thumbnail_url),
++ actor_subject: prior && prior.actor_subject || null, actor_name: clean(v.channel && v.channel.display_name, 80),
++ origin: 'user', occurred_at: at, text: clean(v.excerpt, 280), media_length: Number(v.duration_seconds) > 0 ? Number(v.duration_seconds) : null });
++ if (r.created) created++;
++ if (prior) await store.removeItem(db, 'live', 'stream', prior.source_id);
++ }
++ cursor = typeof out.next === 'string' && out.next ? out.next : null;
++ if (!cursor) break;
++ }
++ if (cursor) throw new Error('Live Content feed exceeded 100 pages; retaining prior cards');
++ const old = await db.prepare("SELECT source_type, source_id FROM pulse_items WHERE source_service = 'live' AND source_type IN ('recording', 'clip')").all();
++ for (const row of old) if (!seen.has(`${row.source_type}:${row.source_id}`)) await store.removeItem(db, 'live', row.source_type, row.source_id);
++ return created;
++ }
++
++ /** Space's public gallery excludes drafts, private spaces, inactive sites and takedowns. */
++ async function publicSpaces() {
++ const seen = new Set();
++ let cursor = null, created = 0;
++ for (let page = 0; page < 50; page++) {
++ const url = new URL('/api/v1/apps/gallery', spaceApi);
++ url.searchParams.set('limit', '100');
++ if (cursor) url.searchParams.set('cursor', cursor);
++ const out = await getJson(url.toString());
++ for (const s of (Array.isArray(out.spaces) ? out.spaces : [])) {
++ const id = clean(s && s.id, 80);
++ const at = Number(s && s.published_at);
++ if (!/^spc_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !Number.isFinite(at) || at <= 0 || !https(s.url)) continue;
++ seen.add(id);
++ const r = await store.upsertItem(db, { source_service: 'space', source_type: 'space', source_id: id,
++ title: clean(s.name, 160) || 'New space', url: https(s.url), text: clean(s.description, 280),
++ actor_name: clean(s.owner_name || s.owner_username, 80), actor_subject: null, origin: 'user', occurred_at: sqlTime(at) });
++ if (r.created) created++;
++ }
++ cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;
++ if (!cursor) break;
++ }
++ if (cursor) throw new Error('Space gallery exceeded 50 pages; retaining prior spaces');
++ const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'space' AND source_type = 'space'").all();
++ for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'space', 'space', row.source_id);
++ 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: 'play-achievements', every: 15 * MINUTE, run: playAchievements },
{ name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },
++ { name: 'public-pictures', every: 5 * MINUTE, run: publicPictures },
++ { name: 'rundown-episodes', every: 10 * MINUTE, run: rundownEpisodes },
++ { name: 'public-content', every: 5 * MINUTE, run: publicContent },
++ { name: 'public-spaces', every: 10 * MINUTE, run: publicSpaces },
];
async function remember(name, { nextRun, lastRun, error = null }) {
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
iindex c1f3ea6..ee64ed6 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 = ?, 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);
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, visibility = ?, 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), pulseTime(it.starts_at) || null, it.actor_name || null, it.visibility || 'public', it.occurred_at, ...key);
} else {
-- 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);
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, visibility = ? 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), pulseTime(it.starts_at) || null, it.actor_name || null, it.visibility || 'public', ...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, 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));
++ 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, starts_at, actor_name)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', 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), pulseTime(it.starts_at) || null, it.actor_name || null);
return { item: await find.get(...key), created: true };
});
}
@@@ -114,11 +114,16 @@ async function removeItem(db, service, type, id) {
* opts.before [occurred_at, id] of the last item already seen
* → { rows, hasMore }
*/
--async function listItems(db, { origin = null, before = null, limit = 30, followed = [], hour = null } = {}) {
++async function listItems(db, { origin = null, before = null, limit = 30, followed = [], viewerSubject = null, hour = null } = {}) {
const priority = "CASE WHEN @hour::text IS NOT NULL AND i.occurred_at >= @hour AND jsonb_exists(@followed::jsonb, i.actor_subject) THEN 1 ELSE 0 END";
const rows = await db.prepare(`
SELECT i.*, ${priority} AS feed_priority FROM pulse_items i
WHERE (@origin::text IS NULL OR i.origin = @origin)
++ AND (i.visibility = 'public' OR (i.visibility = 'followers' AND i.actor_subject IS NOT NULL
++ AND (i.actor_subject = @viewerSubject::text OR jsonb_exists(@followed::jsonb, i.actor_subject))))
++ AND (i.source_service <> 'today' OR i.source_type <> 'event' OR (i.starts_at IS NOT NULL AND i.starts_at > to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')))
++ AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (
++ SELECT 1 FROM pulse_items e WHERE e.source_service = 'live' AND e.source_type = 'episode' AND e.source_id = i.source_id))
AND (@at::text IS NULL OR ${priority} < @priority::integer
OR (${priority} = @priority::integer AND (i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id::bigint))))
AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (
@@@ -141,7 +146,7 @@ async function listItems(db, { origin = null, before = null, limit = 30, followe
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
OR (i.source_type = 'submission' AND EXISTS (SELECT 1 FROM submissions su WHERE su.slug = i.source_id AND su.status = 'accepted')))
ORDER BY feed_priority DESC, i.occurred_at DESC, i.id DESC
-- LIMIT @limit`).all({ origin, followed: JSON.stringify(followed), hour,
++ LIMIT @limit`).all({ origin, followed: JSON.stringify(followed), viewerSubject, hour,
priority: before ? before[0] : null, at: before ? before[1] : null, id: before ? before[2] : null, limit: limit + 1 });
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
ddiff --git a/server/render/feed.js b/server/render/feed.js
iindex d36440e..55500d5 100644
---- a/server/render/feed.js
++++ b/server/render/feed.js
@@@ -5,18 +5,20 @@ 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}` : ''}`; };
++const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'Videos'], ['pic', 'Pics'], ['post', 'Posts'], ['paste', 'Pastes'], ['meme', 'Memes'], ['game', 'Games'], ['update', 'Updates']];
++const href = (kind, cursor = '', search = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (search) q.set('q', search); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };
--function feedPage({ items = [], kind = '', cursor = null, nextCursor = null } = {}) {
++function feedPage({ items = [], kind = '', q = '', examples = [], 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 chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(href(id, '', q))}"${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>
++<form action="/feed" method="get" role="search"><label for="feed-search">Search the feed</label><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>
++${examples.length ? `<p class="muted">Try: ${examples.map((x) => `<a href="${esc(href('', '', x.text))}">${esc(x.text)}</a>`).join(' · ')}</p>` : ''}
<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>` : ''}`;
++${cursor || nextCursor ? `<nav class="pager" aria-label="Feed pages">${cursor ? `<a href="${esc(href(kind, '', q))}">Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(kind, nextCursor, q))}">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 });
++ 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 || q || !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/account-data.test.js b/test/account-data.test.js
iindex 3b27d88..3068f5b 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -26,6 +26,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Community applied',
pulse_quest_people: 'when the Quest badge scan last checked a person: exported as quest_checks.json and erased with them',
++ pulse_profile_revision: 'Network profile event ordering: exported as profile_feed_revision.json and erased with the person',
share_members: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
share_access_requests: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
},
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex 4f94089..abecbbd 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -38,6 +38,13 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
(async () => {
const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch, startRelay: false, startSharingWorker: false, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ await check('feed examples are generated from current Pulse cards and search accepts natural filters', async () => {
++ const examples = await t.get('/api/v1/feed/examples');
++ assert.strictEqual(examples.status, 200, examples.text);
++ assert.ok(Array.isArray(examples.json().examples));
++ const empty = await t.get('/api/v1/feed/search');
++ assert.strictEqual(empty.status, 400);
++ });
const net = t.network;
const db = t.app.locals.db;
const pulse = t.app.locals.pulse;
@@@ -67,14 +74,18 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
['wiki', 'page', 'w1', 'Wiki: a page', 'https://openvibe.wiki/w/1', sam.subject_id, 'user', 19],
['quest', 'badge', 'q1', 'Finished a quest: First Post', 'https://openvibe.quest/', sam.subject_id, 'user', 20],
['chat', 'hour', 'general:2026-11-09T10', '#general: 12 messages this hour', 'https://openvibe.chat/rooms/general', null, 'system', 21],
-- ['today', 'event', 'e1', 'Event: A launch party', 'https://openvibe.today/e/e1', alex.subject_id, 'user', 22],
++ ['today', 'event', 'e1', 'Coming up: A launch party', 'https://openvibe.today/e/e1', alex.subject_id, 'user', 22],
['network', 'members', '2026-11-09', '5 people joined today', 'https://openvibe.network/stats', null, 'system', 23],
];
for (const [service, type, id, title, url, actor, origin, minsAgo] of fixtures) {
await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, text, picture_url)
VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?)`)
.run(service, type, id, title, url, actor, origin, at(minsAgo), type === 'event' ? 'Come along' : '', null);
++ if (type === 'event') await db.prepare('UPDATE pulse_items SET starts_at = ? WHERE source_service = ? AND source_id = ?')
++ .run(new Date(Date.now() + 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19), service, id);
}
++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
++ VALUES ('pics', 'picture', 'p2', 'Another picture', 'https://openvibe.pics/p/2', ?, 'user', 'public', ?)`).run(alex.subject_id, at(15.5));
// Make the paste's counts real so the card has Community's own views, likes and comments.
await db.prepare('UPDATE pastes SET views = 7, likes = 3 WHERE slug = ?').run(textPaste.slug);
@@@ -85,14 +96,18 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.status, 200, r.text);
const j = r.json();
const kinds = new Set(j.items.map((x) => x.kind));
-- for (const k of ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);
++ for (const k of ['live', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);
assert.ok('next_cursor' in j);
for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);
-- const c = j.items.find((x) => x.title === 'Great clip');
++ const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');
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 });
++ const photos = j.items.find((x) => x.kind === 'pic');
++ assert.strictEqual(photos.title, 'Alex added 2 photos');
++ assert.strictEqual(photos.group.count, 2);
++ assert.deepStrictEqual(photos.group.items.map((x) => x.url), ['https://openvibe.pics/p/1', 'https://openvibe.pics/p/2']);
});
await check('GET /feed is server rendered, kind chips keep the page crawlable and Older carries a cursor', async () => {
@@@ -102,13 +117,16 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
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');
++ assert.ok(r.text.includes('id="feed-search"') && r.text.includes('Try:'), 'the page offers feed search and current examples');
++ const searched = await t.get('/feed?q=videos');
++ assert.ok(searched.text.includes('A video') && searched.text.includes('name="q" value="videos"'), 'the page applies a natural kind search');
});
await check('a paste and a screenshot paste carry their text and first picture; the counts are Community\'s', async () => {
const j = (await t.get('/api/v1/feed?limit=40')).json();
const p = j.items.find((x) => x.title === 'Public one');
-- assert.strictEqual(p.kind, 'post');
assert.strictEqual(p.text, 'The first line of a public paste.');
++ assert.strictEqual(p.kind, 'paste');
assert.deepStrictEqual(p.counts, { views: 7, likes: 3, comments: 2 });
const s = j.items.find((x) => x.title === 'A shot');
const stored = await db.prepare('SELECT screenshot_url FROM pastes WHERE slug = ?').get(shot.slug);
@@@ -189,12 +207,12 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.status, 200, r.text);
const page = r.json();
assert.ok(page.items.length <= limit, 'trending counts toward the page limit');
-- seen.push(...page.items.filter((x) => x.id.startsWith('p:')).map((x) => x.id));
++ seen.push(...page.items.filter((x) => x.id.startsWith('p:')).flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]));
cursor = page.next_cursor;
if (cursor) { assert.ok(!cursors.has(cursor), 'cursor advances'); cursors.add(cursor); }
assert.ok(cursors.size < 40, 'pagination terminates');
} while (cursor);
-- assert.deepStrictEqual(seen, all.map((x) => x.id), `limit=${limit}: no missing or repeated rows`);
++ assert.deepStrictEqual([...seen].sort(), all.flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]).sort(), `limit=${limit}: no missing or repeated rows`);
}
});
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
iindex 597d90a..f2d08e9 100644
---- a/test/pulse-consumer.test.js
++++ b/test/pulse-consumer.test.js
@@@ -184,6 +184,18 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
await post(chat(7)); await post(chat(8)); await post(chat(9)); await post(chat(10));
assert.strictEqual((await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'chat'").get()).title, '#general: 10 messages this hour');
++ const profile = (revision, changed, avatar_url, banned = false) => evt({ event_type: 'network.user.updated', source: 'network', visibility: 'internal',
++ subject: { type: 'user', id: USR }, actor: { type: 'user', id: USR },
++ payload: { subject: { type: 'user', id: USR }, revision, changed, username: 'goosely', display_name: 'Goosely', avatar_url, banned } });
++ r = await post(profile(1, ['avatar_url'], 'https://openvibe.media/avatar1.webp'));
++ assert.strictEqual(r.json.outcome, 'pulse:created');
++ assert.strictEqual((await db.prepare("SELECT picture_url FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).picture_url, 'https://openvibe.media/avatar1.webp');
++ r = await post(profile(3, ['avatar_url'], null));
++ assert.strictEqual(r.json.outcome, 'avatar:removed');
++ assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).n, 0);
++ r = await post(profile(2, ['avatar_url'], 'https://openvibe.media/old.webp'));
++ assert.strictEqual(r.json.outcome, 'ignored:old_revision', 'an older event cannot restore an avatar');
++
srv.close();
console.log('pulse consumer: all checks passed');
process.exit(0);
ddiff --git a/test/pulse-sources.test.js b/test/pulse-sources.test.js
iindex 72ac49a..0305843 100644
---- a/test/pulse-sources.test.js
++++ b/test/pulse-sources.test.js
@@@ -14,6 +14,14 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const yesterday = day(Date.now() - 86_400_000);
const badgeAt = iso(Date.now() - 3_600_000);
const calls = [];
++ let galleryRows = [
++ { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,
++ links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },
++ { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPR', title: 'Second photo', author: 'Alex', listed: true, published_at: badgeAt,
++ links: { page: 'https://openvibe.pics/i/secondpic', thumb: 'https://openvibe.pics/i/secondpic/thumb.webp' }, width: 800, height: 600 },
++ { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Unlisted photo', author: 'Alex', listed: false, published_at: badgeAt,
++ links: { page: 'https://openvibe.pics/i/privatep', thumb: 'https://openvibe.pics/i/privatep/thumb.webp' }, width: 800, height: 600 },
++ ];
let eventOwner = null;
const sourcesFetch = async (url) => {
calls.push(String(url));
@@@ -25,10 +33,25 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
] }) };
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('/api/v1/gallery')) return { ok: true, json: async () => ({ pictures: galleryRows, next: null }) };
++ if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,
++ title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',
++ episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };
++ if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [
++ { id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },
++ { id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },
++ ], next: null, sources: { vods: 'ok', clips: 'ok' }, partial: false }) };
++ if (u.includes('/api/v1/apps/gallery')) return { ok: true, json: async () => ({ spaces: [{
++ id: 'spc_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'A shared map', description: 'A map of places',
++ owner_name: 'Alex', url: 'https://map.openvibe.website', published_at: Date.now() - 60_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) },
] }) };
++ if (u.includes('/achievements/feed')) return { ok: true, json: async () => ({ items: [
++ { player: eventOwner, title: 'scraplandia', title_name: 'Scraplandia', id: 'first-haul', name: 'First Haul', description: 'Find your first haul', unlocked_at: badgeAt },
++ ], next: null }) };
return { ok: false, status: 404, json: async () => ({}) };
};
@@@ -45,11 +68,16 @@ 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['play-achievements'].items, 1);
assert.strictEqual(by['meme-entries'].items, 1);
++ assert.strictEqual(by['public-pictures'].items, 2);
++ assert.strictEqual(by['rundown-episodes'].items, 1);
++ assert.strictEqual(by['public-content'].items, 2);
++ assert.strictEqual(by['public-spaces'].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');
-- assert.strictEqual(ev.text, 'Come along');
++ assert.strictEqual(ev.title, 'Coming up: Launch party');
++ assert.match(ev.text, /^Starts .* UTC · Come along$/);
assert.strictEqual(ev.actor.name, 'Alex');
const members = feed.items.filter((x) => x.kind === 'member');
assert.deepStrictEqual(members.map((x) => x.title).sort(), [`3 people joined on ${yesterday}`, '5 people joined today']);
@@@ -57,10 +85,21 @@ 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');
++ assert.ok(feed.items.some((x) => x.kind === 'game' && x.title === 'Unlocked First Haul in Scraplandia'));
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');
++ const episode = feed.items.find((x) => x.kind === 'episode');
++ assert.strictEqual(episode.title, 'Rundown: The night sky');
++ assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');
++ assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));
++ assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));
++ assert.ok(feed.items.some((x) => x.kind === 'space' && x.title === 'A shared map' && x.actor.name === 'Alex'));
++ const photos = feed.items.find((x) => x.site === 'pics');
++ assert.strictEqual(photos.title, 'Alex added 2 photos');
++ assert.strictEqual(photos.group.count, 2);
++ assert.ok(!feed.items.some((x) => x.title.includes('Unlisted photo')));
assert.ok(!feed.items.some((x) => /Cancelled|Private|Old Badge/.test(x.title)), feed.items.map((x) => x.title).join(' | '));
});
@@@ -68,8 +107,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(), ['meme-entries', 'network-members', 'quest-badges', 'today-events']);
-- assert.strictEqual(status.runs.length, 4);
++ assert.deepStrictEqual(status.sources.sort(), ['meme-entries', 'network-members', 'play-achievements', 'public-content', 'public-pictures', 'public-spaces', 'quest-badges', 'rundown-episodes', 'today-events']);
++ assert.strictEqual(status.runs.length, 9);
assert.ok(status.runs.every((r) => r.next_run_at && r.last_run_at && !r.last_error));
});
@@@ -80,6 +119,13 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
assert.strictEqual(calls.length, before, 'reading the Feed made no external call');
});
++ await check('an unpublished picture leaves the feed after the gallery refresh', async () => {
++ galleryRows = galleryRows.slice(0, 1);
++ await t.app.locals.pulseSources.runOnce({ force: true });
++ const pictures = (await t.get('/api/v1/feed?site=pics')).json().items;
++ assert.deepStrictEqual(pictures.map((x) => x.title), ['First photo']);
++ });
++
await check('a re-record that sends no text keeps the card\'s text; one that sends text replaces it', async () => {
const store = require('../server/pulse/store');
const key = { source_service: 'today', source_type: 'event', source_id: 'tev_keep', url: 'https://openvibe.today/e/tev_keep', origin: 'system', occurred_at: '2026-11-09T10:00:00Z' };
ddiff --git a/test/pulse.test.js b/test/pulse.test.js
iindex 6b23b3f..d865496 100644
---- a/test/pulse.test.js
++++ b/test/pulse.test.js
@@@ -198,6 +198,25 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(relative.includes('/data/avatars/alex.png"') && !relative.includes('/data/avatars/alex.png?w='), 'a non-Media avatar is used unchanged');
});
++ await check('followers-only items reach the author and followers, never anonymous readers or strangers', async () => {
++ const sam = net.addUser({ network_user_id: 19, username: 'sam', display_name: 'Sam' });
++ const lee = net.addUser({ network_user_id: 20, username: 'lee', display_name: 'Lee' });
++ const samJwt = net.sign({ id: 19, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const leeJwt = net.sign({ id: 20, subject_id: lee.subject_id, username: 'lee', display_name: 'Lee', role: 'user' });
++ net.addFollow(sam.subject_id, 'channel', alex.subject_id);
++ const body = { ref: { service: 'live', type: 'post', id: 'followers-clip' }, title: 'For followers',
++ url: 'https://openvibe.live/clip/followers-clip', visibility: 'followers' };
++ assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: body })).json().code, 'pulse.followers_subject_required');
++ assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body })).status, 201);
++ const titles = async (path, cookie) => (await t.get(path, cookie ? { cookies: [`ov_token=${cookie}`] } : {})).json().items.map((x) => x.title);
++ for (const path of ['/api/v1/pulse', '/api/v1/feed']) {
++ assert.ok(!(await titles(path)).includes('For followers'));
++ assert.ok(!(await titles(path, leeJwt)).includes('For followers'));
++ assert.ok((await titles(path, samJwt)).includes('For followers'));
++ assert.ok((await titles(path, alexJwt)).includes('For followers'));
++ }
++ });
++
await t.close();
done();
})();