openvibers/openvibe.community Commits
Feed: fill a page by cards after folding, and show a folded run's pictures (#395)
Changes
@@@ -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; }@@@ -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;}@@@ -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://openvlet 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://openvreturn 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 };@@@ -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>`;@@@ -163,6 +163,22 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reassert.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 () => {
@@ -588,6 +588,14 @@ button.rx:hover { border-color: var(--accent); }
@@ -588,6 +588,14 @@ button.rx:hover { border-color: var(--accent); }
.feed-media:hover img { transform: scale(1.02); }
.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-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; }
.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 { 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 > i { font-size: 1.6rem; color: var(--text-secondary); margin-bottom: 6px; }
.feed-empty p { margin: 0; }
.feed-empty p { margin: 0; }
@@ -595,7 +603,7 @@ button.rx:hover { border-color: var(--accent); }
@@ -595,7 +603,7 @@ button.rx:hover { border-color: var(--accent); }
@media (max-width: 600px) {
@media (max-width: 600px) {
.feed-item { grid-template-columns: 28px minmax(0, 1fr); gap: 10px; padding: 12px; }
.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-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-media.is-tall { max-width: 260px; width: auto; margin-left: 0; }
}
}
.feed-kind-tabs .tab { flex: none; white-space: nowrap; }
.feed-kind-tabs .tab { flex: none; white-space: nowrap; }
@@ -832,7 +832,7 @@ async function createApp(opts = {}) {
@@ -832,7 +832,7 @@ async function createApp(opts = {}) {
const seen = [];
const seen = [];
let after = cursor;
let after = cursor;
let nextCursor = null;
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 out = await pulse.list({ limit: 12, after: after || undefined }, req.viewer);
const rows = out.items || [];
const rows = out.items || [];
for (let index = 0; index < rows.length; index++) {
for (let index = 0; index < rows.length; index++) {
@@ -844,14 +844,14 @@ async function createApp(opts = {}) {
@@ -844,14 +844,14 @@ async function createApp(opts = {}) {
}
}
seen.push(card);
seen.push(card);
if ((!kind || card.kind === kind) && feedTools.searchMatches(card, q)) items.push({ row: item, 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
nextCursor = index < rows.length - 1
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
: (out.next_cursor || null);
: (out.next_cursor || null);
break;
break;
}
}
}
}
- if (items.length >= 12 || !out.next_cursor) break;
+ if (feedTools.filled(items, 12) || !out.next_cursor) break;
after = out.next_cursor;
after = out.next_cursor;
nextCursor = after;
nextCursor = after;
}
}
@@ -104,6 +104,16 @@ function foldBursts(pairs) {
@@ -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). */
/** Community's own views, likes and comments, in a few batched reads (never one query per card). */
async function countsFor(db, rows) {
async function countsFor(db, rows) {
const out = new Map();
const out = new Map();
@@ -299,7 +309,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -299,7 +309,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
let cursor = req.query.cursor ? String(req.query.cursor) : '';
let cursor = req.query.cursor ? String(req.query.cursor) : '';
let next = null;
let next = null;
// A kind, site or word filters Pulse's pages until the page is full (at most eight pages read per call).
// 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 page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
const rows = page.items || [];
const rows = page.items || [];
next = page.next_cursor || null;
next = page.next_cursor || null;
@@ -315,7 +325,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -315,7 +325,7 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
|| String(c.actor && c.actor.name || '').toLowerCase().includes(q)
|| String(c.actor && c.actor.name || '').toLowerCase().includes(q)
|| c.site_name.toLowerCase().includes(q) || c.kind.includes(q))) continue;
|| c.site_name.toLowerCase().includes(q) || c.kind.includes(q))) continue;
picked.push({ row: i, card: c });
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.
// 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]);
if (index < rows.length - 1) next = encodeCursor([sqlTime(i.occurred_at), i.id]);
break;
break;
@@ -389,4 +399,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
@@ -389,4 +399,4 @@ function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openv
return router;
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 };
@@ -29,13 +29,28 @@ function ago(iso, now = Date.now()) {
@@ -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 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')}`; };
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. */
/** 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) {
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 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 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 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">`
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>`
+ `<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>`;
+ `<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>`;
@@ -163,6 +163,22 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -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.strictEqual(run.title, 'Maticus added 6 videos');
assert.deepStrictEqual(run.group.items.map((x) => x.id), ['a', 'b', 'c', 'd', 'e', 'f']);
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');
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 () => {
await check('LIVE only for a channel that is live now', async () => {