openvibers/openvibe.network Commits
Order On OpenVibe now by recency and engagement (#782)
Changes
@@@ -149,21 +149,47 @@ function renderNet(frame) {const box = document.getElementById('td-net');const wrap = document.getElementById('td-net-cards');if (!box || !wrap) return;-- const cards = ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).slice(0, 3);++ const now = Date.now();++ // Card order: live first; otherwise age score (newer is higher) plus engagement score++ // (views + replies + viewers, capped at 100). Rows with no relative age retain a declared window.++ const cards = ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).map((c) => {++ const live = c.site === 'live' && /live/i.test(c.title || '');++ const rows = c.items.filter((it) => tdSafeUrl(it.url)).map((it) => {++ const age = String(it.meta || '').match(/(\d+)\s*(m|min|h|d|w)\s*ago/i);++ const ageMs = age ? Number(age[1]) * ({ m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 }[age[2].toLowerCase()]) : null;++ const engagement = [...String(it.meta || '').matchAll(/(\d+)\s*(?:views?|replies|viewers?)/gi)].reduce((n, m) => n + Number(m[1]), 0);++ return { ...it, ageMs, engagement };++ });++ const newest = rows.reduce((min, it) => it.ageMs != null ? Math.min(min, it.ageMs) : min, Infinity);++ const score = rows.reduce((n, it) => n + Math.min(100, it.engagement), 0);++ const rank = live ? Infinity : (newest === Infinity ? 0 : 100 - Math.min(100, newest / 3600000)) + score;++ return { ...c, live, rows, newest, rank };++ }).sort((a, b) => b.rank - a.rank).slice(0, 3);wrap.replaceChildren(...cards.map((c) => {const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];-- const rows = c.items.filter((it) => tdSafeUrl(it.url)).slice(0, 5);-- const list = tdEl('ol', 'td-net-list', rows.map((it, i) => {++ const grouped = new Map();++ for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {++ const key = `${row.title}\u0000${row.person || row.author || row.url}`;++ const old = grouped.get(key);++ if (old) old.count++;++ else grouped.set(key, { ...row, count: 1 });++ }++ const stale = c.newest !== Infinity && c.newest > 7 * 86400000;++ const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);++ const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {const src = it.image && tdSafeUrl(it.image.src);++ const when = it.ageMs == null ? (c.window || 'most watched this week') : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';++ const detail = [it.count > 1 ? `${it.count} streams this week` : '', when].filter(Boolean).join(' · ');return tdEl('li', '', [tdEl('a', 'td-net-row', [tdEl('span', 'td-net-i', [String(i + 1)]),src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,-- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),++ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), tdEl('small', '', [detail])]),], { href: tdSafeUrl(it.url) })]);}));const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;return tdEl('section', 'td-box td-net-card', [tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),++ stale ? tdEl('small', '', [`quiet for ${Math.floor(c.newest / 86400000)} days`]) : null,list,], { 'data-site': String(c.site || '') });}));@@@ -113,6 +113,21 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;['https://openvibe.media/t/1.jpg?w=640', '', '64', '36', 'lazy', 'async'],'a 64x36 lazy thumbnail with empty alt');assert.strictEqual(p.ids['td-net-cards'].all((c) => c.tagName === 'OL')[0].children[0].children[0].children.map((c) => c.className).join(','), 'td-net-i,td-net-thumb,td-net-t', 'number, picture, then the title');++ p.sandbox.renderNet({ cards: [++ { id: 'forum', site: 'community', title: 'New in the forum', items: [{ title: 'Old thread', url: 'https://openvibe.community/t/1', meta: '18d ago' }] },++ { id: 'video', site: 'media-hub', title: 'Trending · Video', items: [{ title: 'Fresh clip', url: 'https://openvibe.video/v/1', meta: '1d ago · 12 views' }] },++ { id: 'live', site: 'live', title: 'Live now', items: [{ title: 'On air', url: 'https://openvibe.live/@a', meta: '1 viewers' }] },++ ] });++ const ordered = p.ids['td-net-cards'].children;++ assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');++ assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');++ assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet');++ assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');++ p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [++ { title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },++ { title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },++ ] }] });++ assert.match(p.ids['td-net-cards'].textContent, /2 streams this week/, 'same title and person fold into one row');p.sandbox.renderNet(null);assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
@@ -149,21 +149,47 @@ function renderNet(frame) {
@@ -149,21 +149,47 @@ function renderNet(frame) {
const box = document.getElementById('td-net');
const box = document.getElementById('td-net');
const wrap = document.getElementById('td-net-cards');
const wrap = document.getElementById('td-net-cards');
if (!box || !wrap) return;
if (!box || !wrap) return;
- const cards = ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).slice(0, 3);
+ const now = Date.now();
+ // Card order: live first; otherwise age score (newer is higher) plus engagement score
+ // (views + replies + viewers, capped at 100). Rows with no relative age retain a declared window.
+ const cards = ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).map((c) => {
+ const live = c.site === 'live' && /live/i.test(c.title || '');
+ const rows = c.items.filter((it) => tdSafeUrl(it.url)).map((it) => {
+ const age = String(it.meta || '').match(/(\d+)\s*(m|min|h|d|w)\s*ago/i);
+ const ageMs = age ? Number(age[1]) * ({ m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 }[age[2].toLowerCase()]) : null;
+ const engagement = [...String(it.meta || '').matchAll(/(\d+)\s*(?:views?|replies|viewers?)/gi)].reduce((n, m) => n + Number(m[1]), 0);
+ return { ...it, ageMs, engagement };
+ });
+ const newest = rows.reduce((min, it) => it.ageMs != null ? Math.min(min, it.ageMs) : min, Infinity);
+ const score = rows.reduce((n, it) => n + Math.min(100, it.engagement), 0);
+ const rank = live ? Infinity : (newest === Infinity ? 0 : 100 - Math.min(100, newest / 3600000)) + score;
+ return { ...c, live, rows, newest, rank };
+ }).sort((a, b) => b.rank - a.rank).slice(0, 3);
wrap.replaceChildren(...cards.map((c) => {
wrap.replaceChildren(...cards.map((c) => {
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
- const rows = c.items.filter((it) => tdSafeUrl(it.url)).slice(0, 5);
- const list = tdEl('ol', 'td-net-list', rows.map((it, i) => {
+ const grouped = new Map();
+ for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {
+ const key = `${row.title}\u0000${row.person || row.author || row.url}`;
+ const old = grouped.get(key);
+ if (old) old.count++;
+ else grouped.set(key, { ...row, count: 1 });
+ }
+ const stale = c.newest !== Infinity && c.newest > 7 * 86400000;
+ const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);
+ const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {
const src = it.image && tdSafeUrl(it.image.src);
const src = it.image && tdSafeUrl(it.image.src);
+ const when = it.ageMs == null ? (c.window || 'most watched this week') : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';
+ const detail = [it.count > 1 ? `${it.count} streams this week` : '', when].filter(Boolean).join(' · ');
return tdEl('li', '', [tdEl('a', 'td-net-row', [
return tdEl('li', '', [tdEl('a', 'td-net-row', [
tdEl('span', 'td-net-i', [String(i + 1)]),
tdEl('span', 'td-net-i', [String(i + 1)]),
src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), it.meta ? tdEl('small', '', [String(it.meta)]) : null]),
+ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), tdEl('small', '', [detail])]),
], { href: tdSafeUrl(it.url) })]);
], { href: tdSafeUrl(it.url) })]);
}));
}));
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
return tdEl('section', 'td-box td-net-card', [
return tdEl('section', 'td-box td-net-card', [
tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),
tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),
+ stale ? tdEl('small', '', [`quiet for ${Math.floor(c.newest / 86400000)} days`]) : null,
list,
list,
], { 'data-site': String(c.site || '') });
], { 'data-site': String(c.site || '') });
}));
}));
@@ -113,6 +113,21 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
@@ -113,6 +113,21 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
['https://openvibe.media/t/1.jpg?w=640', '', '64', '36', 'lazy', 'async'],
['https://openvibe.media/t/1.jpg?w=640', '', '64', '36', 'lazy', 'async'],
'a 64x36 lazy thumbnail with empty alt');
'a 64x36 lazy thumbnail with empty alt');
assert.strictEqual(p.ids['td-net-cards'].all((c) => c.tagName === 'OL')[0].children[0].children[0].children.map((c) => c.className).join(','), 'td-net-i,td-net-thumb,td-net-t', 'number, picture, then the title');
assert.strictEqual(p.ids['td-net-cards'].all((c) => c.tagName === 'OL')[0].children[0].children[0].children.map((c) => c.className).join(','), 'td-net-i,td-net-thumb,td-net-t', 'number, picture, then the title');
+ p.sandbox.renderNet({ cards: [
+ { id: 'forum', site: 'community', title: 'New in the forum', items: [{ title: 'Old thread', url: 'https://openvibe.community/t/1', meta: '18d ago' }] },
+ { id: 'video', site: 'media-hub', title: 'Trending · Video', items: [{ title: 'Fresh clip', url: 'https://openvibe.video/v/1', meta: '1d ago · 12 views' }] },
+ { id: 'live', site: 'live', title: 'Live now', items: [{ title: 'On air', url: 'https://openvibe.live/@a', meta: '1 viewers' }] },
+ ] });
+ const ordered = p.ids['td-net-cards'].children;
+ assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');
+ assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');
+ assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet');
+ assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');
+ p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [
+ { title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },
+ { title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },
+ ] }] });
+ assert.match(p.ids['td-net-cards'].textContent, /2 streams this week/, 'same title and person fold into one row');
p.sandbox.renderNet(null);
p.sandbox.renderNet(null);
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');