openvibers/openvibe.community Commits

People's pastes lead the home and /pastes; the moment pastes from streams have their own shelf and filter (#121)

OP OpenVibers committed 22 days ago · d41bf90480 · 7 files, +118 −40

Changes

ddiff --git a/public/js/community.js b/public/js/community.js
iindex 49253ba..156417a 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -96,7 +96,7 @@
results.classList.remove('loading');
}).catch(function () { location.href = url; });
}
-- browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); }, { signal });
++ browseForm.addEventListener('submit', function (e) { e.preventDefault(); var qs = new URLSearchParams(new FormData(browseForm)); ['sort', 'lang', 'q', 'from'].forEach(function (k) { if (!qs.get(k) || (k === 'sort' && qs.get(k) === 'new') || (k === 'from' && qs.get(k) === 'people')) qs.delete(k); }); var s = qs.toString(); load('/pastes' + (s ? '?' + s : ''), true); }, { signal });
$$('select', browseForm).forEach(function (sel) { sel.addEventListener('change', function () { browseForm.requestSubmit ? browseForm.requestSubmit() : browseForm.dispatchEvent(new Event('submit', { cancelable: true })); }, { signal }); });
results.addEventListener('click', function (e) { var a = e.target.closest ? e.target.closest('.pager a') : null; if (!a) return; e.preventDefault(); load(a.getAttribute('href'), true); window.scrollTo({ top: results.offsetTop - 80, behavior: 'smooth' }); }, { signal });
window.addEventListener('popstate', function (e) { if (e.state && e.state.browse) load(e.state.browse, false); else if (location.pathname === '/pastes') load(location.pathname + location.search, false); }, { signal });
ddiff --git a/server/app.js b/server/app.js
iindex 2308810..fdc7db7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -326,8 +326,9 @@ async function createApp(opts = {}) {
}
app.get('/', withUser, wrap(async (req, res) => {
-- const [latest, trending, languages, extras] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages(), readHomeExtras()]);
-- html(res, pages.homePage({ latest, trending, languages, user: req.user, ...extras }));
++ // People's pastes lead; OpenVibe.AI's moment pastes from streams have their own shelf.
++ const [latest, trending, fromStreams, languages, extras] = await Promise.all([catalog.latest(12, { from: 'people' }), catalog.trending(8, { from: 'people' }), catalog.latest(8, { from: 'streams' }), catalog.languages(), readHomeExtras()]);
++ html(res, pages.homePage({ latest, trending, fromStreams, languages, user: req.user, ...extras }));
}));
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex b33fed7..9d2e855 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -21,42 +21,41 @@ const WINDOW = 200; // page cap for one window query
const LOCAL_TTL_MS = 2000;
const PER_PAGE = 24;
--let _recent = { at: 0, promise: null, pastes: [] };
--const _byType = new Map(); // 'paste' | 'screenshot' → { at, promise, pastes }
++// One cached window per (type, from): type 'paste' | 'screenshot' | null (both), from 'people' | 'streams' | 'all'.
++const _windows = new Map(); // key → { at, promise, pastes }
++// Where a paste comes from: people (written or imported by someone) or streams (OpenVibe.AI's moment pastes from Live).
++const FROM = { people: 'people', streams: 'ai', all: undefined };
function _textOnlyPublic(rows) {
return (rows || []).filter((p) => p && p.slug && (p.visibility === 'public' || p.visibility == null));
}
--/** The latest public pastes (cached). Never throws — an unavailable store yields the stale list or []. */
--async function recent() {
++/** The latest public pastes of one type and origin (cached). Never throws: an unavailable store yields the stale list or []. */
++async function windowOf({ type = null, from = 'all' } = {}) {
++ const key = `${type || 'any'}:${from}`;
const now = Date.now();
-- if (_recent.promise) return _recent.promise;
-- if (now - _recent.at < LOCAL_TTL_MS) return _recent.pastes;
-- _recent.promise = source.listPastes({ limit: WINDOW, offset: 0 })
-- .then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
-- .catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - LOCAL_TTL_MS + 5000; return _recent.pastes; });
-- return _recent.promise;
--}
--
--/** The latest public pastes of one type, 'paste' (text) or 'screenshot' (cached like recent()). Never throws. */
--async function recentOf(type) {
-- const now = Date.now();
-- const cur = _byType.get(type) || { at: 0, promise: null, pastes: [] };
++ const cur = _windows.get(key) || { at: 0, promise: null, pastes: [] };
if (cur.promise) return cur.promise;
if (now - cur.at < LOCAL_TTL_MS) return cur.pastes;
-- cur.promise = source.listPastes({ limit: WINDOW, offset: 0, type })
-- .then((out) => { _byType.set(type, { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }); return _byType.get(type).pastes; })
-- .catch((err) => { console.warn(`[Catalog] recent ${type}s unavailable:`, err.message); _byType.set(type, { at: Date.now() - LOCAL_TTL_MS + 5000, promise: null, pastes: cur.pastes }); return cur.pastes; });
-- _byType.set(type, cur);
++ const query = { limit: WINDOW, offset: 0, ...(type ? { type } : {}), ...(FROM[from] ? { origin: FROM[from] } : {}) };
++ cur.promise = source.listPastes(query)
++ .then((out) => { _windows.set(key, { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }); return _windows.get(key).pastes; })
++ .catch((err) => { console.warn(`[Catalog] recent pastes (${key}) unavailable:`, err.message); _windows.set(key, { at: Date.now() - LOCAL_TTL_MS + 5000, promise: null, pastes: cur.pastes }); return cur.pastes; });
++ _windows.set(key, cur);
return cur.promise;
}
++/** The latest public pastes, every type and origin (cached). Never throws. */
++const recent = () => windowOf();
++/** The latest public pastes of one type, 'paste' (text) or 'screenshot' (cached like recent()). Never throws. */
++const recentOf = (type) => windowOf({ type });
++
function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0) - (a.likes || 0) || String(b.created_at).localeCompare(String(a.created_at)); }
function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }
--async function latest(n = 12) { return (await recent()).slice().sort(byNewest).slice(0, n); }
--async function trending(n = 8) { return (await recent()).slice().sort(byViews).slice(0, n); }
++const fromOf = (v) => (Object.prototype.hasOwnProperty.call(FROM, v) ? v : 'all');
++async function latest(n = 12, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byNewest).slice(0, n); }
++async function trending(n = 8, { from = 'all' } = {}) { return (await windowOf({ from: fromOf(from) })).slice().sort(byViews).slice(0, n); }
/** Pastes in the same language first, then the newest, never the paste itself. */
async function related(paste, n = 6) {
@@@ -85,7 +84,9 @@ function normalizeBrowseQuery(query = {}) {
const page = Math.max(1, Math.min(parseInt(query.page, 10) || 1, 500));
// What to list: text pastes (default), images (screenshots) or all of them.
const type = ['all', 'pastes', 'images'].includes(query.type) ? query.type : (lang === 'image' ? 'images' : 'pastes');
-- return { q, sort, lang: type === 'images' && lang === 'image' ? '' : lang, page, type };
++ // Whose: people's pastes (the default), the moment pastes from streams, or both.
++ const from = ['people', 'streams', 'all'].includes(query.from) ? query.from : 'people';
++ return { q, sort, lang: type === 'images' && lang === 'image' ? '' : lang, page, type, from };
}
const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p.type === 'screenshot' : p.type !== 'screenshot');
@@@ -95,24 +96,25 @@ const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p.
* sort and the language filter are answered from the recent window.
*/
async function browse(query = {}, ctx = {}) {
-- const { q, sort, lang, page, type } = normalizeBrowseQuery(query);
++ const { q, sort, lang, page, type, from } = normalizeBrowseQuery(query);
++ const origin = FROM[from] ? { origin: FROM[from] } : {};
const offset = (page - 1) * PER_PAGE;
let pastes, total, windowed = false;
if (sort === 'new' && !lang && type === 'pastes') {
-- const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
++ const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste', ...origin }, ctx);
pastes = _textOnlyPublic(out && out.pastes);
total = Number(out && out.total) || pastes.length;
} else {
let rows;
if (q) {
-- const out = await source.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
++ const out = await source.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste', ...origin }, ctx);
rows = _textOnlyPublic(out && out.pastes);
} else if (type === 'all') {
-- const [text, images] = await Promise.all([recentOf('paste'), recentOf('screenshot')]);
++ const [text, images] = await Promise.all([windowOf({ type: 'paste', from }), windowOf({ type: 'screenshot', from })]);
rows = [...text, ...images];
} else {
-- rows = await recentOf(type === 'images' ? 'screenshot' : 'paste');
++ rows = await windowOf({ type: type === 'images' ? 'screenshot' : 'paste', from });
}
rows = rows.filter(_ofType(type));
if (lang) rows = rows.filter((p) => (p.language || 'text') === lang);
@@@ -122,10 +124,10 @@ async function browse(query = {}, ctx = {}) {
windowed = true;
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
-- return { pastes, total, page, pages, perPage: PER_PAGE, q, sort, lang, type, windowed, windowSize: WINDOW };
++ return { pastes, total, page, pages, perPage: PER_PAGE, q, sort, lang, type, from, windowed, windowSize: WINDOW };
}
/** For tests and a hot reload after a write. */
--function reset() { _recent = { at: 0, promise: null, pastes: [] }; _byType.clear(); }
++function reset() { _windows.clear(); }
module.exports = { recent, recentOf, latest, trending, related, languages, browse, normalizeBrowseQuery, reset, PER_PAGE, WINDOW };
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 2d5c470..ba79c46 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -212,6 +212,9 @@ async function listPastes(db, opts = {}) {
// hit) would give their content away without the read that burns them.
else if (!opts.includeHidden) where.push("visibility = 'public' AND burn_after_read = 0");
if (opts.origin === 'user' || opts.origin === 'ai' || opts.origin === 'imported') { where.push('origin = ?'); params.push(opts.origin); }
++ // 'people': everything but AI pastes (what people wrote or imported), the shelves' default since AI moment pastes
++ // outnumbered them three to one (2026-10-22: 890 AI, 307 by people).
++ else if (opts.origin === 'people') where.push("origin <> 'ai'");
if (opts.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
if (opts.search) {
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex e7a7901..5bbac7a 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -88,6 +88,7 @@ function pagerHref(state, page) {
if (state.sort && state.sort !== 'new') q.set('sort', state.sort);
if (state.lang) q.set('lang', state.lang);
if (state.type && state.type !== 'pastes') q.set('type', state.type);
++ if (state.from && state.from !== 'people') q.set('from', state.from);
if (page > 1) q.set('page', String(page));
const s = q.toString();
return `/pastes${s ? `?${s}` : ''}`;
@@@ -182,7 +183,7 @@ function homeFaq() {
</section>`;
}
--function homePage({ latest, trending, languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {
++function homePage({ latest, trending, fromStreams = [], languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {
const body = showcase.hero({
eyebrow: 'OpenVibe.Community',
title: 'Share it with a link.', accent: 'Talk about it here.',
@@@ -205,6 +206,12 @@ function homePage({ latest, trending, languages, user, spaces = [], roadmap = []
<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(trending, 'Nothing trending yet.')}
</section>
++${fromStreams.length ? `
++<section class="section" id="from-streams">
++ <div class="section-head"><h2>From streams</h2><a class="more" href="/pastes?from=streams">All from streams <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
++ <p class="muted">Moments from streams on OpenVibe.Live, written up by OpenVibe.AI, each linking back to the stream.</p>
++ ${cardGrid(fromStreams, '')}
++</section>` : ''}
${forumShelf(spaces)}
${roadmapShelf(roadmap)}
${pulseShelf(activity)}
@@@ -266,35 +273,36 @@ function updatesPage() {
// ── Browse ───────────────────────────────────────────────────
function browsePage(result, languages) {
-- const { pastes, total, page, pages, q, sort, lang, type = 'pastes', windowed, windowSize } = result;
++ const { pastes, total, page, pages, q, sort, lang, type = 'pastes', from = 'people', windowed, windowSize } = result;
const titleBits = [];
if (q) titleBits.push(`“${q}”`);
if (lang) titleBits.push(languageLabel(lang));
titleBits.push(sort === 'views' ? 'most viewed' : 'newest');
const title = `Pastes — ${titleBits.join(', ')}${page > 1 ? ` (page ${page})` : ''}`;
-- const canonical = pagerHref({ q, sort, lang, type }, page);
++ const canonical = pagerHref({ q, sort, lang, type, from }, page);
const opt = (id, label) => `<option value="${esc(id)}"${id === (lang || '') ? ' selected' : ''}>${esc(label)}</option>`;
const langOptions = [opt('', 'All languages')].concat((languages || []).map((l) => opt(l.language, `${languageLabel(l.language)} (${l.count})`)));
if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));
const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">
-- ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
++ ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type, from }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
<span class="pager-info">Page ${page} of ${pages}</span>
-- ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
++ ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type, from }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
</nav>` : '';
const body = `
<header class="page-head">
<h1>Pastes</h1>
-- <p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p>
++ <p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${from === 'streams' ? ' from streams, written up by OpenVibe.AI' : from === 'people' ? ' by people' : ''}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p>
</header>
<form class="filters" method="get" action="/pastes" role="search" data-browse>
<label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>
++ <label>From <select name="from"><option value="people"${from === 'people' ? ' selected' : ''}>People</option><option value="streams"${from === 'streams' ? ' selected' : ''}>Streams (AI)</option><option value="all"${from === 'all' ? ' selected' : ''}>Everyone</option></select></label>
<label>Show <select name="type"><option value="pastes"${type === 'pastes' ? ' selected' : ''}>Pastes</option><option value="images"${type === 'images' ? ' selected' : ''}>Images</option><option value="all"${type === 'all' ? ' selected' : ''}>All</option></select></label>
<label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>
<label>Language <select name="lang">${langOptions.join('')}</select></label>
<button class="btn" type="submit">Apply</button>
-- ${(q || lang || sort !== 'new') ? `<a class="btn btn-ghost" href="/pastes">Clear</a>` : ''}
++ ${(q || lang || sort !== 'new' || from !== 'people') ? `<a class="btn btn-ghost" href="/pastes">Clear</a>` : ''}
</form>
<section id="results" data-results>
${cardGrid(pastes, q ? 'No pastes match that search.' : 'No pastes yet.')}
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 43e2a9a..2b411f0 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -115,6 +115,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(bad.status, 400);
const onlyAi = (await call('/api/pastes?origin=ai')).json();
assert.ok(onlyAi.pastes.length >= 1 && onlyAi.pastes.every((x) => x.origin === 'ai'));
++ const people = (await call('/api/pastes?origin=people&limit=200')).json();
++ assert.ok(people.pastes.length >= 1 && people.pastes.every((x) => x.origin !== 'ai'), 'origin=people: every paste but the AI ones');
const browserMeta = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'meta?', metadata: { ai_moment: true } } });
assert.strictEqual(browserMeta.json().paste.metadata, null, 'browsers cannot set metadata');
});
ddiff --git a/test/pastes-from-streams.test.js b/test/pastes-from-streams.test.js
nnew file mode 100644
iindex 0000000..692f94c
---- /dev/null
++++ b/test/pastes-from-streams.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// People's pastes and the moment pastes from streams, apart (plan 10-22: 890 AI pastes beside 307 by people buried what
++// people wrote). The home's latest and most viewed are people's, with a "From streams" shelf of its own; /pastes lists
++// people's by default, from=streams the AI ones and from=all both; the store's 'people' is every origin but AI.
++const assert = require('assert');
++const source = require('../server/pastes/source');
++const catalog = require('../server/pastes/catalog');
++const pages = require('../server/render/pages');
++
++const at = (i) => new Date(Date.UTC(2026, 9, 1) + i * 60_000).toISOString();
++const rows = [
++ { slug: 'ai-1', origin: 'ai', type: 'paste', title: 'Moment one', visibility: 'public', views: 500, created_at: at(30) },
++ { slug: 'ai-2', origin: 'ai', type: 'paste', title: 'Moment two', visibility: 'public', views: 400, created_at: at(31) },
++ { slug: 'me-1', origin: 'user', type: 'paste', title: 'My script', visibility: 'public', views: 5, created_at: at(10) },
++ { slug: 'imp-1', origin: 'imported', type: 'paste', title: 'Imported log', visibility: 'public', views: 9, created_at: at(11) },
++].sort((a, b) => b.created_at.localeCompare(a.created_at));
++const asked = [];
++source.use({
++ list: async (_viewer, q) => {
++ asked.push(q.origin || 'any');
++ const keep = (p) => (!q.type || p.type === q.type) && (!q.origin || (q.origin === 'people' ? p.origin !== 'ai' : p.origin === q.origin));
++ const found = rows.filter(keep);
++ return { pastes: found.slice(q.offset || 0, (q.offset || 0) + q.limit), total: found.length };
++ },
++});
++
++let passed = 0;
++const check = async (name, fn) => { catalog.reset(); await fn(); passed++; console.log(' ✓', name); };
++
++(async () => {
++ await check('latest and most viewed by people leave AI pastes out; from streams is only them; all is everything', async () => {
++ assert.deepStrictEqual((await catalog.latest(12, { from: 'people' })).map((p) => p.slug), ['imp-1', 'me-1']);
++ assert.deepStrictEqual((await catalog.trending(8, { from: 'people' })).map((p) => p.slug), ['imp-1', 'me-1']);
++ assert.deepStrictEqual((await catalog.latest(8, { from: 'streams' })).map((p) => p.slug), ['ai-2', 'ai-1']);
++ assert.strictEqual((await catalog.latest(12)).length, 4, 'the default (the sitemap and feeds) is every public paste');
++ assert.deepStrictEqual((await catalog.latest(3, { from: 'nonsense' })).length, 3, 'an unknown origin is everything');
++ });
++
++ await check('/pastes lists people\'s by default, and from=streams or from=all on request, in the store and the window', async () => {
++ const people = await catalog.browse({});
++ assert.deepStrictEqual([people.from, people.pastes.map((p) => p.slug)], ['people', ['imp-1', 'me-1']]);
++ assert.deepStrictEqual((await catalog.browse({ from: 'streams' })).pastes.map((p) => p.slug), ['ai-2', 'ai-1']);
++ assert.strictEqual((await catalog.browse({ from: 'all' })).total, 4);
++ assert.deepStrictEqual((await catalog.browse({ from: 'streams', sort: 'views' })).pastes.map((p) => p.slug), ['ai-1', 'ai-2'], 'the windowed path keeps the origin too');
++ assert.ok(asked.includes('people') && asked.includes('ai'));
++ });
++
++ await check('the home has a From streams shelf; the browse page says whose pastes and keeps it in its links', async () => {
++ const home = pages.homePage({ latest: [], trending: [], fromStreams: [rows[0]], languages: [] });
++ assert.match(home, /id="from-streams"/);
++ assert.match(home, /href="\/pastes\?from=streams"/);
++ assert.doesNotMatch(pages.homePage({ latest: [], trending: [], languages: [] }), /id="from-streams"/, 'no shelf without any');
++ const out = await catalog.browse({ from: 'streams' });
++ const page = pages.browsePage({ ...out, pages: 3 }, []);
++ assert.match(page, /from streams, written up by OpenVibe\.AI/);
++ assert.match(page, /<option value="streams" selected>/);
++ assert.match(page, /href="\/pastes\?from=streams&amp;page=2"/, 'the pager keeps whose pastes');
++ assert.match(pages.browsePage({ ...(await catalog.browse({})), pages: 1 }, []), /pastes by people/);
++ });
++
++ console.log(`pastes from streams: ${passed} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });