openvibers/openvibe.community Commits

Feed: fill a page by cards after folding, and show a folded run's pictures (#395)

OP OpenVibers committed 1 days ago · a0b7de5b8d · 5 files, +58 −9

After #393/#394 a streamer's evening folds into one card, and the page counted
raw rows: /feed read twelve rows, folded them into three cards and showed
"Older". A page is now full by cards after folding (raw rows stop at four times
the page), in the API and on /feed.

A folded run ("iZoR_One added 10 videos") showed one picture of ten. It now
shows its first four, each opening its own video, with "+N" over the last, two
across on a phone and four on a wider screen, and its label is plural
("Recordings"). openvibe.design rule 136, groups show their members.

Test: test/feed-api.test.js.

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 4d30af4..82412da 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -588,6 +588,14 @@ button.rx:hover { border-color: var(--accent); }
.feed-media:hover img { transform: scale(1.02); }
.feed-len, .feed-live { position: absolute; bottom: 8px; right: 8px; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .72); color: #fff; font-size: .75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.feed-live { left: 8px; right: auto; background: #dc2626; letter-spacing: .04em; }
++/* A folded run (feed.js groupStrip): its first pictures side by side, two across on a phone, "+N" over the last. */
++.feed-strip { list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; max-width: 560px; margin: 6px 0 0; padding: 0; border-radius: 12px; overflow: hidden; }
++.feed-strip li a { position: relative; display: block; aspect-ratio: 16 / 9; background: var(--bg-secondary, #101828); overflow: hidden; }
++.feed-strip img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform .25s ease; }
++.feed-strip a:hover img { transform: scale(1.03); }
++.feed-strip-more { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0, 0, 0, .55); color: #fff; font-size: 1.15rem; font-weight: 700; }
++.feed-strip[data-n="3"] li:first-child { grid-column: 1 / -1; }
++@media (min-width: 601px) { .feed-strip[data-n="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); } .feed-strip[data-n="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); } .feed-strip[data-n="3"] li:first-child { grid-column: auto; } }
.feed-empty { display: grid; justify-items: center; gap: 4px; padding: 36px 16px; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); }
.feed-empty > i { font-size: 1.6rem; color: var(--text-secondary); margin-bottom: 6px; }
.feed-empty p { margin: 0; }
@@@ -595,7 +603,7 @@ button.rx:hover { border-color: var(--accent); }
@media (max-width: 600px) {
.feed-item { grid-template-columns: 28px minmax(0, 1fr); gap: 10px; padding: 12px; }
.feed-ic { width: 28px; height: 28px; border-radius: 8px; font-size: .8rem; }
-- .feed-media { margin-left: -38px; width: calc(100% + 38px); max-width: none; }
++ .feed-media, .feed-strip { margin-left: -38px; width: calc(100% + 38px); max-width: none; }
.feed-media.is-tall { max-width: 260px; width: auto; margin-left: 0; }
}
.feed-kind-tabs .tab { flex: none; white-space: nowrap; }
ddiff --git a/server/app.js b/server/app.js
iindex 46b1f53..6e26fdb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -832,7 +832,7 @@ async function createApp(opts = {}) {
const seen = [];
let after = cursor;
let nextCursor = null;
-- for (let pages = 0; pages < 8 && items.length < 12; pages++) {
++ for (let pages = 0; pages < 8 && !feedTools.filled(items, 12); pages++) {
const out = await pulse.list({ limit: 12, after: after || undefined }, req.viewer);
const rows = out.items || [];
for (let index = 0; index < rows.length; index++) {
@@@ -844,14 +844,14 @@ async function createApp(opts = {}) {
}
seen.push(card);
if ((!kind || card.kind === kind) && feedTools.searchMatches(card, q)) items.push({ row: item, card });
-- if (items.length >= 12) {
++ if (feedTools.filled(items, 12)) {
nextCursor = index < rows.length - 1
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
: (out.next_cursor || null);
break;
}
}
-- if (items.length >= 12 || !out.next_cursor) break;
++ if (feedTools.filled(items, 12) || !out.next_cursor) break;
after = out.next_cursor;
nextCursor = after;
}
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex 6c5fb2c..7f14bd7 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -104,6 +104,16 @@ function foldBursts(pairs) {
});
}
++/**
++ * Is a page full? Counted after folding: twelve rows that fold into three cards are three cards, and 11-12's /feed
++ * showed three cards and "Older" (a streamer's ten recordings were one). Raw rows stop at four times the page, so a
++ * long run cannot keep a request reading.
++ */
++function filled(pairs, want) {
++ if (pairs.length < want) return false;
++ return pairs.length >= want * 4 || foldBursts(pairs).length >= want;
++}
++
/** Community's own views, likes and comments, in a few batched reads (never one query per card). */
async function countsFor(db, rows) {
const out = new Map();
@@@ -299,7 +309,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
let cursor = req.query.cursor ? String(req.query.cursor) : '';
let next = null;
// A kind, site or word filters Pulse's pages until the page is full (at most eight pages read per call).
-- for (let pages = 0; pages < MAX_PAGE_READS && picked.length < pulseWant; pages++) {
++ for (let pages = 0; pages < MAX_PAGE_READS && !filled(picked, pulseWant); pages++) {
const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
const rows = page.items || [];
next = page.next_cursor || null;
@@@ -315,7 +325,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
|| 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) {
++ if (filled(picked, pulseWant)) {
// Resume after the last consumed row, including when Pulse's final page was only partly read.
if (index < rows.length - 1) next = encodeCursor([sqlTime(i.occurred_at), i.id]);
break;
@@@ -389,4 +399,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
}
--module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, engagement, compareRankedCards, rankCards };
++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, filled, engagement, compareRankedCards, rankCards };
ddiff --git a/server/render/feed.js b/server/render/feed.js
iindex c44a534..992a3b9 100644
---- a/server/render/feed.js
++++ b/server/render/feed.js
@@@ -29,13 +29,28 @@ function ago(iso, now = Date.now()) {
}
const fullDate = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toUTCString().replace(' GMT', ' UTC') : ''; };
const clock = (sec) => { const n = Math.round(Number(sec) || 0); if (!n) return ''; const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); const x = n % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
++/**
++ * A folded run's pictures: its first four, each opening its own item, "+N" on the last (openvibe.design rule 136, a
++ * group shows its members: "iZoR_One added 10 videos" over one picture showed one video of ten).
++ */
++function groupStrip(c) {
++ const items = (c.group.items || []).filter((x) => x.media && x.media.thumb).slice(0, 4);
++ if (items.length < 2) return '';
++ const more = Math.max(0, Number(c.group.count) - items.length);
++ return `<ul class="feed-strip" data-n="${items.length}">${items.map((x, i) => `<li><a href="${esc(x.url)}"${x.title ? ` title="${esc(x.title)}"` : ''}>`
++ + `<img src="${esc(x.media.thumb)}" alt="${esc(x.title || '')}" loading="lazy" decoding="async">`
++ + `${i === items.length - 1 && more ? `<span class="feed-strip-more">+${more}</span>` : ''}</a></li>`).join('')}</ul>`;
++}
++
/** One card: its mark, who and what, where and when on one line; the title; the line; the picture at its own shape. */
function feedCard(c, now) {
-- const [what, icon] = lookOf(c.kind);
++ const [one, icon] = lookOf(c.kind);
++ const what = c.group && !/s$/.test(one) ? `${one}s` : one;
const who = c.actor && c.actor.name ? `<span class="feed-who">${c.actor.avatar ? `<img src="${esc(c.actor.avatar)}" alt="" width="20" height="20" loading="lazy" decoding="async">` : ''}${esc(c.actor.name)}</span>` : '';
const when = c.at ? `<time datetime="${esc(c.at)}" title="${esc(fullDate(c.at))}">${esc(ago(c.at, now))}</time>` : '';
const ratio = c.media && Number(c.media.ratio) > 0 ? Math.min(2.4, Math.max(0.5, Number(c.media.ratio))) : 16 / 9;
-- const media = c.media ? `<a class="feed-media${ratio < 1 ? ' is-tall' : ''}" href="${esc(c.url)}" tabindex="-1" aria-hidden="true" style="aspect-ratio:${ratio.toFixed(3)}"><img src="${esc(c.media.thumb)}" alt="" loading="lazy" decoding="async">${c.media.length ? `<span class="feed-len">${esc(clock(c.media.length))}</span>` : ''}${c.kind === 'live' ? '<span class="feed-live">LIVE</span>' : ''}</a>` : '';
++ const strip = c.group ? groupStrip(c) : '';
++ const media = strip || !c.media ? strip : `<a class="feed-media${ratio < 1 ? ' is-tall' : ''}" href="${esc(c.url)}" tabindex="-1" aria-hidden="true" style="aspect-ratio:${ratio.toFixed(3)}"><img src="${esc(c.media.thumb)}" alt="" loading="lazy" decoding="async">${c.media.length ? `<span class="feed-len">${esc(clock(c.media.length))}</span>` : ''}${c.kind === 'live' ? '<span class="feed-live">LIVE</span>' : ''}</a>`;
return `<li class="feed-item is-${esc(c.kind || 'update')}"><span class="feed-ic" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div class="feed-body">`
+ `<p class="feed-top">${who}<span class="feed-what">${esc(what)}</span>${c.site_name && !String(what).toLowerCase().startsWith(String(c.site_name).toLowerCase()) && !String(what).toLowerCase().endsWith(String(c.site_name).toLowerCase()) ? `<span class="feed-site">${esc(c.site_name)}</span>` : ''}${when}</p>`
+ `<a class="feed-title" href="${esc(c.url)}">${esc(c.title)}</a>${c.text ? `<p class="feed-text">${esc(c.text)}</p>` : ''}${media}</div></li>`;
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex 5406382..0169ea9 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -163,6 +163,22 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(run.title, 'Maticus added 6 videos');
assert.deepStrictEqual(run.group.items.map((x) => x.id), ['a', 'b', 'c', 'd', 'e', 'f']);
assert.deepStrictEqual(out.map((c) => c.id), ['a', 'clip', 'other', 'later'], 'a clip, someone else and a card past the gap stay apart');
++
++ // A page is full by cards, after folding (11-12's /feed: twelve rows, three cards, then "Older").
++ const { filled } = require('../server/pulse/feed');
++ const six = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => pair(id, i * 10));
++ assert.strictEqual(filled(six, 3), false, 'six rows that fold into one card do not fill three');
++ assert.strictEqual(filled([...six, pair('clip', 5, 'clip'), pair('other', 5, 'video', 'sub_ada')], 3), true, 'three cards do');
++ assert.strictEqual(filled(Array.from({ length: 12 }, (_, i) => pair(`r${i}`, i)), 3), true, 'raw rows stop at four times the page');
++
++ // A folded run shows its members: the first four pictures, "+N" over the last (rule 136).
++ const { feedPage } = require('../server/render/feed');
++ const pics = ['a', 'b', 'c', 'd', 'e', 'f'].map((id) => ({ ...pair(id, 0), card: { ...pair(id, 0).card, media: { thumb: `https://openvibe.media/${id}.webp`, ratio: 16 / 9 } } }));
++ const html = feedPage({ items: foldBursts(pics) });
++ assert.ok(html.includes('class="feed-strip" data-n="4"'), 'four pictures of the run');
++ assert.ok(html.includes('>+2</span>'), '"+2" for the other two');
++ assert.ok(html.includes('https://openvibe.video/watch?v=d'), 'each picture opens its own video');
++ assert.ok(html.includes('Recordings'), 'the run is named in the plural');
});
await check('LIVE only for a channel that is live now', async () => {