openvibers/openvibe.network Commits

Feed: game level-ups, one card per poll, AI labels and one person (#875)

OP OpenVibers committed 19 h ago · faaa75e798 · 4 files, +38 −2

Level-ups from network.module.updated become game cards; a poll of deals or stories folds to one card; AI and system rows keep their labels; the report peak says '1 person'.

Changes

ddiff --git a/server/feed/card.js b/server/feed/card.js
iindex 9e3b46e9..b6941e3a 100644
---- a/server/feed/card.js
++++ b/server/feed/card.js
@@@ -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 {
ddiff --git a/server/feed/events.js b/server/feed/events.js
iindex 5dbdb8b8..9802f800 100644
---- a/server/feed/events.js
++++ b/server/feed/events.js
@@@ -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);
ddiff --git a/server/feed/sources.js b/server/feed/sources.js
iindex acfb54c6..c2a6c478 100644
---- a/server/feed/sources.js
++++ b/server/feed/sources.js
@@@ -563,7 +563,7 @@ function createFeedSources({ db, config = {}, fetchImpl = globalThis.fetch, now
if (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 });
ddiff --git a/test/feed.test.js b/test/feed.test.js
iindex cf8ef77a..e41d4420 100644
---- a/test/feed.test.js
++++ b/test/feed.test.js
@@@ -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) });