openvibers/openvibe.network Commits
Feed: game level-ups, one card per poll, AI labels and one person (#875)
Changes
@@@ -92,6 +92,9 @@ function cardOf(row, actor = null) {};if (row.starts_at) card.starts_at = new Date(row.starts_at).toISOString();if (row.label) card.label = row.label;++ // The menu labels AI output and system/aggregate rows; keep the old Pulse's promise that AI is never a person.++ if (!card.label && row.origin === 'ai') card.label = 'AI';++ else if (!card.label && row.origin === 'system' && !card.actor) card.label = 'System';if (row.visibility === 'followers') card.visibility = 'followers';if (card.kind === 'live' || card.kind === 'stream') {card.live_now = row.kind === 'live' && row.live_now === true;@@@ -111,6 +114,8 @@ function cardOf(row, actor = null) {const BURST_MS = 15 * 60_000;const BURST_WORD = { pic: ['photo', 'photos'], clip: ['clip', 'clips'], video: ['video', 'videos'], game: ['achievement', 'achievements'], quest: ['quest', 'quests'], avatar: ['picture change', 'picture changes'], banner: ['banner change', 'banner changes'], paste: ['paste', 'pastes'], space: ['space', 'spaces'] };++// A poll can deliver forty of the same kind at once with no person behind them (deals, news): one card.++const BULK_WORD = { deal: ['deal', 'deals'], news: ['story', 'stories'], event: ['event', 'events'] };function titleKey(card) {return String(card.title || '').toLowerCase().replace(/\s+/g, ' ').trim();@@@ -137,7 +142,11 @@ function foldCards(cards, now = Date.now()) {const prior = BURST_WORD[card.kind] && who && Number.isFinite(at) && groups.find((g) => g.subject != null&& g.subject === who && g.site === card.site && g.card.kind === card.kind && Math.abs(g.at - at) <= BURST_MS);if (prior) { prior.cards.push(card); continue; }-- groups.push({ subject: who, at, site: card.site, card, cards: [card] });++ // No person behind a run (a Deals poll, a News import): the same kind and site in the window is one card.++ const bulk = !who && BULK_WORD[card.kind] && Number.isFinite(at) && groups.find((g) => g.bulk++ && g.card.kind === card.kind && g.site === card.site && Math.abs(g.at - at) <= BURST_MS);++ if (bulk) { bulk.cards.push(card); continue; }++ groups.push({ subject: who, at, site: card.site, card, cards: [card], bulk: !who && BULK_WORD[card.kind] ? true : undefined });}return groups.map((g) => {if (g.workHour !== undefined) {@@@ -155,6 +164,15 @@ function foldCards(cards, now = Date.now()) {};}if (g.cards.length < 2) return g.card;++ if (BULK_WORD[g.card.kind]) {++ const [one, many] = BULK_WORD[g.card.kind];++ return {++ ...g.card,++ title: `${g.cards.length} new ${g.cards.length === 1 ? one : many}`,++ group: { count: g.cards.length, items: g.cards.slice(0, 20).map((c) => ({ id: c.id, url: c.url, title: c.title, media: c.media })) },++ counts: { ...g.card.counts, people: g.cards.length },++ };++ }const [one, many] = BURST_WORD[g.card.kind];const name = (g.card.actor && g.card.actor.name) || 'Someone';return {@@@ -110,6 +110,16 @@ function eventToItems(event) {return { items: [item({ service: 'games', type: 'world', id: idOf(event, p, 'world_id'), kind: 'game',title: clean(p.title, 160) || 'A new world', text: p.description, url, media: mediaOf(p) })], remove: [] };}++ if (type === 'network.module.updated') {++ // The game-progress module (games.progress.summary): a public level becomes one game card per level.++ const ns = clean(p.namespace, 60);++ const level = Number(p.public && p.public.level);++ const who = userOf(p.owner) || subject;++ if (ns !== 'games.progress.summary' || !Number.isSafeInteger(level) || level < 1 || level > 100000 || !who) return { items: [], remove: [] };++ const game = clean(p.public && (p.public.title || p.public.game), 80) || 'an OpenVibe game';++ return { items: [item({ service: 'games', type: 'level', id: `${who}:${level}`, kind: 'game',++ title: `Reached level ${level} in ${game}`, url: 'https://openvibe.games/', at })], remove: [] };++ }if (type === 'games.skill.leveled') {const url = https(p.url) || 'https://openvibe.games/';const level = Number(p.level);@@@ -563,7 +563,7 @@ function createFeedSources({ db, config = {}, fetchImpl = globalThis.fetch, nowif (peak && Number(peak.peak) > 0) {const when = new Date(peak.peak_at).toISOString().slice(11, 16);const r = await upsert({ source_service: 'report', source_type: 'peak', source_id: `peak:${day}`, kind: 'report',-- title: `Peak today: ${Number(peak.peak).toLocaleString('en-US')} people online at ${when} UTC`,++ title: `Peak today: ${Number(peak.peak).toLocaleString('en-US')} ${Number(peak.peak) === 1 ? 'person' : 'people'} online at ${when} UTC`,text: 'The most people on OpenVibe at once today', url: `${urls.report}/network`, site: 'report', site_name: 'Report',actor_subject: null, origin: 'system', label: 'System', counts: { people: Number(peak.peak) },occurred_at: new Date(at).toISOString(), touch: true });@@@ -34,6 +34,10 @@ const envelope = (type, payload, over = {}) => ({assert.strictEqual(kindOf('mediahub.video.published', { video_id: 'v1', title: 'A video', canonical_url: 'https://openvibe.video/watch?v=v1' }), 'video');assert.strictEqual(kindOf('mediahub.picture.uploaded', { picture_id: 'p1', url: 'https://openvibe.media/p/1', thumb: 'https://openvibe.media/t/1.jpg' }), 'pic');assert.strictEqual(kindOf('games.world.published', { world_id: 'w1', title: 'A world', url: 'https://openvibe.games/w/1' }), 'game');++ {++ const g = eventToItems(envelope('network.module.updated', { namespace: 'games.progress.summary', change: 'updated', owner: { type: 'user', id: A }, public: { level: 15 } }, { source: 'network' }));++ assert.deepStrictEqual([g.items[0].kind, g.items[0].source_id, g.items[0].title], ['game', `${A}:15`, 'Reached level 15 in an OpenVibe game']);++ }assert.strictEqual(kindOf('blog.post.published', { post_id: 'b1', url: 'https://openvibe.blog/p/1', blog: { handle: 'openvibe' } }), 'update');assert.strictEqual(kindOf('wiki.page.published', { page_id: 'w1', url: 'https://openvibe.wiki/space/page', title: 'A page' }), 'wiki');assert.strictEqual(kindOf('news.story.published', { story_id: 's1', url: 'https://openvibe.news/s/1', topic: 'feeds' }), 'news');@@@ -55,6 +59,10 @@ const envelope = (type, payload, over = {}) => ({assert.strictEqual(jobs.length, 1);assert.strictEqual(jobs[0].title, '3 new jobs this hour');assert.strictEqual(jobs[0].group.count, 3);++ const deals = foldCards([1, 2, 3].map((n) => cardOf({ source_service: 'deals', source_type: 'offer', source_id: `d${n}`, kind: 'deal', title: `Deal ${n}`, url: 'https://openvibe.deals/d/x', site: 'deals', site_name: 'Deals', counts: {}, occurred_at: '2026-11-12T09:00:00.000Z' })));++ assert.strictEqual(deals.length, 1, 'a poll of deals is one card');++ assert.strictEqual(deals[0].title, '3 new deals');++ assert.strictEqual(deals[0].group.count, 3);// ── ranking: live, followed, fresh, then older than a week ─const live = cardOf({ source_service: 'live', source_type: 'stream', source_id: 'l', kind: 'live', title: 'Live', url: 'https://openvibe.live/@alex', site: 'live', site_name: 'Live', actor_subject: B, live_now: true, occurred_at: iso(now - 86400000) });
@@ -92,6 +92,9 @@ function cardOf(row, actor = null) {
@@ -92,6 +92,9 @@ function cardOf(row, actor = null) {
};
};
if (row.starts_at) card.starts_at = new Date(row.starts_at).toISOString();
if (row.starts_at) card.starts_at = new Date(row.starts_at).toISOString();
if (row.label) card.label = row.label;
if (row.label) card.label = row.label;
+ // The menu labels AI output and system/aggregate rows; keep the old Pulse's promise that AI is never a person.
+ if (!card.label && row.origin === 'ai') card.label = 'AI';
+ else if (!card.label && row.origin === 'system' && !card.actor) card.label = 'System';
if (row.visibility === 'followers') card.visibility = 'followers';
if (row.visibility === 'followers') card.visibility = 'followers';
if (card.kind === 'live' || card.kind === 'stream') {
if (card.kind === 'live' || card.kind === 'stream') {
card.live_now = row.kind === 'live' && row.live_now === true;
card.live_now = row.kind === 'live' && row.live_now === true;
@@ -111,6 +114,8 @@ function cardOf(row, actor = null) {
@@ -111,6 +114,8 @@ function cardOf(row, actor = null) {
const BURST_MS = 15 * 60_000;
const BURST_MS = 15 * 60_000;
const BURST_WORD = { pic: ['photo', 'photos'], clip: ['clip', 'clips'], video: ['video', 'videos'], game: ['achievement', 'achievements'], quest: ['quest', 'quests'], avatar: ['picture change', 'picture changes'], banner: ['banner change', 'banner changes'], paste: ['paste', 'pastes'], space: ['space', 'spaces'] };
const BURST_WORD = { pic: ['photo', 'photos'], clip: ['clip', 'clips'], video: ['video', 'videos'], game: ['achievement', 'achievements'], quest: ['quest', 'quests'], avatar: ['picture change', 'picture changes'], banner: ['banner change', 'banner changes'], paste: ['paste', 'pastes'], space: ['space', 'spaces'] };
+// A poll can deliver forty of the same kind at once with no person behind them (deals, news): one card.
+const BULK_WORD = { deal: ['deal', 'deals'], news: ['story', 'stories'], event: ['event', 'events'] };
function titleKey(card) {
function titleKey(card) {
return String(card.title || '').toLowerCase().replace(/\s+/g, ' ').trim();
return String(card.title || '').toLowerCase().replace(/\s+/g, ' ').trim();
@@ -137,7 +142,11 @@ function foldCards(cards, now = Date.now()) {
@@ -137,7 +142,11 @@ function foldCards(cards, now = Date.now()) {
const prior = BURST_WORD[card.kind] && who && Number.isFinite(at) && groups.find((g) => g.subject != null
const prior = BURST_WORD[card.kind] && who && Number.isFinite(at) && groups.find((g) => g.subject != null
&& g.subject === who && g.site === card.site && g.card.kind === card.kind && Math.abs(g.at - at) <= BURST_MS);
&& g.subject === who && g.site === card.site && g.card.kind === card.kind && Math.abs(g.at - at) <= BURST_MS);
if (prior) { prior.cards.push(card); continue; }
if (prior) { prior.cards.push(card); continue; }
- groups.push({ subject: who, at, site: card.site, card, cards: [card] });
+ // No person behind a run (a Deals poll, a News import): the same kind and site in the window is one card.
+ const bulk = !who && BULK_WORD[card.kind] && Number.isFinite(at) && groups.find((g) => g.bulk
+ && g.card.kind === card.kind && g.site === card.site && Math.abs(g.at - at) <= BURST_MS);
+ if (bulk) { bulk.cards.push(card); continue; }
+ groups.push({ subject: who, at, site: card.site, card, cards: [card], bulk: !who && BULK_WORD[card.kind] ? true : undefined });
}
}
return groups.map((g) => {
return groups.map((g) => {
if (g.workHour !== undefined) {
if (g.workHour !== undefined) {
@@ -155,6 +164,15 @@ function foldCards(cards, now = Date.now()) {
@@ -155,6 +164,15 @@ function foldCards(cards, now = Date.now()) {
};
};
}
}
if (g.cards.length < 2) return g.card;
if (g.cards.length < 2) return g.card;
+ if (BULK_WORD[g.card.kind]) {
+ const [one, many] = BULK_WORD[g.card.kind];
+ return {
+ ...g.card,
+ title: `${g.cards.length} new ${g.cards.length === 1 ? one : many}`,
+ group: { count: g.cards.length, items: g.cards.slice(0, 20).map((c) => ({ id: c.id, url: c.url, title: c.title, media: c.media })) },
+ counts: { ...g.card.counts, people: g.cards.length },
+ };
+ }
const [one, many] = BURST_WORD[g.card.kind];
const [one, many] = BURST_WORD[g.card.kind];
const name = (g.card.actor && g.card.actor.name) || 'Someone';
const name = (g.card.actor && g.card.actor.name) || 'Someone';
return {
return {
@@ -110,6 +110,16 @@ function eventToItems(event) {
@@ -110,6 +110,16 @@ function eventToItems(event) {
return { items: [item({ service: 'games', type: 'world', id: idOf(event, p, 'world_id'), kind: 'game',
return { items: [item({ service: 'games', type: 'world', id: idOf(event, p, 'world_id'), kind: 'game',
title: clean(p.title, 160) || 'A new world', text: p.description, url, media: mediaOf(p) })], remove: [] };
title: clean(p.title, 160) || 'A new world', text: p.description, url, media: mediaOf(p) })], remove: [] };
}
}
+ if (type === 'network.module.updated') {
+ // The game-progress module (games.progress.summary): a public level becomes one game card per level.
+ const ns = clean(p.namespace, 60);
+ const level = Number(p.public && p.public.level);
+ const who = userOf(p.owner) || subject;
+ if (ns !== 'games.progress.summary' || !Number.isSafeInteger(level) || level < 1 || level > 100000 || !who) return { items: [], remove: [] };
+ const game = clean(p.public && (p.public.title || p.public.game), 80) || 'an OpenVibe game';
+ return { items: [item({ service: 'games', type: 'level', id: `${who}:${level}`, kind: 'game',
+ title: `Reached level ${level} in ${game}`, url: 'https://openvibe.games/', at })], remove: [] };
+ }
if (type === 'games.skill.leveled') {
if (type === 'games.skill.leveled') {
const url = https(p.url) || 'https://openvibe.games/';
const url = https(p.url) || 'https://openvibe.games/';
const level = Number(p.level);
const level = Number(p.level);
@@ -563,7 +563,7 @@ function createFeedSources({ db, config = {}, fetchImpl = globalThis.fetch, now
@@ -563,7 +563,7 @@ function createFeedSources({ db, config = {}, fetchImpl = globalThis.fetch, now
if (peak && Number(peak.peak) > 0) {
if (peak && Number(peak.peak) > 0) {
const when = new Date(peak.peak_at).toISOString().slice(11, 16);
const when = new Date(peak.peak_at).toISOString().slice(11, 16);
const r = await upsert({ source_service: 'report', source_type: 'peak', source_id: `peak:${day}`, kind: 'report',
const r = await upsert({ source_service: 'report', source_type: 'peak', source_id: `peak:${day}`, kind: 'report',
- title: `Peak today: ${Number(peak.peak).toLocaleString('en-US')} people online at ${when} UTC`,
+ title: `Peak today: ${Number(peak.peak).toLocaleString('en-US')} ${Number(peak.peak) === 1 ? 'person' : 'people'} online at ${when} UTC`,
text: 'The most people on OpenVibe at once today', url: `${urls.report}/network`, site: 'report', site_name: 'Report',
text: 'The most people on OpenVibe at once today', url: `${urls.report}/network`, site: 'report', site_name: 'Report',
actor_subject: null, origin: 'system', label: 'System', counts: { people: Number(peak.peak) },
actor_subject: null, origin: 'system', label: 'System', counts: { people: Number(peak.peak) },
occurred_at: new Date(at).toISOString(), touch: true });
occurred_at: new Date(at).toISOString(), touch: true });
@@ -34,6 +34,10 @@ const envelope = (type, payload, over = {}) => ({
@@ -34,6 +34,10 @@ const envelope = (type, payload, over = {}) => ({
assert.strictEqual(kindOf('mediahub.video.published', { video_id: 'v1', title: 'A video', canonical_url: 'https://openvibe.video/watch?v=v1' }), 'video');
assert.strictEqual(kindOf('mediahub.video.published', { video_id: 'v1', title: 'A video', canonical_url: 'https://openvibe.video/watch?v=v1' }), 'video');
assert.strictEqual(kindOf('mediahub.picture.uploaded', { picture_id: 'p1', url: 'https://openvibe.media/p/1', thumb: 'https://openvibe.media/t/1.jpg' }), 'pic');
assert.strictEqual(kindOf('mediahub.picture.uploaded', { picture_id: 'p1', url: 'https://openvibe.media/p/1', thumb: 'https://openvibe.media/t/1.jpg' }), 'pic');
assert.strictEqual(kindOf('games.world.published', { world_id: 'w1', title: 'A world', url: 'https://openvibe.games/w/1' }), 'game');
assert.strictEqual(kindOf('games.world.published', { world_id: 'w1', title: 'A world', url: 'https://openvibe.games/w/1' }), 'game');
+ {
+ const g = eventToItems(envelope('network.module.updated', { namespace: 'games.progress.summary', change: 'updated', owner: { type: 'user', id: A }, public: { level: 15 } }, { source: 'network' }));
+ assert.deepStrictEqual([g.items[0].kind, g.items[0].source_id, g.items[0].title], ['game', `${A}:15`, 'Reached level 15 in an OpenVibe game']);
+ }
assert.strictEqual(kindOf('blog.post.published', { post_id: 'b1', url: 'https://openvibe.blog/p/1', blog: { handle: 'openvibe' } }), 'update');
assert.strictEqual(kindOf('blog.post.published', { post_id: 'b1', url: 'https://openvibe.blog/p/1', blog: { handle: 'openvibe' } }), 'update');
assert.strictEqual(kindOf('wiki.page.published', { page_id: 'w1', url: 'https://openvibe.wiki/space/page', title: 'A page' }), 'wiki');
assert.strictEqual(kindOf('wiki.page.published', { page_id: 'w1', url: 'https://openvibe.wiki/space/page', title: 'A page' }), 'wiki');
assert.strictEqual(kindOf('news.story.published', { story_id: 's1', url: 'https://openvibe.news/s/1', topic: 'feeds' }), 'news');
assert.strictEqual(kindOf('news.story.published', { story_id: 's1', url: 'https://openvibe.news/s/1', topic: 'feeds' }), 'news');
@@ -55,6 +59,10 @@ const envelope = (type, payload, over = {}) => ({
@@ -55,6 +59,10 @@ const envelope = (type, payload, over = {}) => ({
assert.strictEqual(jobs.length, 1);
assert.strictEqual(jobs.length, 1);
assert.strictEqual(jobs[0].title, '3 new jobs this hour');
assert.strictEqual(jobs[0].title, '3 new jobs this hour');
assert.strictEqual(jobs[0].group.count, 3);
assert.strictEqual(jobs[0].group.count, 3);
+ const deals = foldCards([1, 2, 3].map((n) => cardOf({ source_service: 'deals', source_type: 'offer', source_id: `d${n}`, kind: 'deal', title: `Deal ${n}`, url: 'https://openvibe.deals/d/x', site: 'deals', site_name: 'Deals', counts: {}, occurred_at: '2026-11-12T09:00:00.000Z' })));
+ assert.strictEqual(deals.length, 1, 'a poll of deals is one card');
+ assert.strictEqual(deals[0].title, '3 new deals');
+ assert.strictEqual(deals[0].group.count, 3);
// ── ranking: live, followed, fresh, then older than a week ─
// ── ranking: live, followed, fresh, then older than a week ─
const live = cardOf({ source_service: 'live', source_type: 'stream', source_id: 'l', kind: 'live', title: 'Live', url: 'https://openvibe.live/@alex', site: 'live', site_name: 'Live', actor_subject: B, live_now: true, occurred_at: iso(now - 86400000) });
const live = cardOf({ source_service: 'live', source_type: 'stream', source_id: 'l', kind: 'live', title: 'Live', url: 'https://openvibe.live/@alex', site: 'live', site_name: 'Live', actor_subject: B, live_now: true, occurred_at: iso(now - 86400000) });