openvibers/openvibe.chat Commits

Chat: page back through Global history (#472)

OP OpenVibers committed 4 days ago · 610d2b895c · 5 files, +48 −16

Changes

ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 6c7743f..80f028b 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -235,6 +235,29 @@
function startGlobal() {
var live = document.getElementById('oc-live');
var ws = null, delay = 1000, opened = false;
++ var historyState = document.getElementById('oc-history-state'), loadingEarlier = false, historyMore = true;
++ function loadEarlier() {
++ if (loadingEarlier || !historyMore || !feed) return;
++ var first = feed.querySelector('[data-id]');
++ if (!first) return;
++ loadingEarlier = true;
++ if (historyState) historyState.textContent = 'Loading earlier messages';
++ var before = Number(first.dataset.id), height = feed.scrollHeight, top = feed.scrollTop;
++ fetch('/api/chat/global/history?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) {
++ if (!d || !Array.isArray(d.messages)) return;
++ var anchor = feed.querySelector('[data-id="' + first.dataset.id + '"]');
++ (d.messages || []).forEach(function (m) {
++ if ((m.message_type || 'chat') === 'chat' && !m.is_deleted && !feed.querySelector('[data-id="' + m.id + '"]')) feed.insertBefore(globalItem(m), historyState && historyState.nextSibling);
++ });
++ historyMore = !!d.more;
++ if (historyState) historyState.textContent = historyMore ? '' : 'The start of the chat';
++ if (anchor) feed.scrollTop = top + (feed.scrollHeight - height);
++ }).catch(function () {}).finally(function () { loadingEarlier = false; });
++ }
++ if (feed) feed.addEventListener('scroll', function () { if (feed.scrollTop < 600) loadEarlier(); });
++ if (feed) { feed.scrollTop = feed.scrollHeight; }
// A workspace room's queue (server/web/workspace-pages.js): its panel reloads when the socket says it changed.
var queueBox = document.getElementById('oc-queue'), queueTimer = null;
function refreshQueue() {
ddiff --git a/server/chat/history-store.js b/server/chat/history-store.js
iindex 4921761..17fbb82 100644
---- a/server/chat/history-store.js
++++ b/server/chat/history-store.js
@@@ -3,7 +3,7 @@
*
* Rooms: 'global' | 'channel:<userId>' (the stream route keeps its own scope logic)
* Reads:
-- * page(room, { limit, before }) newest `limit` rows, oldest→newest — the first paint
++ * page(room, { limit, before }) newest `limit` rows, newest→oldest — the first paint
* delta(room, { afterId, limit }) rows with id > afterId, oldest→newest — reopen, reconnect,
* tab focus. Cheap: a primary-key range scan, no reverse.
* Plus `deleted_ids` (deletedIds below): the rows at or under
@@@ -98,11 +98,12 @@ function memoSet(key, value) {
async function readPage(r, { limit, before, channelUsername }) {
const { sql, params } = baseSql(r, { channelUsername });
let q = sql;
-- if (before) { q += ' AND cm.timestamp < ?'; params.push(before); }
++ if (before) { q += ' AND cm.id < ?'; params.push(before); }
// The page order is the primary key alone (plan T3 decision 2): ids are monotonic in insert order,
// so this is the same order as before, and idx_chat_page_live serves it without a sort on PostgreSQL.
-- q += ' ORDER BY cm.id DESC LIMIT ?'; params.push(limit);
-- return (await db.all(q, params)).reverse();
++ q += ' ORDER BY cm.id DESC LIMIT ?'; params.push(limit + 1);
++ const rows = await db.all(q, params);
++ return { rows: rows.slice(0, limit), more: rows.length > limit };
}
async function readDelta(r, { afterId, limit, channelUsername }) {
@@@ -114,8 +115,8 @@ async function readDelta(r, { afterId, limit, channelUsername }) {
}
/**
-- * Newest rows of a room, oldest→newest.
-- * @returns {{ messages: object[], latest_id: number }}
++ * Newest rows of a room, newest→oldest. `before` is an exclusive message id cursor.
++ * @returns {{ messages: object[], latest_id: number, more: boolean }}
*/
async function page(room, { limit, before, channelUsername, decorate } = {}) {
const r = parseRoom(room);
@@@ -123,15 +124,16 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
// The newest row id is part of the memo key, so a memoised page can never miss a message:
// one primary-key lookup instead of the five-table join for every repeat reader in the window.
const key = !before ? `${room}|${lim}|${channelUsername || ''}|${await tipId()}` : null;
-- let rows = key ? memoGet(key) : null;
-- if (!rows) {
-- rows = await readPage(r, { limit: lim, before, channelUsername });
-- if (key) memoSet(key, rows);
++ let page = key ? memoGet(key) : null;
++ if (!page) {
++ page = await readPage(r, { limit: lim, before, channelUsername });
++ if (key) memoSet(key, page);
}
// Copies: the page memo holds the rows, and a refreshed card must not change what the next reader sees.
++ const rows = page.rows;
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
-- return { messages, latest_id: latestIdOf(rows) };
++ return { messages, latest_id: latestIdOf(rows), more: page.more };
}
/**
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex a3d5beb..f40a660 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -518,12 +518,13 @@ router.get('/filters/friendly', (req, res) => {
router.get('/global/history', optionalAuth, async (req, res) => {
try {
-- const limit = req.query.limit;
++ const askedLimit = parseInt(req.query.limit, 10);
++ const limit = Math.min(Number.isFinite(askedLimit) && askedLimit > 0 ? askedLimit : 50, 100);
const before = req.query.before;
const afterId = req.query.after_id;
const channelUsername = String(req.query.username || '').trim();
// after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
-- // Anything else = a page: the newest rows, optionally before a timestamp.
++ // Anything else = a page: the newest rows, optionally before an exclusive message id.
const out = afterId != null
? await historyStore.delta('global', { afterId, limit, channelUsername })
: await historyStore.page('global', { limit, before, channelUsername });
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex ecf7f9d..d4e0c30 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -251,7 +251,7 @@ function createWebRoutes({ config }) {
// ── Global chat ──
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
-- const { messages, latest_id } = await historyStore.page('global', { limit: 60 });
++ const { messages, latest_id } = await historyStore.page('global', { limit: 50 });
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
// And not what OpenVibe's global room does not show of other rooms' lines (chat/global-standard.js).
@@@ -295,7 +295,7 @@ function createWebRoutes({ config }) {
].filter((pane) => pane.html);
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const inlineStyles = [];
-- const feedList = `<ol class="oc-feed${signedIn ? ' oc-srv-feed' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>`;
++ const feedList = `<ol class="oc-feed${signedIn ? ' oc-srv-feed' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages"><li id="oc-history-state" class="oc-muted" aria-live="polite"></li>${shown.slice().reverse().map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>`;
// Someone signed in gets the app shell (the plan, 2026-11-09, stage V2): Global chat is a destination on the
// rail, the side column is Home's, and the feed fills the middle with the composer at its foot.
const shellBody = signedIn ? await (async () => {
ddiff --git a/test/rest.test.js b/test/rest.test.js
iindex 092db8d..43f824f 100644
---- a/test/rest.test.js
++++ b/test/rest.test.js
@@@ -118,8 +118,14 @@ t('chat history: global page and cursor delta, deleted and expired rows hidden',
await h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);
const page = await h.http('GET', '/api/chat/global/history?limit=10');
const got = page.body.messages.map((m) => m.message);
-- assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g0', 'g3', 'g4']);
++ assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g4', 'g3', 'g0']);
assert.strictEqual(page.body.latest_id, ids[4]);
++ const firstPage = await h.http('GET', '/api/chat/global/history?limit=2');
++ assert.strictEqual(firstPage.body.more, true);
++ assert.deepStrictEqual(firstPage.body.messages.map((m) => m.id), [ids[4], ids[3]]);
++ const older = await h.http('GET', `/api/chat/global/history?before=${ids[3]}&limit=2`);
++ assert.strictEqual(older.body.more, false);
++ assert.deepStrictEqual(older.body.messages.map((m) => m.id).filter((id) => ids.includes(id)), [ids[0]]);
const delta = await h.http('GET', `/api/chat/global/history?after_id=${ids[3]}`);
assert.deepStrictEqual(delta.body.messages.map((m) => m.message), ['g4']);
assert.strictEqual(delta.body.complete, true);