openvibers/openvibe.community Commits
The Feed on every site: one stream from Pulse, with trending pictures (#307)
Changes
@@@ -309,6 +309,8 @@ async function createApp(opts = {}) {const cors = v1.cors(config.apiCorsOrigins);app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));++ // The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).++ app.use('/api/v1/feed', cors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.@@@ -316,6 +316,18 @@ route('post', '/api/v1/comments/{commentId}/votes', {responses: { 200: json('The comment and its score'), ...errors() },});++// ── /api/v1/feed — the menu's Feed on every site (server/pulse/feed.js) ──────────────────────++route('get', '/api/v1/feed', {++ operationId: 'listFeed', summary: 'One stream of everything public on the network, as cards by kind', tags: ['pulse'], security: ANONYMOUS,++ description: 'Pulse items as cards (live, clip, video, pic, post, meme, game, update), newest first, with the network\'s trending pictures and videos woven into the first page.',++ parameters: [++ query('kind', { type: 'string', enum: ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'] }),++ query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40 })],++ responses: { 200: json('Cards and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object', properties: {++ id: str(), kind: str(), url: str(), title: str(), text: str(), at: nullable('string'), site: str(), site_name: str(), label: str(),++ actor: { type: ['object', 'null'] }, media: { type: ['object', 'null'] }, counts: { type: 'object' } } }), next_cursor: nullable('string') } }), ...errors([429]) },++});++// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────route('get', '/api/v1/pulse', {operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++++/**++ * GET /api/v1/feed — the menu's Feed on every OpenVibe site (openvibe-shared ov-home.js feed(): "a real feed… you scroll++ * through easy content… a filter system", the owner, 11-09). One stream, newest first, built from Pulse (every public++ * thing that happens on the network: posts, pastes, statuses, threads, a stream going live, a clip, a video, a picture,++ * a meme, an update…) with the network's trending pictures and videos (Network's Frame cards) woven into the first page.++ *++ * GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)++ * → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,++ * media: { thumb, ratio, length } | null, counts: {} }], next_cursor }++ *++ * Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { run } = require('../http/v1');++++const KINDS = ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'];++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' };++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);++++/** What a Pulse item is, as a card. */++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 === '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';++ if (s === 'meme' || t === 'meme') return 'meme';++ if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';++ if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';++ return 'post';++}++++function card(i) {++ const site = String((i.source && i.source.service) || 'community');++ const a = i.actor;++ return {++ id: `p:${i.id}`,++ kind: kindOf(i),++ url: https(i.url) || i.url,++ title: clean(i.title, 300),++ text: '',++ at: i.occurred_at || null,++ site,++ site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),++ actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,++ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ratio: 16 / 9 } : null,++ counts: {},++ ...(i.label ? { label: i.label } : {}),++ };++}++++/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */++function createTrending({ networkUrl, fetchImpl }) {++ let cache = null; let at = 0;++ const SITE_KIND = { 'media-hub': 'video', video: 'video', live: 'live', pics: 'pic', meme: 'meme', games: 'game' };++ return async function trending() {++ if (cache && Date.now() - at < 5 * 60_000) return cache;++ try {++ const r = await fetchImpl(`${networkUrl}/api/frame?v=2`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });++ const f = r.ok ? await r.json() : null;++ const out = [];++ for (const c of (f && Array.isArray(f.cards) ? f.cards : [])) {++ const kind = SITE_KIND[c && c.site];++ if (!kind || !Array.isArray(c.items)) continue;++ for (const x of c.items.slice(0, 4)) {++ const url = https(x && x.url); const thumb = https(x && x.image && x.image.src);++ if (!url || !thumb || !x.title) continue;++ out.push({ id: `t:${c.site}:${url}`, kind, url, title: clean(x.title, 200), text: clean(x.meta, 160), at: null, site: c.site,++ site_name: SITE_NAMES[c.site] || c.site, actor: null, media: { thumb, ratio: 16 / 9 }, counts: {}, label: 'Trending' });++ }++ }++ cache = out; at = Date.now();++ } catch { cache = cache || []; }++ return cache;++ };++}++++function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', fetchImpl = globalThis.fetch }) {++ const router = express.Router();++ router.use(contracts.http.middleware());++ router.use(viewers.middleware());++ router.use(limits.reads('community.feed.read'));++ const trending = createTrending({ networkUrl, fetchImpl });++++ router.get('/', run(async (req) => {++ const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';++ const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);++ const items = [];++ let cursor = req.query.cursor ? String(req.query.cursor) : '';++ let next = null;++ // A kind filters Pulse's pages until the page is full (at most four pages read per call).++ for (let pages = 0; pages < 4 && items.length < want; pages++) {++ const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);++ for (const i of page.items || []) { const c = card(i); if (!kind || c.kind === kind) items.push(c); if (items.length >= want) break; }++ next = page.next_cursor || null;++ if (!next) break;++ cursor = next;++ }++ // The first page carries the network's trending pictures and videos, one after every three items.++ if (!req.query.cursor) {++ const t = (await trending()).filter((x) => !kind || x.kind === kind);++ if (t.length) {++ const woven = [];++ let k = 0;++ items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });++ while (woven.length < want && k < t.length) woven.push(t[k++]);++ return { items: woven, next_cursor: next };++ }++ }++ return { items, next_cursor: next };++ }));++ 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 };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * GET /api/v1/feed (the menu's Feed on every site): one stream from Pulse, newest first, as cards by kind (a paste or a++ * thread is a post, a Live clip a clip), a kind filter, the network's trending pictures woven into the first page (from++ * Network's Frame, https and pictures only), and the shape openvibe-shared's ov-home.js feed() draws.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++const FRAME = { cards: [++ { site: 'media-hub', title: 'Trending', items: [++ { title: 'A trending video', url: 'https://openvibe.video/v/1', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },++ { title: 'No picture', url: 'https://openvibe.video/v/2' },++ { title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },++ ] },++ { site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },++] };++const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json: async () => FRAME });++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };++ const pastes = t.app.locals.pastes;++ const svc = net.signService({ cap: ['community.pulse.write'] });++++ await pastes.createText(asAlex, { title: 'Public one', content: 'x' });++ const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },++ body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' }) });++ assert.strictEqual(clip.status, 201, clip.text);++++ await check('the feed: newest first, cards by kind, the shape the menu draws', async () => {++ const r = await t.get('/api/v1/feed');++ assert.strictEqual(r.status, 200, r.text);++ const j = r.json();++ const kinds = j.items.map((x) => x.kind);++ assert.ok(kinds.includes('clip') && kinds.includes('post'), `a clip and a post (${kinds})`);++ const c = j.items.find((x) => x.kind === 'clip');++ assert.strictEqual(c.title, 'Great clip');++ assert.strictEqual(c.site, 'live');++ assert.strictEqual(c.site_name, 'Live');++ assert.strictEqual(c.actor.name, 'Alex');++ assert.ok('next_cursor' in j);++ for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'site', 'site_name', 'counts']) assert.ok(k in x, `${k} on every card`);++ });++++ await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {++ const j = (await t.get('/api/v1/feed')).json();++ const tr = j.items.filter((x) => x.label === 'Trending');++ assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'], 'only the https one with a picture');++ assert.strictEqual(tr[0].kind, 'video');++ assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');++ });++++ await check('a kind filters the stream', async () => {++ const j = (await t.get('/api/v1/feed?kind=clip')).json();++ assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));++ const none = (await t.get('/api/v1/feed?kind=meme')).json();++ assert.deepStrictEqual(none.items, []);++ });++++ await check('cross-site: answers any OpenVibe site (CORS)', async () => {++ const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });++ assert.strictEqual(r.status, 200);++ });++++ await done(t);++})();@@@ -35,6 +35,7 @@ const INVENTORY = {'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],++ 'server/pulse/feed.js': [1, 'Network /api/frame at the configured networkUrl (trending cards), no user input in the URL'],'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
@@ -309,6 +309,8 @@ async function createApp(opts = {}) {
@@ -309,6 +309,8 @@ async function createApp(opts = {}) {
const cors = v1.cors(config.apiCorsOrigins);
const cors = v1.cors(config.apiCorsOrigins);
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));
+ // The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).
+ app.use('/api/v1/feed', cors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
@@ -316,6 +316,18 @@ route('post', '/api/v1/comments/{commentId}/votes', {
@@ -316,6 +316,18 @@ route('post', '/api/v1/comments/{commentId}/votes', {
responses: { 200: json('The comment and its score'), ...errors() },
responses: { 200: json('The comment and its score'), ...errors() },
});
});
+// ── /api/v1/feed — the menu's Feed on every site (server/pulse/feed.js) ──────────────────────
+route('get', '/api/v1/feed', {
+ operationId: 'listFeed', summary: 'One stream of everything public on the network, as cards by kind', tags: ['pulse'], security: ANONYMOUS,
+ description: 'Pulse items as cards (live, clip, video, pic, post, meme, game, update), newest first, with the network\'s trending pictures and videos woven into the first page.',
+ parameters: [
+ query('kind', { type: 'string', enum: ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'] }),
+ query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40 })],
+ responses: { 200: json('Cards and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object', properties: {
+ id: str(), kind: str(), url: str(), title: str(), text: str(), at: nullable('string'), site: str(), site_name: str(), label: str(),
+ actor: { type: ['object', 'null'] }, media: { type: ['object', 'null'] }, counts: { type: 'object' } } }), next_cursor: nullable('string') } }), ...errors([429]) },
+});
+
// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────
// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────
route('get', '/api/v1/pulse', {
route('get', '/api/v1/pulse', {
operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [
operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+
+/**
+ * GET /api/v1/feed — the menu's Feed on every OpenVibe site (openvibe-shared ov-home.js feed(): "a real feed… you scroll
+ * through easy content… a filter system", the owner, 11-09). One stream, newest first, built from Pulse (every public
+ * thing that happens on the network: posts, pastes, statuses, threads, a stream going live, a clip, a video, a picture,
+ * a meme, an update…) with the network's trending pictures and videos (Network's Frame cards) woven into the first page.
+ *
+ * GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)
+ * → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,
+ * media: { thumb, ratio, length } | null, counts: {} }], next_cursor }
+ *
+ * Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { run } = require('../http/v1');
+
+const KINDS = ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'];
+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' };
+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);
+
+/** What a Pulse item is, as a card. */
+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 === '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';
+ if (s === 'meme' || t === 'meme') return 'meme';
+ if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';
+ if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';
+ return 'post';
+}
+
+function card(i) {
+ const site = String((i.source && i.source.service) || 'community');
+ const a = i.actor;
+ return {
+ id: `p:${i.id}`,
+ kind: kindOf(i),
+ url: https(i.url) || i.url,
+ title: clean(i.title, 300),
+ text: '',
+ at: i.occurred_at || null,
+ site,
+ site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
+ actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
+ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ratio: 16 / 9 } : null,
+ counts: {},
+ ...(i.label ? { label: i.label } : {}),
+ };
+}
+
+/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */
+function createTrending({ networkUrl, fetchImpl }) {
+ let cache = null; let at = 0;
+ const SITE_KIND = { 'media-hub': 'video', video: 'video', live: 'live', pics: 'pic', meme: 'meme', games: 'game' };
+ return async function trending() {
+ if (cache && Date.now() - at < 5 * 60_000) return cache;
+ try {
+ const r = await fetchImpl(`${networkUrl}/api/frame?v=2`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
+ const f = r.ok ? await r.json() : null;
+ const out = [];
+ for (const c of (f && Array.isArray(f.cards) ? f.cards : [])) {
+ const kind = SITE_KIND[c && c.site];
+ if (!kind || !Array.isArray(c.items)) continue;
+ for (const x of c.items.slice(0, 4)) {
+ const url = https(x && x.url); const thumb = https(x && x.image && x.image.src);
+ if (!url || !thumb || !x.title) continue;
+ out.push({ id: `t:${c.site}:${url}`, kind, url, title: clean(x.title, 200), text: clean(x.meta, 160), at: null, site: c.site,
+ site_name: SITE_NAMES[c.site] || c.site, actor: null, media: { thumb, ratio: 16 / 9 }, counts: {}, label: 'Trending' });
+ }
+ }
+ cache = out; at = Date.now();
+ } catch { cache = cache || []; }
+ return cache;
+ };
+}
+
+function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', fetchImpl = globalThis.fetch }) {
+ const router = express.Router();
+ router.use(contracts.http.middleware());
+ router.use(viewers.middleware());
+ router.use(limits.reads('community.feed.read'));
+ const trending = createTrending({ networkUrl, fetchImpl });
+
+ router.get('/', run(async (req) => {
+ const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
+ const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
+ const items = [];
+ let cursor = req.query.cursor ? String(req.query.cursor) : '';
+ let next = null;
+ // A kind filters Pulse's pages until the page is full (at most four pages read per call).
+ for (let pages = 0; pages < 4 && items.length < want; pages++) {
+ const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
+ for (const i of page.items || []) { const c = card(i); if (!kind || c.kind === kind) items.push(c); if (items.length >= want) break; }
+ next = page.next_cursor || null;
+ if (!next) break;
+ cursor = next;
+ }
+ // The first page carries the network's trending pictures and videos, one after every three items.
+ if (!req.query.cursor) {
+ const t = (await trending()).filter((x) => !kind || x.kind === kind);
+ if (t.length) {
+ const woven = [];
+ let k = 0;
+ items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
+ while (woven.length < want && k < t.length) woven.push(t[k++]);
+ return { items: woven, next_cursor: next };
+ }
+ }
+ return { items, next_cursor: next };
+ }));
+ 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * GET /api/v1/feed (the menu's Feed on every site): one stream from Pulse, newest first, as cards by kind (a paste or a
+ * thread is a post, a Live clip a clip), a kind filter, the network's trending pictures woven into the first page (from
+ * Network's Frame, https and pictures only), and the shape openvibe-shared's ov-home.js feed() draws.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+const FRAME = { cards: [
+ { site: 'media-hub', title: 'Trending', items: [
+ { title: 'A trending video', url: 'https://openvibe.video/v/1', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },
+ { title: 'No picture', url: 'https://openvibe.video/v/2' },
+ { title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },
+ ] },
+ { site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },
+] };
+const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json: async () => FRAME });
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
+ const pastes = t.app.locals.pastes;
+ const svc = net.signService({ cap: ['community.pulse.write'] });
+
+ await pastes.createText(asAlex, { title: 'Public one', content: 'x' });
+ const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },
+ body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' }) });
+ assert.strictEqual(clip.status, 201, clip.text);
+
+ await check('the feed: newest first, cards by kind, the shape the menu draws', async () => {
+ const r = await t.get('/api/v1/feed');
+ assert.strictEqual(r.status, 200, r.text);
+ const j = r.json();
+ const kinds = j.items.map((x) => x.kind);
+ assert.ok(kinds.includes('clip') && kinds.includes('post'), `a clip and a post (${kinds})`);
+ const c = j.items.find((x) => x.kind === 'clip');
+ assert.strictEqual(c.title, 'Great clip');
+ assert.strictEqual(c.site, 'live');
+ assert.strictEqual(c.site_name, 'Live');
+ assert.strictEqual(c.actor.name, 'Alex');
+ assert.ok('next_cursor' in j);
+ for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'site', 'site_name', 'counts']) assert.ok(k in x, `${k} on every card`);
+ });
+
+ await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {
+ const j = (await t.get('/api/v1/feed')).json();
+ const tr = j.items.filter((x) => x.label === 'Trending');
+ assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'], 'only the https one with a picture');
+ assert.strictEqual(tr[0].kind, 'video');
+ assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
+ });
+
+ await check('a kind filters the stream', async () => {
+ const j = (await t.get('/api/v1/feed?kind=clip')).json();
+ assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));
+ const none = (await t.get('/api/v1/feed?kind=meme')).json();
+ assert.deepStrictEqual(none.items, []);
+ });
+
+ await check('cross-site: answers any OpenVibe site (CORS)', async () => {
+ const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
+ assert.strictEqual(r.status, 200);
+ });
+
+ await done(t);
+})();
@@ -35,6 +35,7 @@ const INVENTORY = {
@@ -35,6 +35,7 @@ const INVENTORY = {
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
+ 'server/pulse/feed.js': [1, 'Network /api/frame at the configured networkUrl (trending cards), no user input in the URL'],
'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],