openvibers/openvibe.chat Commits
Chat: page older messages in every room (#475)
Changes
nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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);@@@ -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; })@@@ -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' });@@@ -251,6 +251,21 @@ async function getMessages(conversationId, limit = 50, before = null, after = nureturn 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,@@@ -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,};@@@ -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); }});@@@ -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>` : ''}@@@ -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,}),@@@ -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 }),@@@ -62,6 +62,13 @@ t('DMs: create 1:1 (idempotent), send, list with unread, read, delete own messagassert.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);});@@@ -77,7 +77,7 @@ t('@pantry answers with the recent messages and the room\'s notes, live, attribuassert.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 aretry {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 faiaiClient._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);});@@@ -109,11 +109,11 @@ t('@sage answers through the key (a credentialed run); outside the room\'s dailyassert.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; }@@@ -290,7 +290,7 @@ t('@dealer rolls through a tool: the call runs here, its result goes back, the tassert.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 tuawait 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 () => {@@@ -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');@@@ -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']);});@@@ -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 () => {@@@ -18,7 +18,7 @@ const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistanconst 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));@@@ -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 () => {@@@ -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));}@@@ -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 };};@@@ -82,7 +82,7 @@ t('a quick task runs on Actor as the person who asked; its result comes back froassert.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())
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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);
@@ -168,7 +168,11 @@
@@ -168,7 +168,11 @@
var stick = nearBottom();
var stick = nearBottom();
removeEmpty();
removeEmpty();
feed.appendChild(li);
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();
if (stick) toBottom();
}
}
function showError(text) {
function showError(text) {
@@ -235,29 +239,11 @@
@@ -235,29 +239,11 @@
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; }
+ 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.
// 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() {
@@ -324,6 +310,10 @@
@@ -324,6 +310,10 @@
}
}
function startDm() {
function startDm() {
var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
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() {
function poll() {
if (document.hidden) return;
if (document.hidden) return;
fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })
fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })
@@ -457,6 +447,10 @@
@@ -457,6 +447,10 @@
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 api = '/api/chat/rooms/' + encodeURIComponent(cfg.room);
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() {
function catchUpRoom() {
return fetch(api + '/messages?after=' + latest + '&limit=200', { credentials: 'same-origin' })
return fetch(api + '/messages?after=' + latest + '&limit=200', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) { return r.ok ? r.json() : null; })
@@ -218,10 +218,13 @@ router.get('/conversations/:id/messages', async (req, res) => {
@@ -218,10 +218,13 @@ router.get('/conversations/:id/messages', async (req, res) => {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
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 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) {
} catch (err) {
console.error('[DM] Get messages error:', err.message);
console.error('[DM] Get messages error:', err.message);
res.status(500).json({ error: 'Failed to load messages' });
res.status(500).json({ error: 'Failed to load messages' });
@@ -251,6 +251,21 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
@@ -251,6 +251,21 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
return await linkCards.hydrate(rows, 'dm_messages');
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).
* Mark a conversation as read for a user (set last_read_at to now).
*/
*/
@@ -461,7 +476,7 @@ module.exports = {
@@ -461,7 +476,7 @@ module.exports = {
getParticipants,
getParticipants,
getConversation,
getConversation,
sendMessage,
sendMessage,
- getMessages,
+ getMessages, getMessagesPage,
markRead,
markRead,
getTotalUnread,
getTotalUnread,
searchUsers,
searchUsers,
@@ -369,7 +369,21 @@ async function messageById(id) {
@@ -369,7 +369,21 @@ async function messageById(id) {
return row;
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 } = {}) {
async function history(room, { before = null, after = null, limit = 60 } = {}) {
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
const rows = after != null
const rows = after != null
@@ -558,7 +572,7 @@ module.exports = {
@@ -558,7 +572,7 @@ module.exports = {
LIMITS,
LIMITS,
isWorkspace, isStaffRoom,
isWorkspace, isStaffRoom,
postAsAi,
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,
attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
};
@@ -201,8 +201,13 @@ router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
@@ -201,8 +201,13 @@ router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.get('/:slug/messages', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
try {
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); }
} catch (err) { send(res, err); }
});
});
@@ -176,7 +176,8 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
@@ -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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.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.shell ? `<script src="${asset('chat-shell.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
@@ -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: 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).
// 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"><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
// 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 () => {
@@ -330,7 +330,7 @@ ${composer}`,
@@ -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.',
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.
// 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)],
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',
shell: signedIn, footer: signedIn ? 'none' : 'full',
body: signedIn ? shellBody : `<header class="oc-hero">
body: signedIn ? shellBody : `<header class="oc-hero">
<h1>Chat with everyone on OpenVibe, or start your own server</h1>
<h1>Chat with everyone on OpenVibe, or start your own server</h1>
@@ -545,7 +545,8 @@ ${blockedList}`,
@@ -545,7 +545,8 @@ ${blockedList}`,
await presence.attachTo(me.subject_id, others);
await presence.attachTo(me.subject_id, others);
const conv = await dm.getConversation(id);
const conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
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 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 viewKind = others.length <= 1 ? 'one_to_one' : 'group';
const viewDefaults = await require('../chat/view-defaults').of(me.id);
const viewDefaults = await require('../chat/view-defaults').of(me.id);
@@ -589,13 +590,13 @@ ${blockedList}`,
@@ -589,13 +590,13 @@ ${blockedList}`,
const dmUnread = dms.reduce((n, d) => n + (d.current ? 0 : d.unread), 0);
const dmUnread = dms.reduce((n, d) => n + (d.current ? 0 : d.unread), 0);
await page(req, res, 200, {
await page(req, res, 200, {
actor, shell: true, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
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({
body: shell.shell({
rail: shell.rail({ home: true, dmUnread, servers: shell.railServers(mineServers) }),
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 || '' } }),
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',
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}
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}`,
${composer}`,
mainLabel: title,
mainLabel: title,
}),
}),
@@ -1028,7 +1029,8 @@ ${people}
@@ -1028,7 +1029,8 @@ ${people}
if (room.server_id) return res.redirect(302, await channelUrl(room));
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>).
// 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)}`);
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);
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
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';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
@@ -1068,7 +1070,7 @@ ${people}
@@ -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,
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'] : [])],
footer: 'none', shell: true, upload: pictures.enabled(), scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],
description,
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 } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: shell.shell({
body: shell.shell({
rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
@@ -1078,7 +1080,7 @@ ${people}
@@ -1078,7 +1080,7 @@ ${people}
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
}),
}),
sideLabel: 'Rooms',
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>
<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>
<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>
<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}
@@ -1090,7 +1092,7 @@ ${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
${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 && 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>` : ''}`,
${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
mainLabel: room.name,
mainLabel: room.name,
}),
}),
@@ -232,7 +232,8 @@ ${sheet}`,
@@ -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>` });
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);
if (me && rcan.role) await rooms.markRead(room, me);
const mine = me ? (await servers.list(me)).mine : [];
const mine = me ? (await servers.list(me)).mine : [];
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
@@ -294,7 +295,7 @@ ${sheet}`,
@@ -294,7 +295,7 @@ ${sheet}`,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
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'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
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,
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 } } : {}),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
},
},
@@ -322,7 +323,7 @@ ${invitePanel}
@@ -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>` : ''}
${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)}
${aiHere}${errorOf(req)}
${isVoice ? callPanel(room, rcan, me) : ''}
${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}`,
${composer}`,
mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
members: shell.membersColumn({ groups: roleGroups }),
members: shell.membersColumn({ groups: roleGroups }),
@@ -62,6 +62,13 @@ t('DMs: create 1:1 (idempotent), send, list with unread, read, delete own messag
@@ -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.strictEqual(msgs.body.messages.length, 1);
assert.ok(!('sender_subject_id' in msgs.body.messages[0]));
assert.ok(!('sender_subject_id' in msgs.body.messages[0]));
const mid = msgs.body.messages[0].id;
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: 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);
assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${convId}/messages/${mid}`, { token: alice.token })).status, 200);
});
});
@@ -77,7 +77,7 @@ t('@pantry answers with the recent messages and the room\'s notes, live, attribu
@@ -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.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' });
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;
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();
bobWs.close();
});
});
@@ -96,7 +96,7 @@ t('no answer without a mention, to itself, or past the room\'s day; mentions are
@@ -96,7 +96,7 @@ t('no answer without a mention, to itself, or past the room\'s day; mentions are
try {
try {
await api('POST', '/our-kitchen/messages', ann, { message: '@pantry again?' });
await api('POST', '/our-kitchen/messages', ann, { message: '@pantry again?' });
await settle();
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.match(last.message, /my limit/, 'past the day\'s cap it says so instead of calling AI');
assert.strictEqual(calls.length, before);
assert.strictEqual(calls.length, before);
} finally { config.ai.memberRepliesPerDay = keep; }
} 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
@@ -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' } } }) } });
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 api('POST', '/our-kitchen/messages', ann, { message: '@pantry one more question?' });
await settle(300);
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.match(last.message, /AI is resting for a moment/, last.message);
assert.ok(!/could not answer just now/.test(last.message), last.message);
assert.ok(!/could not answer just now/.test(last.message), last.message);
});
});
@@ -109,11 +109,11 @@ t('@sage answers through the key (a credentialed run); outside the room\'s daily
@@ -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.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.deepStrictEqual([run.workflow, run.credential], ['chat.member.reply', { subject: h.live.users.get(ann.id).subject_id }]);
assert.strictEqual(run.attribution.type, 'room');
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']);
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 api('POST', '/rooms/model-night/messages', bob, { message: '@pip hi' });
await settle();
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.match(last.message, /my limit/, 'OpenVibe\'s model keeps the room\'s cap');
assert.strictEqual(shared.length, 0);
assert.strictEqual(shared.length, 0);
} finally { config.ai.memberRepliesPerDay = keep; }
} finally { config.ai.memberRepliesPerDay = keep; }
@@ -290,7 +290,7 @@ t('@dealer rolls through a tool: the call runs here, its result goes back, the t
@@ -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[0].content, /You are Dealer/);
assert.match(turns[0].input.messages[1].content, /Ann just wrote to you: @dealer roll two dice/);
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]);
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.match(last.message, /^You rolled \d and \d: \d+\.$/);
assert.strictEqual(last.display_name, 'Dealer');
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
@@ -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 api('POST', '/rooms/game-night/messages', ann, { message: '@dealer what time is it, really?' });
await settle();
await settle();
assert.strictEqual(calls.length - before, 4);
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;
before = calls.length;
script = []; // the turn fails (a provider without tools)
script = []; // the turn fails (a provider without tools)
await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer hello' });
await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer hello' });
await settle();
await settle();
assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.turn', 'chat.member.reply']);
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 () => {
t('CHAT_AI_MEMBER_TOOLS=0: straight to chat.member.reply', async () => {
@@ -99,7 +99,7 @@ t('the form works without JavaScript, and goes back to where it was sent from',
@@ -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.status, 303);
assert.strictEqual(r.headers.get('location'), '/r/photo-walk#oc-compose');
assert.strictEqual(r.headers.get('location'), '/r/photo-walk#oc-compose');
const msgs = (await api('GET', '/photo-walk/messages', ann)).body.messages;
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 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' });
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');
assert.strictEqual(r2.headers.get('location'), '/r/photo-walk#oc-compose', 'never back to another site');
@@ -44,7 +44,13 @@ t('create, list, join, post, history and unread', async () => {
@@ -44,7 +44,13 @@ t('create, list, join, post, history and unread', async () => {
list = (await api('GET', '/', bob)).body;
list = (await api('GET', '/', bob)).body;
assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 0);
assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 0);
const hist = (await api('GET', '/night-owls/messages', null)).body;
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']);
assert.deepStrictEqual((await api('GET', `/night-owls/messages?after=${first}`, null)).body.messages.map((m) => m.message), ['second']);
});
});
@@ -61,7 +61,8 @@ t('a message with a file stores the reference and Vibe\'s turn carries it to AI'
@@ -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.length, 1);
assert.equal(turns[0].input.files[0].id, file.id);
assert.equal(turns[0].input.files[0].id, file.id);
const history = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token });
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 () => {
t('the composer page carries the attachment tray and the shared upload kit', async () => {
@@ -18,7 +18,7 @@ const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistan
@@ -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 });
const vibeApi = (method, path, body) => h.http(method, `/api/chat/vibe${path}`, { token: ann.token, body });
async function lastAi(slug) {
async function lastAi(slug) {
for (let i = 0; i < 80; i++) {
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);
const ai = msgs.filter((m) => m.ai_member_id);
if (ai.length) return ai[ai.length - 1];
if (ai.length) return ai[ai.length - 1];
await new Promise((r) => setTimeout(r, 25));
await new Promise((r) => setTimeout(r, 25));
@@ -17,7 +17,7 @@ const turns = [];
@@ -17,7 +17,7 @@ const turns = [];
const actorCalls = [];
const actorCalls = [];
let actorTask = { id: 'tsk_r1', state: 'queued' };
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 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;
const realFetch = globalThis.fetch;
t('boot', async () => {
t('boot', async () => {
@@ -26,7 +26,7 @@ const web = async (method, path, u, fields) => {
@@ -26,7 +26,7 @@ const web = async (method, path, u, fields) => {
/** Vibe's answers in a chat, waiting until there are `n`. */
/** Vibe's answers in a chat, waiting until there are `n`. */
async function answers(s, n) {
async function answers(s, n) {
for (let i = 0; i < 60; i++) {
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;
if (got.length >= n) return got;
await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 50));
}
}
@@ -269,12 +269,14 @@ const runClient = (file, page, docCookie) => {
@@ -269,12 +269,14 @@ const runClient = (file, page, docCookie) => {
function WebSocket(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
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.send = function (d) { this.sent.push(JSON.parse(d)); };
WebSocket.prototype.close = function () {};
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() {} },
window: { __OV_PAGE: { chat: page }, RTCPeerConnection: function () {}, WebSocket, addEventListener() {} },
WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },
WebSocket, navigator: {}, location: { protocol: 'https:', host: 'openvibe.chat' },
document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },
document: { cookie: docCookie, hidden: false, getElementById: element, createElement: () => element(null), addEventListener() {} },
fetch: async () => ({ ok: false }), setTimeout: () => 0, setInterval: () => 0,
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 };
return { sockets, listeners };
};
};
@@ -82,7 +82,7 @@ t('a quick task runs on Actor as the person who asked; its result comes back fro
@@ -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.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);
assert.strictEqual(r.body.task.cost_usd, 0.0031);
const msgs = (await api('GET', '/rooms/staff/messages', staff)).body.messages;
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.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, /Three large images block the first paint\./);
assert.match(last.message, new RegExp(`/r/staff/tasks/${task.id}$`));
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 () =>
@@ -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.status, 200, r.text);
assert.strictEqual(r.body.task.status, 'succeeded');
assert.strictEqual(r.body.task.status, 'succeeded');
const msgs = (await api('GET', '/rooms/staff/messages', staff)).body.messages;
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
// 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.
// 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())
const steps = (await h.db.getDb().prepare('SELECT envelope FROM service_outbox ORDER BY id').all())