openvibers/openvibe.chat Commits
Chat: page back through Global history (#472)
Changes
@@@ -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() {@@@ -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 };}/**@@@ -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 });@@@ -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 () => {@@@ -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);
@@ -235,6 +235,29 @@
@@ -235,6 +235,29 @@
function startGlobal() {
function startGlobal() {
var live = document.getElementById('oc-live');
var live = document.getElementById('oc-live');
var ws = null, delay = 1000, opened = false;
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.
// 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;
var queueBox = document.getElementById('oc-queue'), queueTimer = null;
function refreshQueue() {
function refreshQueue() {
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
*
*
* Rooms: 'global' | 'channel:<userId>' (the stream route keeps its own scope logic)
* Rooms: 'global' | 'channel:<userId>' (the stream route keeps its own scope logic)
* Reads:
* 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,
* delta(room, { afterId, limit }) rows with id > afterId, oldest→newest — reopen, reconnect,
* tab focus. Cheap: a primary-key range scan, no reverse.
* tab focus. Cheap: a primary-key range scan, no reverse.
* Plus `deleted_ids` (deletedIds below): the rows at or under
* Plus `deleted_ids` (deletedIds below): the rows at or under
@@ -98,11 +98,12 @@ function memoSet(key, value) {
@@ -98,11 +98,12 @@ function memoSet(key, value) {
async function readPage(r, { limit, before, channelUsername }) {
async function readPage(r, { limit, before, channelUsername }) {
const { sql, params } = baseSql(r, { channelUsername });
const { sql, params } = baseSql(r, { channelUsername });
let q = sql;
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,
// 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.
// 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 }) {
async function readDelta(r, { afterId, limit, channelUsername }) {
@@ -114,8 +115,8 @@ 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 } = {}) {
async function page(room, { limit, before, channelUsername, decorate } = {}) {
const r = parseRoom(room);
const r = parseRoom(room);
@@ -123,15 +124,16 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
@@ -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:
// 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.
// 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;
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.
// 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 }));
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
await linkCards.hydrate(messages, 'chat_messages');
await linkCards.hydrate(messages, 'chat_messages');
- return { messages, latest_id: latestIdOf(rows) };
+ return { messages, latest_id: latestIdOf(rows), more: page.more };
}
}
/**
/**
@@ -518,12 +518,13 @@ router.get('/filters/friendly', (req, res) => {
@@ -518,12 +518,13 @@ router.get('/filters/friendly', (req, res) => {
router.get('/global/history', optionalAuth, async (req, res) => {
router.get('/global/history', optionalAuth, async (req, res) => {
try {
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 before = req.query.before;
const afterId = req.query.after_id;
const afterId = req.query.after_id;
const channelUsername = String(req.query.username || '').trim();
const channelUsername = String(req.query.username || '').trim();
// after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
// 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
const out = afterId != null
? await historyStore.delta('global', { afterId, limit, channelUsername })
? await historyStore.delta('global', { afterId, limit, channelUsername })
: await historyStore.page('global', { limit, before, channelUsername });
: await historyStore.page('global', { limit, before, channelUsername });
@@ -251,7 +251,7 @@ function createWebRoutes({ config }) {
@@ -251,7 +251,7 @@ function createWebRoutes({ config }) {
// ── Global chat ──
// ── Global chat ──
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
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).
// 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();
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).
// 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 }) {
@@ -295,7 +295,7 @@ function createWebRoutes({ config }) {
].filter((pane) => pane.html);
].filter((pane) => pane.html);
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const explore = pickBlock({ key: 'explore', label: 'Explore OpenVibe.Chat', head: 'Explore OpenVibe.Chat', panes });
const inlineStyles = [];
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
// 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.
// rail, the side column is Home's, and the feed fills the middle with the composer at its foot.
const shellBody = signedIn ? await (async () => {
const shellBody = signedIn ? await (async () => {
@@ -118,8 +118,14 @@ t('chat history: global page and cursor delta, deleted and expired rows hidden',
@@ -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]]);
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 page = await h.http('GET', '/api/chat/global/history?limit=10');
const got = page.body.messages.map((m) => m.message);
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]);
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]}`);
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.deepStrictEqual(delta.body.messages.map((m) => m.message), ['g4']);
assert.strictEqual(delta.body.complete, true);
assert.strictEqual(delta.body.complete, true);