diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js
index 236c0b7c..f9b88e73 100644
--- a/src/frontend/web/von_interface/static/js/components/messagePanel.js
+++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js
@@ -823,10 +823,7 @@ async function loadConversation(userId, { silent = false, before = null, observe
const generation = _exchangeGeneration;
const org = getSessionScopedOrgId();
const contentEl = _messagesContainer?.querySelector('#messageViewContent');
- const oldTop = contentEl?.scrollTop || 0;
- const oldHeight = contentEl?.scrollHeight || 0;
- const nearBottom = !contentEl || oldHeight - oldTop - contentEl.clientHeight < 60;
- if (contentEl && !silent) contentEl.innerHTML = '
Loading conversation…
';
+ if (contentEl && !silent && !_currentMessages.length) contentEl.innerHTML = 'Loading conversation…
';
try {
const response = _exchange
? await postJson('/api/messages/exchange', { participant_ids: _exchange.participant_ids, organisation_concept_id: _exchange.organisation_concept_id || null, before })
@@ -854,21 +851,35 @@ async function loadConversation(userId, { silent = false, before = null, observe
_currentUserId = response.current_user_id || null;
_readObserver?.disconnect();
_readObserver = null;
- await renderMessages();
+ // Capture at render time: the reader may have scrolled during the request.
+ // Keep a visible contribution at the same offset, including when earlier
+ // pages or edits change the height above it. Never implicitly follow unread.
+ const oldTop = contentEl?.scrollTop || 0;
+ const viewportTop = contentEl?.getBoundingClientRect().top || 0;
+ const retainedIds = new Set(_currentMessages.map(message => message.concept_id));
+ const anchor = [...(contentEl?.querySelectorAll('[data-contribution-id]') || [])]
+ .find(element => retainedIds.has(element.dataset.contributionId)
+ && element.getBoundingClientRect().bottom > viewportTop);
+ const anchorId = anchor?.dataset.contributionId;
+ const anchorOffset = anchor ? anchor.getBoundingClientRect().top - viewportTop : 0;
+ await renderMessages(() => {
+ if (_olderCursor && contentEl) {
+ const earlier = document.createElement('button'); earlier.type = 'button'; earlier.textContent = 'Load earlier messages';
+ earlier.onclick = () => void loadConversation(userId, { silent: true, before: _olderCursor });
+ contentEl.prepend(earlier);
+ }
+ updateUnreadMarkers();
+ if (contentEl) {
+ const restoredAnchor = [...contentEl.querySelectorAll('[data-contribution-id]')]
+ .find(element => element.dataset.contributionId === anchorId);
+ contentEl.scrollTop = restoredAnchor
+ ? contentEl.scrollTop + restoredAnchor.getBoundingClientRect().top
+ - contentEl.getBoundingClientRect().top - anchorOffset
+ : oldTop;
+ }
+ });
if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId()) return;
restoreReplyDeliveryAttemptForCurrentScope();
- if (_olderCursor && contentEl) {
- const earlier = document.createElement('button'); earlier.type = 'button'; earlier.textContent = 'Load earlier messages';
- earlier.onclick = () => void loadConversation(userId, { silent: true, before: _olderCursor });
- contentEl.prepend(earlier);
- }
- if (contentEl && before) contentEl.scrollTop = oldTop + contentEl.scrollHeight - oldHeight;
- else if (contentEl && silent && !nearBottom) contentEl.scrollTop = oldTop;
- else if (contentEl) contentEl.scrollTop = contentEl.scrollHeight;
- updateUnreadMarkers();
- // Opening starts at the first loaded unread contribution, without
- // acknowledging earlier/unloaded or skipped contributions.
- if (contentEl && !silent) contentEl.querySelector('.is-unread')?.scrollIntoView?.({ block: 'start' });
updateMessageNavigation();
if (observeReads) observeDisplayedMessages();
return true;
@@ -1153,7 +1164,7 @@ function selectReplyContext(messageId) {
/**
* Render messages in the conversation view.
*/
-async function renderMessages() {
+async function renderMessages(onRendered = () => {}) {
const contentEl = _messagesContainer?.querySelector('#messageViewContent');
if (!contentEl) return;
@@ -1165,6 +1176,7 @@ async function renderMessages() {
Send the first message!
`;
+ onRendered();
return;
}
@@ -1280,6 +1292,8 @@ async function renderMessages() {
}));
});
});
+ // Restore before asynchronous hydration yields a frame with the new DOM.
+ onRendered();
await hydrateConceptCartouchesInRoot(contentEl);
// The loader restores the viewport before attaching read observers.
diff --git a/tests/browser/messageUnreadNavigation.cjs b/tests/browser/messageUnreadNavigation.cjs
index 190dbc1b..5f45dba9 100644
--- a/tests/browser/messageUnreadNavigation.cjs
+++ b/tests/browser/messageUnreadNavigation.cjs
@@ -6,16 +6,19 @@ const http = require('node:http');
const path = require('node:path');
const { chromium, expect } = require('@playwright/test');
const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
+const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8').replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, '');
const evidence = process.argv[2] || '.run/unread-navigation';
fs.mkdirSync(evidence, { recursive: true });
let cursors = [];
+let background = false;
+let pendingPage = null;
const message = (id, unread, day) => ({ concept_id: id, created_at: `2026-09-${day}T12:00:00Z`,
relationships: { '#V#has_sender': ['#V#bob'], '#V#has_recipient': ['#V#alice'] },
- concept_data: { content_fallback: id, read_by: unread ? [] : ['#V#alice'] } });
+ concept_data: { content_fallback: `${id}: ${'A realistic message for checking the reading position. '.repeat(5)}`, read_by: unread ? [] : ['#V#alice'] } });
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html');
- return res.end('');
+ return res.end(`${markup}`);
}
if (req.url.startsWith('/static/')) {
const file = path.resolve(root, `.${req.url}`);
@@ -28,10 +31,14 @@ const server = http.createServer(async (req, res) => {
if (req.url === '/api/messages/exchange') {
let body = ''; for await (const chunk of req) body += chunk;
const { before } = JSON.parse(body); cursors.push(before);
- const result = !before ? { messages: [message('Latest read message', false, '13')], before: 'middle' }
- : before === 'middle' ? { messages: [message('Middle read message', false, '12')], before: 'older' }
+ const initial = Array.from({ length: 20 }, (_, index) => message(`latest-${String(index).padStart(2, '0')}`, false, '13'));
+ const result = !before ? { messages: background ? [...initial, message('Background unread', true, '14')] : initial, before: 'middle' }
+ : before === 'middle' ? { messages: Array.from({ length: 10 }, (_, index) => message(`middle-${index}`, false, '12')), before: 'older' }
: { messages: [message('Earlier unread', true, '10'), message('Most recent unread', true, '11')], before: null };
- return res.end(JSON.stringify({ ...result, current_user_id: '#V#alice' }));
+ const finish = () => res.end(JSON.stringify({ ...result, current_user_id: '#V#alice' }));
+ if (before || background) pendingPage = finish;
+ else finish();
+ return;
}
res.end(JSON.stringify({ success: true, messages: [], profiles: [] }));
});
@@ -42,25 +49,68 @@ const server = http.createServer(async (req, res) => {
const results = [];
for (const width of [390, 1280]) {
cursors = [];
+ background = false;
const page = await browser.newPage({ viewport: { width, height: 850 } });
const errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.evaluate(async () => {
// Keep read markers stable while checking navigation independently.
window.IntersectionObserver = undefined;
+ document.getElementById('conversationWorkspace').classList.add('show-message-exchange');
+ document.body.classList.add('viewing-message-exchange');
const { openMessageExchange } = await import('/static/js/components/messagePanel.js');
await openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice',
participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'],
session_name: 'Unread pagination fixture', shared_unread_count: 2 });
});
+ const content = page.locator('#messageViewContent');
+ assert.equal(await content.evaluate(el => el.scrollTop), 0, 'default loading preserves the top');
+ await content.evaluate(el => { el.scrollTop = el.scrollHeight - el.clientHeight - 10; });
+ const bottomPosition = await content.evaluate(el => el.scrollTop);
+ assert.ok(bottomPosition > 0, 'fixture must overflow');
+ background = true;
+ await page.evaluate(() => {
+ window.refreshDone = import('/static/js/components/messagePanel.js').then(panel => panel.refreshOpenMessageExchange());
+ });
+ await expect.poll(() => Boolean(pendingPage)).toBe(true);
+ assert.equal(await content.evaluate(el => el.scrollTop), bottomPosition, 'pending background request must preserve position');
+ pendingPage(); pendingPage = null;
+ await page.evaluate(() => window.refreshDone);
+ assert.equal(await content.evaluate(el => el.scrollTop), bottomPosition, 'background unread must not follow the bottom');
+ // Reopen the initial fixture, then traverse delayed earlier pages.
+ background = false;
+ await page.evaluate(async () => {
+ const panel = await import('/static/js/components/messagePanel.js');
+ await panel.openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice',
+ participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'],
+ session_name: 'Unread pagination fixture', shared_unread_count: 2 });
+ });
+ cursors = [];
+ await content.evaluate(el => { el.scrollTop = 450; });
+ const anchor = await content.evaluate(el => {
+ const top = el.getBoundingClientRect().top;
+ const item = [...el.querySelectorAll('[data-contribution-id]')].find(item => item.getBoundingClientRect().bottom > top);
+ return { id: item.dataset.contributionId, offset: item.getBoundingClientRect().top - top };
+ });
+ const offset = () => page.locator(`[data-contribution-id="${anchor.id}"]`).evaluate(el =>
+ el.getBoundingClientRect().top - document.getElementById('messageViewContent').getBoundingClientRect().top);
await page.getByRole('button', { name: 'Jump to most recent unread', exact: true }).click();
+ await expect.poll(() => Boolean(pendingPage)).toBe(true);
+ assert.equal(await offset(), anchor.offset, 'pending go-to-unread preserves the visible message');
+ pendingPage(); pendingPage = null;
+ await expect.poll(() => Boolean(pendingPage)).toBe(true);
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ assert.ok(Math.abs(await offset() - anchor.offset) <= 1, 'intermediate page preserves the visible message within one CSS pixel');
+ await expect(page.getByRole('button', { name: 'Loading unread messages…', exact: true })).toBeVisible();
+ await page.screenshot({ path: path.join(evidence, `loading-${width}.png`) });
+ pendingPage(); pendingPage = null;
const target = page.locator('[data-contribution-id="Most recent unread"]');
await expect(target).toBeFocused();
await expect(target).toBeInViewport();
- assert.deepEqual(cursors, [null, 'middle', 'older']);
+ assert.deepEqual(cursors, ['middle', 'older']);
assert.deepEqual(errors, []);
await page.screenshot({ path: path.join(evidence, `unread-${width}.png`) });
- results.push({ width, cursors: [...cursors], mostRecentUnreadFocused: true, targetInViewport: true });
+ results.push({ width, cursors: [...cursors], defaultTopPreserved: true, backgroundPosition: bottomPosition, intermediateAnchorOffset: anchor.offset, mostRecentUnreadFocused: true, targetInViewport: true });
await page.close();
}
fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2));
diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js
index 1f5675b8..dcf2d122 100644
--- a/tests/frontend/messageExchangeView.test.js
+++ b/tests/frontend/messageExchangeView.test.js
@@ -280,3 +280,74 @@ test('exhausting earlier pages reports stale unread counts and hides the action'
expect(document.querySelector('.message-unread-navigation-status').textContent).toContain('No unread messages remain');
expect(document.querySelector('.message-jump-unread').hidden).toBe(true);
});
+
+test('default loading does not navigate to unread or to the bottom', async () => {
+ HTMLElement.prototype.scrollIntoView = jest.fn();
+ const api = require(base + 'apiService.js');
+ let finish;
+ api.postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
+ const opening = require(base + 'components/messagePanel.js').openMessageExchange(row('#V#bob'));
+ const content = document.getElementById('messageViewContent');
+ Object.defineProperty(content, 'scrollHeight', { configurable: true, value: 1000 });
+ content.scrollTop = 30;
+ finish(unreadResponse());
+ await opening;
+ expect(content.scrollTop).toBe(30);
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+});
+
+test('background unread loading preserves even a near-bottom reader and scrolling during the request', async () => {
+ HTMLElement.prototype.scrollIntoView = jest.fn();
+ const panel = require(base + 'components/messagePanel.js');
+ await panel.openMessageExchange(row('#V#bob'));
+ const content = document.getElementById('messageViewContent');
+ Object.defineProperties(content, {
+ scrollHeight: { configurable: true, value: 1000 },
+ clientHeight: { configurable: true, value: 200 }
+ });
+ content.scrollTop = 790;
+ let finish;
+ require(base + 'apiService.js').postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
+ const refresh = panel.refreshOpenMessageExchange();
+ expect(content.querySelector('[data-contribution-id="one"]')).not.toBeNull();
+ expect(content.scrollTop).toBe(790);
+ content.scrollTop = 450;
+ const updated = unreadResponse();
+ updated.messages.push({ ...response('two').messages[0], created_at: '2026-09-12T12:00:00Z' });
+ finish(updated);
+ await refresh;
+ expect(content.scrollTop).toBe(450);
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+});
+
+test('deferred unread navigation preserves the visible contribution across intermediate pages before jumping', async () => {
+ HTMLElement.prototype.scrollIntoView = jest.fn();
+ const api = require(base + 'apiService.js');
+ api.postJson.mockResolvedValueOnce(page(['latest'], 'page2'));
+ await require(base + 'components/messagePanel.js').openMessageExchange({ ...row('#V#bob'), shared_unread_count: 1 });
+ const content = document.getElementById('messageViewContent');
+ content.scrollTop = 25;
+ const geometry = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
+ if (this.dataset.contributionId === 'latest') {
+ const top = 100 + (content.querySelector('[data-contribution-id="middle"]') ? 200 : 0) - content.scrollTop;
+ return { top, bottom: top + 100 };
+ }
+ return { top: 0, bottom: 0 };
+ });
+ let finishMiddle, finishUnread;
+ api.postJson.mockImplementationOnce(() => new Promise(resolve => { finishMiddle = resolve; }))
+ .mockImplementationOnce(() => new Promise(resolve => { finishUnread = resolve; }));
+ const jump = document.querySelector('.message-jump-unread');
+ jump.click();
+ expect(jump.textContent).toBe('Loading unread messages…');
+ expect(content.scrollTop).toBe(25);
+ content.scrollTop = 40;
+ finishMiddle(page(['middle'], 'page3')); await flush();
+ expect(content.scrollTop).toBe(240);
+ expect(content.querySelector('[data-contribution-id="latest"]').getBoundingClientRect().top).toBe(60);
+ expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+ finishUnread(page(['unread'], null, ['unread'])); await flush();
+ expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(document.activeElement.dataset.contributionId).toBe('unread');
+ geometry.mockRestore();
+});