openvibers/openvibe.community Commits
Add feed search and examples (#366)
Changes
nnew file mode 100644@@@ -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++);@@@ -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) => {@@@ -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;@@@ -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' }) } }),@@@ -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 }); }@@@ -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://openvmark([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 };@@@ -85,6 +85,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.ftitle: 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.fif (!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.flet 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.fsource_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@@@ -27,8 +27,8 @@ const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().sconst 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, nowconst 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, nowreturn 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, nowreturn 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 }) {@@@ -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 iWHERE (@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::integerOR (${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, followeAND 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();@@@ -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 };@@@ -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)',},@@@ -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().reassert.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().reassert.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().reassert.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`);}});@@@ -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);@@@ -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' };@@@ -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();})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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
+);
@@ -800,14 +800,16 @@ async function createApp(opts = {}) {
@@ -800,14 +800,16 @@ async function createApp(opts = {}) {
const feedTools = require('./pulse/feed');
const feedTools = require('./pulse/feed');
const feedLiveNow = feedTools.createLiveNow({ liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });
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) => {
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 cursor = req.query.cursor ? String(req.query.cursor) : '';
const live = await feedLiveNow();
const live = await feedLiveNow();
const items = [];
const items = [];
+ const seen = [];
let after = cursor;
let after = cursor;
let nextCursor = null;
let nextCursor = null;
for (let pages = 0; pages < 8 && items.length < 12; pages++) {
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 || [];
const rows = out.items || [];
for (let index = 0; index < rows.length; index++) {
for (let index = 0; index < rows.length; index++) {
const item = rows[index];
const item = rows[index];
@@ -816,7 +818,8 @@ async function createApp(opts = {}) {
@@ -816,7 +818,8 @@ async function createApp(opts = {}) {
card.live_now = live.has(feedTools.channelOf(card.url).toLowerCase());
card.live_now = live.has(feedTools.channelOf(card.url).toLowerCase());
if (!card.live_now) card.kind = 'stream';
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) {
if (items.length >= 12) {
nextCursor = index < rows.length - 1
nextCursor = index < rows.length - 1
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
@@ -828,7 +831,7 @@ async function createApp(opts = {}) {
@@ -828,7 +831,7 @@ async function createApp(opts = {}) {
after = out.next_cursor;
after = out.next_cursor;
nextCursor = after;
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) => {
app.get('/my', withUser, wrap(async (req, res) => {
@@ -40,6 +40,7 @@ const EXPORTS = [
@@ -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'],
['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'],
['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'],
['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) {
async function exportPart(db, subject) {
@@ -49,7 +50,7 @@ async function exportPart(db, subject) {
@@ -49,7 +50,7 @@ async function exportPart(db, subject) {
if (!await hasColumn(db, table, col)) continue;
if (!await hasColumn(db, table, col)) continue;
// Newest first: by id where the table has one, else by its timestamp.
// 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'
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);
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) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
if (rows.length > ROW_LIMIT) truncated.push(name);
@@ -178,7 +179,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -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);
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);
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'],
['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']]) {
['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;
if (!await hasTable(db, table)) continue;
@@ -374,7 +374,7 @@ route('post', '/api/v1/pulse/items', {
@@ -374,7 +374,7 @@ route('post', '/api/v1/pulse/items', {
} }, { type: 'object', required: ['ref', 'title', 'url'], properties: {
} }, { type: 'object', required: ['ref', 'title', 'url'], properties: {
ref: { type: 'object', required: ['service', 'type', 'id'], properties: { service: str(), type: str(), id: str() } },
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'] },
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 } } }),
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() },
201: json('Created', { type: 'object', properties: { item: ref('PulseItem'), created: { const: true } } }), ...errors() },
@@ -389,17 +389,27 @@ route('get', '/api/v1/feed', {
@@ -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,
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.',
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: [
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('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('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 })],
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: {
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: {
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' }),
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') } }) } }),
actor: nullable('object', { properties: { name: nullable('string'), avatar: nullable('string') } }) } }),
next_cursor: nullable('string') } }), ...errors([400, 401, 429]) },
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', {
route('post', '/api/v1/me/status', {
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
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' }) } }),
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
@@ -39,7 +39,7 @@ const store = require('./store');
@@ -39,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', '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_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
const GAME_URL = 'https://openvibe.games/';
@@ -109,6 +109,31 @@ function gameProgressOf(event) {
@@ -109,6 +109,31 @@ function gameProgressOf(event) {
return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };
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 */
/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
async function applyGameProgress(db, g) {
async function applyGameProgress(db, g) {
const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
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 =
@@ -210,6 +235,17 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
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') {
if (event.event_type === 'chat.room.message.created') {
const c = chatMessageOf(event);
const c = chatMessageOf(event);
if (typeof c === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: c }); }
if (typeof c === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: c }); }
@@ -22,8 +22,8 @@ const express = require('express');
@@ -22,8 +22,8 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');
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 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 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);
const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -33,6 +33,13 @@ function kindOf(i) {
@@ -33,6 +33,13 @@ function kindOf(i) {
const s = String((i.source && i.source.service) || '');
const s = String((i.source && i.source.service) || '');
const t = String((i.source && i.source.type) || '');
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 (/^(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 (t === 'clip' || t === 'moment') return 'clip';
if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
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';
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
@@ -49,7 +56,7 @@ function kindOf(i) {
@@ -49,7 +56,7 @@ function kindOf(i) {
function card(i) {
function card(i) {
const site = String((i.source && i.source.service) || 'community');
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 {
return {
id: `p:${i.id}`,
id: `p:${i.id}`,
kind: kindOf(i),
kind: kindOf(i),
@@ -57,11 +64,13 @@ function card(i) {
@@ -57,11 +64,13 @@ function card(i) {
title: clean(i.title, 300),
title: clean(i.title, 300),
text: clean(i.text, 280),
text: clean(i.text, 280),
at: isoTime(i.occurred_at) || i.occurred_at || null,
at: isoTime(i.occurred_at) || i.occurred_at || null,
+ ...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),
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), ...(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,
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.visibility === 'followers' ? { visibility: 'followers' } : {}),
...(i.label ? { label: i.label } : {}),
...(i.label ? { label: i.label } : {}),
};
};
}
}
@@ -69,6 +78,25 @@ function card(i) {
@@ -69,6 +78,25 @@ function card(i) {
const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');
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 typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');
const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');
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). */
/** Community's own views, likes and comments, in a few batched reads (never one query per card). */
async function countsFor(db, rows) {
async function countsFor(db, rows) {
@@ -159,6 +187,32 @@ function createLiveNow({ liveUrl, fetchImpl }) {
@@ -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 ''; } };
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 }) {
function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
@@ -202,7 +256,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -202,7 +256,9 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
mark([c], live);
mark([c], live);
if (kind && c.kind !== kind) continue;
if (kind && c.kind !== kind) continue;
if (site && c.site !== site) 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 });
picked.push({ row: i, card: c });
if (picked.length >= pulseWant) {
if (picked.length >= pulseWant) {
// Resume after the last consumed row, including when Pulse's final page was only partly read.
// 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
@@ -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.
// The first page carries trending pictures and videos, one after every three Pulse items.
const items = [];
const items = [];
let k = 0;
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 };
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 }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow };
+module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, searchMatches, searchExamples, foldBursts };
@@ -85,6 +85,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -85,6 +85,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
title: i.title,
title: i.title,
url: i.url,
url: i.url,
origin: i.origin,
origin: i.origin,
+ ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
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 } : {}),
@@ -92,6 +93,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -92,6 +93,8 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
...(i.media_length ? { media_length: i.media_length } : {}),
...(i.media_length ? { media_length: i.media_length } : {}),
...(i.text ? { text: i.text } : {}),
...(i.text ? { text: i.text } : {}),
+ ...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),
+ ...(i.actor_name ? { actor_name: i.actor_name } : {}),
actor,
actor,
occurred_at: isoTime(i.occurred_at),
occurred_at: isoTime(i.occurred_at),
};
};
@@ -151,7 +154,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -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}');
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);
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 (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();
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)');
if (!title || title.length > 300) fail(400, 'pulse.invalid_title', 'title is required (at most 300 characters)');
const url = String(body.url || '');
const url = String(body.url || '');
@@ -161,6 +164,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -161,6 +164,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
let origin = body.origin == null ? v.origin : body.origin;
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 (!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 (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);
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 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
@@ -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,
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), 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]);
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 };
@@ -250,9 +254,9 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -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]))) {
|| ![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');
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 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 projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
const last = rows[rows.length - 1];
const last = rows[rows.length - 1];
return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last
return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last
@@ -27,8 +27,8 @@ const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().s
@@ -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 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 subject = (v) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(v || '')) ? String(v) : null);
const sqlFrom = (v, fallback) => {
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);
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -40,6 +40,10 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -40,6 +40,10 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
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(/\/+$/, '');
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 timer = null;
let running = null;
let running = null;
@@ -52,21 +56,24 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -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. */
/** OpenVibe.Today's public calendar → one event row per scheduled stream or event. */
async function todayEvents() {
async function todayEvents() {
const at = now();
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 to = new Date(at + 60 * DAY).toISOString();
const out = await getJson(`${todayApi}/api/v1/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=100`);
const out = await getJson(`${todayApi}/api/v1/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=100`);
let n = 0;
let n = 0;
for (const e of (Array.isArray(out.events) ? out.events : [])) {
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 || typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || !e.title.trim()) continue;
if (e.visibility !== 'public' || e.status === 'cancelled') 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 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 when = sqlFrom(e.created_at, sqlTime(at));
const out2 = await store.upsertItem(db, {
const out2 = await store.upsertItem(db, {
source_service: 'today', source_type: 'event', source_id: e.id,
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)}`,
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++;
if (out2.created) n++;
}
}
@@ -150,6 +157,39 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -150,6 +157,39 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return n;
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. */
/** Meme's public entries → one Feed card each; the API excludes hidden and unlisted entries. */
async function memeEntries() {
async function memeEntries() {
let cursor = null, created = 0;
let cursor = null, created = 0;
@@ -176,11 +216,150 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -176,11 +216,150 @@ function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now
return created;
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 = [
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: 'play-achievements', every: 15 * MINUTE, run: playAchievements },
{ name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },
{ 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 }) {
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 = ?, 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 {
} 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 };
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 };
return { item: await find.get(...key), created: true };
});
});
}
}
@@ -114,11 +114,16 @@ async function removeItem(db, service, type, id) {
@@ -114,11 +114,16 @@ async function removeItem(db, service, type, id) {
* opts.before [occurred_at, id] of the last item already seen
* opts.before [occurred_at, id] of the last item already seen
* → { rows, hasMore }
* → { 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 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(`
const rows = await db.prepare(`
SELECT i.*, ${priority} AS feed_priority FROM pulse_items i
SELECT i.*, ${priority} AS feed_priority FROM pulse_items i
WHERE (@origin::text IS NULL OR i.origin = @origin)
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
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))))
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 (
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
@@ -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))
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')))
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
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 });
priority: before ? before[0] : null, at: before ? before[1] : null, id: before ? before[2] : null, limit: limit + 1 });
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
if (hasMore) rows.pop();
@@ -5,18 +5,20 @@ const { renderPage } = require('./layout');
@@ -5,18 +5,20 @@ const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { card } = require('../pulse/feed');
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 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>
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>
<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>
<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 })) }] : [];
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 };
module.exports = { feedPage };
@@ -26,6 +26,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -26,6 +26,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Community applied',
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_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_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)',
share_access_requests: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
},
},
@@ -38,6 +38,13 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -38,6 +38,13 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
(async () => {
(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 } } });
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 net = t.network;
const db = t.app.locals.db;
const db = t.app.locals.db;
const pulse = t.app.locals.pulse;
const pulse = t.app.locals.pulse;
@@ -67,14 +74,18 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -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],
['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],
['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],
['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],
['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) {
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)
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', ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?)`)
.run(service, type, id, title, url, actor, origin, at(minsAgo), type === 'event' ? 'Come along' : '', null);
.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.
// 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);
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
@@ -85,14 +96,18 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const j = r.json();
const j = r.json();
const kinds = new Set(j.items.map((x) => x.kind));
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);
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`);
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, '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 });
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 () => {
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
@@ -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('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('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('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 () => {
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 j = (await t.get('/api/v1/feed?limit=40')).json();
const p = j.items.find((x) => x.title === 'Public one');
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.text, 'The first line of a public paste.');
+ assert.strictEqual(p.kind, 'paste');
assert.deepStrictEqual(p.counts, { views: 7, likes: 3, comments: 2 });
assert.deepStrictEqual(p.counts, { views: 7, likes: 3, comments: 2 });
const s = j.items.find((x) => x.title === 'A shot');
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);
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
@@ -189,12 +207,12 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const page = r.json();
const page = r.json();
assert.ok(page.items.length <= limit, 'trending counts toward the page limit');
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;
cursor = page.next_cursor;
if (cursor) { assert.ok(!cursors.has(cursor), 'cursor advances'); cursors.add(cursor); }
if (cursor) { assert.ok(!cursors.has(cursor), 'cursor advances'); cursors.add(cursor); }
assert.ok(cursors.size < 40, 'pagination terminates');
assert.ok(cursors.size < 40, 'pagination terminates');
} while (cursor);
} 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`);
}
}
});
});
@@ -184,6 +184,18 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -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));
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');
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();
srv.close();
console.log('pulse consumer: all checks passed');
console.log('pulse consumer: all checks passed');
process.exit(0);
process.exit(0);
@@ -14,6 +14,14 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -14,6 +14,14 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
const yesterday = day(Date.now() - 86_400_000);
const yesterday = day(Date.now() - 86_400_000);
const badgeAt = iso(Date.now() - 3_600_000);
const badgeAt = iso(Date.now() - 3_600_000);
const calls = [];
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;
let eventOwner = null;
const sourcesFetch = async (url) => {
const sourcesFetch = async (url) => {
calls.push(String(url));
calls.push(String(url));
@@ -25,10 +33,25 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -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/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/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: [
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) },
] }) };
] }) };
+ 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 () => ({}) };
return { ok: false, status: 404, json: async () => ({}) };
};
};
@@ -45,11 +68,16 @@ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -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['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['play-achievements'].items, 1);
assert.strictEqual(by['meme-entries'].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 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.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');
assert.strictEqual(ev.actor.name, 'Alex');
const members = feed.items.filter((x) => x.kind === 'member');
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']);
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);
@@ -57,10 +85,21 @@ 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');
+ 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');
const meme = feed.items.find((x) => x.kind === 'meme');
assert.strictEqual(meme.title, 'Open source meme');
assert.strictEqual(meme.title, 'Open source meme');
assert.strictEqual(meme.text, 'A public entry');
assert.strictEqual(meme.text, 'A public entry');
assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');
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(' | '));
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);
@@ -68,8 +107,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(), ['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));
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);
@@ -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');
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 () => {
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 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' };
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' };
@@ -198,6 +198,25 @@ const { boot, check, done } = require('./helpers/app');
@@ -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');
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();
await t.close();
done();
done();
})();
})();