openvibers/openvibe.chat Commits

Chat: page older messages in every room (#475)

OP OpenVibers committed 4 days ago · 518d030237 · 22 files, +158 −64

* Chat: page older messages in every room

* Merge remote-tracking branch 'origin/main' into chat-paged-all-1110

* Tests: expect newest-first room pages

* Tests: follow newest-first room history

Changes

ddiff --git a/migrations/0062_dm_message_pages.sql b/migrations/0062_dm_message_pages.sql
nnew file mode 100644
iindex 0000000..99a8d64
---- /dev/null
++++ b/migrations/0062_dm_message_pages.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- A DM's newest and before-ID pages follow one conversation's monotonic message IDs.
++CREATE INDEX IF NOT EXISTS idx_dm_messages_page ON dm_messages (conversation_id, id DESC);
ddiff --git a/public/web/chat-history.js b/public/web/chat-history.js
nnew file mode 100644
iindex 0000000..73e7310
---- /dev/null
++++ b/public/web/chat-history.js
@@@ -0,0 +1,40 @@
++/* One top-paging controller shared by Global, rooms/channels and DMs. */
++(function (root) {
++ 'use strict';
++ root.OvChatHistory = {
++ attach: function (feed, state, fetchPage, render, hasMore) {
++ if (!feed) return;
++ var loading = false, more = hasMore !== false;
++ if (state) { state.hidden = more; state.textContent = more ? '' : 'The start of the chat'; }
++ function load() {
++ if (loading || !more) return;
++ var first = feed.querySelector('[data-id]');
++ if (!first) return;
++ loading = true;
++ if (state) { state.hidden = false; state.textContent = 'Loading earlier messages'; }
++ var cursor = Number(first.dataset.id), top = first.getBoundingClientRect().top;
++ fetchPage(cursor).then(function (page) {
++ if (!page || !Array.isArray(page.messages)) {
++ if (state) { state.textContent = ''; state.hidden = true; }
++ return;
++ }
++ var anchor = feed.querySelector('[data-id="' + first.dataset.id + '"]');
++ // APIs return each page newest first; insert oldest first to retain the feed's chronological display.
++ page.messages.slice().reverse().forEach(function (message) {
++ if (!message || feed.querySelector('[data-id="' + Number(message.id) + '"]')) return;
++ feed.insertBefore(render(message), anchor || (state && state.nextSibling) || feed.firstChild);
++ });
++ more = !!page.more;
++ if (state) {
++ state.textContent = more ? '' : 'The start of the chat';
++ state.hidden = more;
++ }
++ if (anchor) feed.scrollTop += anchor.getBoundingClientRect().top - top;
++ }).catch(function () { if (state) { state.textContent = ''; state.hidden = true; } }).finally(function () { loading = false; });
++ }
++ feed.addEventListener('scroll', function () { if (feed.scrollTop < 600) load(); });
++ feed.scrollTop = feed.scrollHeight;
++ return { load: load };
++ }
++ };
++})(window);
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 80f028b..f8ea5a0 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -168,7 +168,11 @@
var stick = nearBottom();
removeEmpty();
feed.appendChild(li);
-- while (feed.children.length > 300) feed.removeChild(feed.firstChild);
++ while (feed.children.length > 300) {
++ var oldest = feed.querySelector('[data-id]');
++ if (!oldest) break;
++ oldest.remove();
++ }
if (stick) toBottom();
}
function showError(text) {
@@@ -235,29 +239,11 @@
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; }
++ var historyState = document.getElementById('oc-history-state');
++ window.OvChatHistory.attach(feed, historyState, function (before) {
++ return fetch('/api/chat/global/history?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; });
++ }, globalItem, cfg.historyMore);
// 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() {
@@@ -324,6 +310,10 @@
}
function startDm() {
var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
++ window.OvChatHistory.attach(feed, document.getElementById('oc-history-state'), function (before) {
++ return fetch(base + '/messages?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; });
++ }, dmItem, cfg.historyMore);
function poll() {
if (document.hidden) return;
fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })
@@@ -457,6 +447,10 @@
var live = document.getElementById('oc-live');
var ws = null, delay = 1000, opened = false;
var api = '/api/chat/rooms/' + encodeURIComponent(cfg.room);
++ window.OvChatHistory.attach(feed, document.getElementById('oc-history-state'), function (before) {
++ return fetch(api + '/messages?before=' + encodeURIComponent(before) + '&limit=50', { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; });
++ }, roomItem, cfg.historyMore);
function catchUpRoom() {
return fetch(api + '/messages?after=' + latest + '&limit=200', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex 99a67f2..a119799 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -218,10 +218,13 @@ router.get('/conversations/:id/messages', async (req, res) => {
return res.status(403).json({ error: 'Not a participant' });
}
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
-- const before = parseInt(req.query.before) || null;
const after = parseInt(req.query.after) || null;
-- const messages = (await dm.getMessages(convId, limit, before, after)).map(publicMessage);
-- res.json({ messages });
++ if (after != null) {
++ const messages = (await dm.getMessages(convId, limit, null, after)).map(publicMessage);
++ return res.json({ messages });
++ }
++ const page = await dm.getMessagesPage(convId, { limit, before: req.query.before ?? null });
++ res.json({ ...page, messages: page.messages.map(publicMessage) });
} catch (err) {
console.error('[DM] Get messages error:', err.message);
res.status(500).json({ error: 'Failed to load messages' });
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex a443b16..587df03 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -251,6 +251,21 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
return await linkCards.hydrate(rows, 'dm_messages');
}
++async function getMessagesPage(conversationId, { before = null, limit = 50 } = {}) {
++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
++ const params = [conversationId];
++ let where = 'm.conversation_id = ?';
++ if (before != null) { where += ' AND m.id < ?'; params.push(Number(before) || 0); }
++ params.push(lim + 1);
++ const rows = await db.all(`SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id
++ WHERE ${where} ORDER BY m.id DESC LIMIT ?`, params);
++ const more = rows.length > lim;
++ rows.length = Math.min(rows.length, lim);
++ await linkCards.hydrate(rows, 'dm_messages');
++ return { messages: rows, more };
++}
++
/**
* Mark a conversation as read for a user (set last_read_at to now).
*/
@@@ -461,7 +476,7 @@ module.exports = {
getParticipants,
getConversation,
sendMessage,
-- getMessages,
++ getMessages, getMessagesPage,
markRead,
getTotalUnread,
searchUsers,
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 777e755..10e4174 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -369,7 +369,21 @@ async function messageById(id) {
return row;
}
--/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
++/** A bounded page, newest first. `before` is an exclusive message ID cursor. */
++async function historyPage(room, { before = null, limit = 50 } = {}) {
++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
++ const params = [room.id];
++ let where = 'm.room_id = ? AND m.is_deleted = 0';
++ if (before != null) { where += ' AND m.id < ?'; params.push(Number(before) || 0); }
++ params.push(lim + 1);
++ const rows = await db.all(`${MSG_SELECT} WHERE ${where} ORDER BY m.id DESC LIMIT ?`, params);
++ const more = rows.length > lim;
++ rows.length = Math.min(rows.length, lim);
++ await linkCards.hydrate(rows, 'room_messages');
++ return { messages: rows, more };
++}
++
++/** Existing internal callers get chronological arrays; external paged readers use historyPage. */
async function history(room, { before = null, after = null, limit = 60 } = {}) {
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
const rows = after != null
@@@ -558,7 +572,7 @@ module.exports = {
LIMITS,
isWorkspace, isStaffRoom,
postAsAi,
-- ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
++ ensureSchema, create, list, bySlug, access, join, leave, post, history, historyPage, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 8bffab3..e77999e 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -201,8 +201,13 @@ router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
router.get('/:slug/messages', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
-- const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 50));
++ if (req.query.after != null) {
++ const messages = await rooms.history(room, { after: req.query.after, limit });
++ return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
++ }
++ const page = await rooms.historyPage(room, { before: req.query.before ?? null, limit });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ...page, latest_id: page.messages.length ? page.messages[0].id : null });
} catch (err) { send(res, err); }
});
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 0ff8fd6..2ff31c7 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -176,7 +176,8 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
--${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
++${o.script ? `<script src="${asset('chat-history.js')}" defer></script>
++<script src="${asset('chat.js')}" defer></script>` : ''}
${o.shell ? `<script src="${asset('chat-shell.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 1461ef8..085b770 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: 50 });
++ const { messages, latest_id, more: historyMore } = 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"><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>`;
++ 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" hidden 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 () => {
@@@ -330,7 +330,7 @@ ${composer}`,
description: 'Chat with everyone on OpenVibe, start a server with text and voice channels, message friends and call them. Free and open source.',
// The same JSON-LD for every reader (discovery.test.js): the questions are the site's, shown to visitors.
jsonLd: [...homeJsonLd(config), sharedSeo.jsonLd.faq(home.FAQ)],
-- page: { view: 'global', latest: latest_id, signedIn, me: signedIn ? actor.user.username : null },
++ page: { view: 'global', latest: latest_id, historyMore, signedIn, me: signedIn ? actor.user.username : null },
shell: signedIn, footer: signedIn ? 'none' : 'full',
body: signedIn ? shellBody : `<header class="oc-hero">
<h1>Chat with everyone on OpenVibe, or start your own server</h1>
@@@ -545,7 +545,8 @@ ${blockedList}`,
await presence.attachTo(me.subject_id, others);
const conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
-- const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
++ const dmPage = await dm.getMessagesPage(id, { limit: 50 });
++ const msgs = dmPage.messages.slice().reverse();
const savedView = await db.get('SELECT view FROM dm_view_preferences WHERE user_id = ? AND conversation_id = ?', [me.id, id]).catch(() => null);
const viewKind = others.length <= 1 ? 'one_to_one' : 'group';
const viewDefaults = await require('../chat/view-defaults').of(me.id);
@@@ -589,13 +590,13 @@ ${blockedList}`,
const dmUnread = dms.reduce((n, d) => n + (d.current ? 0 : d.unread), 0);
await page(req, res, 200, {
actor, shell: true, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
-- page: { view: 'dm', conversation: id, messageView: view, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },
++ page: { view: 'dm', conversation: id, messageView: view, latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: dmPage.more, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },
body: shell.shell({
rail: shell.rail({ home: true, dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
sideLabel: 'Home',
main: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
--<ol class="oc-feed oc-room-feed${view === 'bubbles' ? ' oc-bubbles' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me, view)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
++<ol class="oc-feed oc-room-feed${view === 'bubbles' ? ' oc-bubbles' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => dmMessage(m, me, view)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
${composer}`,
mainLabel: title,
}),
@@@ -1028,7 +1029,8 @@ ${people}
if (room.server_id) return res.redirect(302, await channelUrl(room));
// A person's own Vibe chat opens in the Vibe app (its links in task pages and messages name /r/<slug>).
if (me && Number(room.assistant) === 1 && await require('../rooms/vibe').ownChat(room.slug, me)) return res.redirect(302, `/vibe/${encodeURIComponent(room.slug)}`);
-- const msgs = await rooms.history(room, { limit: 60 });
++ const roomPage = await rooms.historyPage(room, { limit: 50 });
++ const msgs = roomPage.messages.slice().reverse();
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
@@@ -1068,7 +1070,7 @@ ${people}
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
footer: 'none', shell: true, upload: pictures.enabled(), scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],
description,
-- page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
++ page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: shell.shell({
rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
@@@ -1078,7 +1080,7 @@ ${people}
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
}),
sideLabel: 'Rooms',
-- main: `<header class="oc-room-head">
++ main: `<header class="oc-room-head">
<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
<p class="oc-muted oc-room-sub">${room.topic && !isAgentRoom ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p>${roomRules(room)}</div>
@@@ -1090,7 +1092,7 @@ ${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
${work && room.screen_url && can.role ? `<details class="oc-screen" open><summary>Shared screen</summary><iframe src="${esc(room.screen_url)}" title="The room's shared screen" loading="lazy" referrerpolicy="no-referrer" allow="clipboard-write; fullscreen"></iframe></details>` : ''}
--${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
++${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
mainLabel: room.name,
}),
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex aa4128b..fa0b8d2 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -232,7 +232,8 @@ ${sheet}`,
return await page(req, res, err.status || 503, { actor, shell: true, title: 'Forum unavailable', path: req.path, active: 'servers', robots: 'noindex', body: `<h1>Forum unavailable</h1><p>${esc(err.message)}</p><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
}
}
-- const msgs = await rooms.history(room, { limit: 60 });
++ const roomPage = await rooms.historyPage(room, { limit: 50 });
++ const msgs = roomPage.messages.slice().reverse();
if (me && rcan.role) await rooms.markRead(room, me);
const mine = me ? (await servers.list(me)).mine : [];
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
@@@ -294,7 +295,7 @@ ${sheet}`,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
-- view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,
++ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
},
@@@ -322,7 +323,7 @@ ${invitePanel}
${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
${aiHere}${errorOf(req)}
${isVoice ? callPanel(room, rcan, me) : ''}
--<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
++<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
${composer}`,
mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
members: shell.membersColumn({ groups: roleGroups }),
ddiff --git a/test/rest.test.js b/test/rest.test.js
iindex 43f824f..c1c5acb 100644
---- a/test/rest.test.js
++++ b/test/rest.test.js
@@@ -62,6 +62,13 @@ t('DMs: create 1:1 (idempotent), send, list with unread, read, delete own messag
assert.strictEqual(msgs.body.messages.length, 1);
assert.ok(!('sender_subject_id' in msgs.body.messages[0]));
const mid = msgs.body.messages[0].id;
++ await h.http('POST', `/api/dm/conversations/${convId}/messages`, { token: alice.token, body: { message: 'second hi bob' } });
++ const dmPage = await h.http('GET', `/api/dm/conversations/${convId}/messages?limit=1`, { token: bob.token });
++ assert.strictEqual(dmPage.body.messages[0].message, 'second hi bob');
++ assert.strictEqual(dmPage.body.more, true);
++ const dmOlder = await h.http('GET', `/api/dm/conversations/${convId}/messages?before=${dmPage.body.messages[0].id}&limit=1`, { token: bob.token });
++ assert.strictEqual(dmOlder.body.messages[0].message, 'hi bob');
++ assert.strictEqual(dmOlder.body.more, false);
assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${convId}/messages/${mid}`, { token: bob.token })).status, 403, 'only the sender deletes');
assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${convId}/messages/${mid}`, { token: alice.token })).status, 200);
});
ddiff --git a/test/room-ai-members.test.js b/test/room-ai-members.test.js
iindex 6df22a2..548e1b1 100644
---- a/test/room-ai-members.test.js
++++ b/test/room-ai-members.test.js
@@@ -77,7 +77,7 @@ t('@pantry answers with the recent messages and the room\'s notes, live, attribu
assert.deepStrictEqual([call.input.ai_name, call.input.persona, call.input.room], ['Pantry', 'A cheerful cook', 'Our Kitchen']);
assert.deepStrictEqual(call.o.attribution, { service: 'chat', type: 'room', id: String(reply.message.room_id), label: 'Our Kitchen' });
const hist = (await api('GET', '/our-kitchen/messages', ann)).body.messages;
-- assert.strictEqual(hist[hist.length - 1].display_name, 'Pantry', 'the history names it');
++ assert.strictEqual(hist[0].display_name, 'Pantry', 'the history names it');
bobWs.close();
});
@@@ -96,7 +96,7 @@ t('no answer without a mention, to itself, or past the room\'s day; mentions are
try {
await api('POST', '/our-kitchen/messages', ann, { message: '@pantry again?' });
await settle();
-- const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages.pop();
++ const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages[0];
assert.match(last.message, /my limit/, 'past the day\'s cap it says so instead of calling AI');
assert.strictEqual(calls.length, before);
} finally { config.ai.memberRepliesPerDay = keep; }
@@@ -116,7 +116,7 @@ t('AI is resting: the member says so in AI\'s own words instead of a generic fai
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed', error: { code: 'ai.resting', detail: 'AI is resting for a moment' } } }) } });
await api('POST', '/our-kitchen/messages', ann, { message: '@pantry one more question?' });
await settle(300);
-- const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages.pop();
++ const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages[0];
assert.match(last.message, /AI is resting for a moment/, last.message);
assert.ok(!/could not answer just now/.test(last.message), last.message);
});
ddiff --git a/test/room-ai-models.test.js b/test/room-ai-models.test.js
iindex e548b09..f9abe59 100644
---- a/test/room-ai-models.test.js
++++ b/test/room-ai-models.test.js
@@@ -109,11 +109,11 @@ t('@sage answers through the key (a credentialed run); outside the room\'s daily
assert.ok(run, 'a run went out');
assert.deepStrictEqual([run.workflow, run.credential], ['chat.member.reply', { subject: h.live.users.get(ann.id).subject_id }]);
assert.strictEqual(run.attribution.type, 'room');
-- let last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();
++ let last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages[0];
assert.deepStrictEqual([last.display_name, last.message], ['Sage', 'From gpt-4.1-mini: hello Bob']);
await api('POST', '/rooms/model-night/messages', bob, { message: '@pip hi' });
await settle();
-- last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();
++ last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages[0];
assert.match(last.message, /my limit/, 'OpenVibe\'s model keeps the room\'s cap');
assert.strictEqual(shared.length, 0);
} finally { config.ai.memberRepliesPerDay = keep; }
ddiff --git a/test/room-ai-tools.test.js b/test/room-ai-tools.test.js
iindex c754c2a..abd6767 100644
---- a/test/room-ai-tools.test.js
++++ b/test/room-ai-tools.test.js
@@@ -290,7 +290,7 @@ t('@dealer rolls through a tool: the call runs here, its result goes back, the t
assert.match(turns[0].input.messages[0].content, /You are Dealer/);
assert.match(turns[0].input.messages[1].content, /Ann just wrote to you: @dealer roll two dice/);
assert.deepStrictEqual([turns[0].o.idempotencyKey.endsWith('-t0'), turns[1].o.idempotencyKey.endsWith('-t1')], [true, true]);
-- const last = (await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop();
++ const last = (await api('GET', '/rooms/game-night/messages', ann)).body.messages[0];
assert.match(last.message, /^You rolled \d and \d: \d+\.$/);
assert.strictEqual(last.display_name, 'Dealer');
});
@@@ -301,14 +301,14 @@ t('after three rounds of tools the fourth turn must answer in text; a failing tu
await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer what time is it, really?' });
await settle();
assert.strictEqual(calls.length - before, 4);
-- assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'It is late.');
++ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages[0].message, 'It is late.');
before = calls.length;
script = []; // the turn fails (a provider without tools)
await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer hello' });
await settle();
assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.turn', 'chat.member.reply']);
-- assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'The plain reply.');
++ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages[0].message, 'The plain reply.');
});
t('CHAT_AI_MEMBER_TOOLS=0: straight to chat.member.reply', async () => {
ddiff --git a/test/room-pictures.test.js b/test/room-pictures.test.js
iindex d65c6e1..4145436 100644
---- a/test/room-pictures.test.js
++++ b/test/room-pictures.test.js
@@@ -99,7 +99,7 @@ t('the form works without JavaScript, and goes back to where it was sent from',
assert.strictEqual(r.status, 303);
assert.strictEqual(r.headers.get('location'), '/r/photo-walk#oc-compose');
const msgs = (await api('GET', '/photo-walk/messages', ann)).body.messages;
-- assert.match(msgs[msgs.length - 1].message, /^From the form https:\/\/openvibe\.pics\/i\//);
++ assert.match(msgs[0].message, /^From the form https:\/\/openvibe\.pics\/i\//);
const bad = multipart({ back: 'https://evil.example/' }, { name: 'p.png', type: 'image/png', buffer: PNG });
const r2 = await fetch(`${h.base}/r/photo-walk/picture`, { method: 'POST', headers: { cookie: `ov_token=${bob.token}`, 'content-type': bad.type }, body: bad.body, redirect: 'manual' });
assert.strictEqual(r2.headers.get('location'), '/r/photo-walk#oc-compose', 'never back to another site');
ddiff --git a/test/rooms.test.js b/test/rooms.test.js
iindex c0190e1..f212560 100644
---- a/test/rooms.test.js
++++ b/test/rooms.test.js
@@@ -44,7 +44,13 @@ t('create, list, join, post, history and unread', async () => {
list = (await api('GET', '/', bob)).body;
assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 0);
const hist = (await api('GET', '/night-owls/messages', null)).body;
-- assert.deepStrictEqual(hist.messages.map((m) => m.message), ['welcome in', 'second'], 'public rooms are readable signed out');
++ assert.deepStrictEqual(hist.messages.map((m) => m.message), ['second', 'welcome in'], 'public rooms are readable signed out, newest first');
++ const newest = (await api('GET', '/night-owls/messages?limit=1', null)).body;
++ assert.strictEqual(newest.messages[0].message, 'second');
++ assert.strictEqual(newest.more, true);
++ const older = (await api('GET', `/night-owls/messages?before=${newest.messages[0].id}&limit=1`, null)).body;
++ assert.deepStrictEqual(older.messages.map((m) => m.message), ['welcome in']);
++ assert.strictEqual(older.more, false);
assert.deepStrictEqual((await api('GET', `/night-owls/messages?after=${first}`, null)).body.messages.map((m) => m.message), ['second']);
});
ddiff --git a/test/vibe-files.test.js b/test/vibe-files.test.js
iindex df86116..c583822 100644
---- a/test/vibe-files.test.js
++++ b/test/vibe-files.test.js
@@@ -61,7 +61,8 @@ t('a message with a file stores the reference and Vibe\'s turn carries it to AI'
assert.equal(turns[0].input.files.length, 1);
assert.equal(turns[0].input.files[0].id, file.id);
const history = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token });
-- assert.deepEqual(history.body.messages[0].files.map((f) => f.id), [file.id], 'the message keeps the reference');
++ const withFile = history.body.messages.find((m) => (m.files || []).some((f) => f.id === file.id));
++ assert.ok(withFile, 'the message keeps the reference');
});
t('the composer page carries the attachment tray and the shared upload kit', async () => {
ddiff --git a/test/vibe-how-steps.test.js b/test/vibe-how-steps.test.js
iindex de8e708..d20a0ac 100644
---- a/test/vibe-how-steps.test.js
++++ b/test/vibe-how-steps.test.js
@@@ -18,7 +18,7 @@ const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistan
const vibeApi = (method, path, body) => h.http(method, `/api/chat/vibe${path}`, { token: ann.token, body });
async function lastAi(slug) {
for (let i = 0; i < 80; i++) {
-- const msgs = (await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || [];
++ const msgs = ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []).slice().reverse();
const ai = msgs.filter((m) => m.ai_member_id);
if (ai.length) return ai[ai.length - 1];
await new Promise((r) => setTimeout(r, 25));
ddiff --git a/test/vibe-modes.test.js b/test/vibe-modes.test.js
iindex 66249d3..af91354 100644
---- a/test/vibe-modes.test.js
++++ b/test/vibe-modes.test.js
@@@ -17,7 +17,7 @@ const turns = [];
const actorCalls = [];
let actorTask = { id: 'tsk_r1', state: 'queued' };
const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u && u.token, body });
--const messagesOf = async (slug, as = ann) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: as.token })).body.messages || []);
++const messagesOf = async (slug, as = ann) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: as.token })).body.messages || []).slice().reverse();
const realFetch = globalThis.fetch;
t('boot', async () => {
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex a87428a..b4b6973 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -26,7 +26,7 @@ const web = async (method, path, u, fields) => {
/** Vibe's answers in a chat, waiting until there are `n`. */
async function answers(s, n) {
for (let i = 0; i < 60; i++) {
-- const got = ((await rooms('GET', `/${s}/messages`, ann)).body.messages || []).filter((m) => m.ai_member_id);
++ const got = ((await rooms('GET', `/${s}/messages`, ann)).body.messages || []).slice().reverse().filter((m) => m.ai_member_id);
if (got.length >= n) return got;
await new Promise((r) => setTimeout(r, 50));
}
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 5e95a74..29e11ef 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -269,12 +269,14 @@ const runClient = (file, page, docCookie) => {
function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
WebSocket.prototype.send = function (d) { this.sent.push(JSON.parse(d)); };
WebSocket.prototype.close = function () {};
-- require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), {
++ const context = {
window: { __OV_PAGE: { chat: page }, RTCPeerConnection: function () {}, WebSocket, addEventListener() {} },
WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },
document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },
fetch: async () => ({ ok: false }), setTimeout: () => 0, setInterval: () => 0,
-- });
++ };
++ if (file === 'chat.js') require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', 'chat-history.js'), 'utf8'), context);
++ require('vm').runInNewContext(require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', file), 'utf8'), context);
return { sockets, listeners };
};
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex 23579a0..62e6a88 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -82,7 +82,7 @@ t('a quick task runs on Actor as the person who asked; its result comes back fro
assert.deepStrictEqual(r.body.task.sources, ['https://example.com/a', 'https://example.com/b'], 'only http(s) links');
assert.strictEqual(r.body.task.cost_usd, 0.0031);
const msgs = (await api('GET', '/rooms/staff/messages', staff)).body.messages;
-- const last = msgs[msgs.length - 1];
++ const last = msgs[0];
assert.ok(last.ai_member_id && /^✅ Done: "Why does the Live home load slowly on phones" \(asked by Mia\)/.test(last.message), last.message);
assert.match(last.message, /Three large images block the first paint\./);
assert.match(last.message, new RegExp(`/r/staff/tasks/${task.id}$`));
@@@ -108,7 +108,7 @@ t('code work waits for a worker: claim, report, the room hears it', async () =>
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.body.task.status, 'succeeded');
const msgs = (await api('GET', '/rooms/staff/messages', staff)).body.messages;
-- assert.match(msgs[msgs.length - 1].message, /^✅ Done: "Add a dark favicon to Tools"[\s\S]*https:\/\/github\.com\/OpenVibers\/OpenVibe\.Tools\/pull\/1/);
++ assert.match(msgs[0].message, /^✅ Done: "Add a dark favicon to Tools"[\s\S]*https:\/\/github\.com\/OpenVibers\/OpenVibe\.Tools\/pull\/1/);
// The agent's step (design 2026-11-07): claim and report are its two changes, one row each. The payload is a
// generated title, an outcome and a link: never the task's title (the person's words) or its result.
const steps = (await h.db.getDb().prepare('SELECT envelope FROM service_outbox ORDER BY id').all())