Skip to content

Commit ee73f53

Browse files
authored
Preserve message reading position while loading unread pages (#717)
1 parent 93fc896 commit ee73f53

3 files changed

Lines changed: 160 additions & 25 deletions

File tree

‎src/frontend/web/von_interface/static/js/components/messagePanel.js‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -823,10 +823,7 @@ async function loadConversation(userId, { silent = false, before = null, observe
823823
const generation = _exchangeGeneration;
824824
const org = getSessionScopedOrgId();
825825
const contentEl = _messagesContainer?.querySelector('#messageViewContent');
826-
const oldTop = contentEl?.scrollTop || 0;
827-
const oldHeight = contentEl?.scrollHeight || 0;
828-
const nearBottom = !contentEl || oldHeight - oldTop - contentEl.clientHeight < 60;
829-
if (contentEl && !silent) contentEl.innerHTML = '<div class="loading">Loading conversation…</div>';
826+
if (contentEl && !silent && !_currentMessages.length) contentEl.innerHTML = '<div class="loading">Loading conversation…</div>';
830827
try {
831828
const response = _exchange
832829
? 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
854851
_currentUserId = response.current_user_id || null;
855852
_readObserver?.disconnect();
856853
_readObserver = null;
857-
await renderMessages();
854+
// Capture at render time: the reader may have scrolled during the request.
855+
// Keep a visible contribution at the same offset, including when earlier
856+
// pages or edits change the height above it. Never implicitly follow unread.
857+
const oldTop = contentEl?.scrollTop || 0;
858+
const viewportTop = contentEl?.getBoundingClientRect().top || 0;
859+
const retainedIds = new Set(_currentMessages.map(message => message.concept_id));
860+
const anchor = [...(contentEl?.querySelectorAll('[data-contribution-id]') || [])]
861+
.find(element => retainedIds.has(element.dataset.contributionId)
862+
&& element.getBoundingClientRect().bottom > viewportTop);
863+
const anchorId = anchor?.dataset.contributionId;
864+
const anchorOffset = anchor ? anchor.getBoundingClientRect().top - viewportTop : 0;
865+
await renderMessages(() => {
866+
if (_olderCursor && contentEl) {
867+
const earlier = document.createElement('button'); earlier.type = 'button'; earlier.textContent = 'Load earlier messages';
868+
earlier.onclick = () => void loadConversation(userId, { silent: true, before: _olderCursor });
869+
contentEl.prepend(earlier);
870+
}
871+
updateUnreadMarkers();
872+
if (contentEl) {
873+
const restoredAnchor = [...contentEl.querySelectorAll('[data-contribution-id]')]
874+
.find(element => element.dataset.contributionId === anchorId);
875+
contentEl.scrollTop = restoredAnchor
876+
? contentEl.scrollTop + restoredAnchor.getBoundingClientRect().top
877+
- contentEl.getBoundingClientRect().top - anchorOffset
878+
: oldTop;
879+
}
880+
});
858881
if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId()) return;
859882
restoreReplyDeliveryAttemptForCurrentScope();
860-
if (_olderCursor && contentEl) {
861-
const earlier = document.createElement('button'); earlier.type = 'button'; earlier.textContent = 'Load earlier messages';
862-
earlier.onclick = () => void loadConversation(userId, { silent: true, before: _olderCursor });
863-
contentEl.prepend(earlier);
864-
}
865-
if (contentEl && before) contentEl.scrollTop = oldTop + contentEl.scrollHeight - oldHeight;
866-
else if (contentEl && silent && !nearBottom) contentEl.scrollTop = oldTop;
867-
else if (contentEl) contentEl.scrollTop = contentEl.scrollHeight;
868-
updateUnreadMarkers();
869-
// Opening starts at the first loaded unread contribution, without
870-
// acknowledging earlier/unloaded or skipped contributions.
871-
if (contentEl && !silent) contentEl.querySelector('.is-unread')?.scrollIntoView?.({ block: 'start' });
872883
updateMessageNavigation();
873884
if (observeReads) observeDisplayedMessages();
874885
return true;
@@ -1153,7 +1164,7 @@ function selectReplyContext(messageId) {
11531164
/**
11541165
* Render messages in the conversation view.
11551166
*/
1156-
async function renderMessages() {
1167+
async function renderMessages(onRendered = () => {}) {
11571168
const contentEl = _messagesContainer?.querySelector('#messageViewContent');
11581169
if (!contentEl) return;
11591170

@@ -1165,6 +1176,7 @@ async function renderMessages() {
11651176
<p class="message-empty-hint">Send the first message!</p>
11661177
</div>
11671178
`;
1179+
onRendered();
11681180
return;
11691181
}
11701182

@@ -1280,6 +1292,8 @@ async function renderMessages() {
12801292
}));
12811293
});
12821294
});
1295+
// Restore before asynchronous hydration yields a frame with the new DOM.
1296+
onRendered();
12831297
await hydrateConceptCartouchesInRoot(contentEl);
12841298

12851299
// The loader restores the viewport before attaching read observers.

‎tests/browser/messageUnreadNavigation.cjs‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ const http = require('node:http');
66
const path = require('node:path');
77
const { chromium, expect } = require('@playwright/test');
88
const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
9+
const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8').replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, '');
910
const evidence = process.argv[2] || '.run/unread-navigation';
1011
fs.mkdirSync(evidence, { recursive: true });
1112
let cursors = [];
13+
let background = false;
14+
let pendingPage = null;
1215
const message = (id, unread, day) => ({ concept_id: id, created_at: `2026-09-${day}T12:00:00Z`,
1316
relationships: { '#V#has_sender': ['#V#bob'], '#V#has_recipient': ['#V#alice'] },
14-
concept_data: { content_fallback: id, read_by: unread ? [] : ['#V#alice'] } });
17+
concept_data: { content_fallback: `${id}: ${'A realistic message for checking the reading position. '.repeat(5)}`, read_by: unread ? [] : ['#V#alice'] } });
1518
const server = http.createServer(async (req, res) => {
1619
if (req.url === '/') {
1720
res.setHeader('Content-Type', 'text/html');
18-
return res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><div id="conversationWorkspace" class="show-message-exchange"><div id="messagesContainer"></div></div>');
21+
return res.end(`<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><link rel="stylesheet" href="/static/css/participantProfile.css">${markup}`);
1922
}
2023
if (req.url.startsWith('/static/')) {
2124
const file = path.resolve(root, `.${req.url}`);
@@ -28,10 +31,14 @@ const server = http.createServer(async (req, res) => {
2831
if (req.url === '/api/messages/exchange') {
2932
let body = ''; for await (const chunk of req) body += chunk;
3033
const { before } = JSON.parse(body); cursors.push(before);
31-
const result = !before ? { messages: [message('Latest read message', false, '13')], before: 'middle' }
32-
: before === 'middle' ? { messages: [message('Middle read message', false, '12')], before: 'older' }
34+
const initial = Array.from({ length: 20 }, (_, index) => message(`latest-${String(index).padStart(2, '0')}`, false, '13'));
35+
const result = !before ? { messages: background ? [...initial, message('Background unread', true, '14')] : initial, before: 'middle' }
36+
: before === 'middle' ? { messages: Array.from({ length: 10 }, (_, index) => message(`middle-${index}`, false, '12')), before: 'older' }
3337
: { messages: [message('Earlier unread', true, '10'), message('Most recent unread', true, '11')], before: null };
34-
return res.end(JSON.stringify({ ...result, current_user_id: '#V#alice' }));
38+
const finish = () => res.end(JSON.stringify({ ...result, current_user_id: '#V#alice' }));
39+
if (before || background) pendingPage = finish;
40+
else finish();
41+
return;
3542
}
3643
res.end(JSON.stringify({ success: true, messages: [], profiles: [] }));
3744
});
@@ -42,25 +49,68 @@ const server = http.createServer(async (req, res) => {
4249
const results = [];
4350
for (const width of [390, 1280]) {
4451
cursors = [];
52+
background = false;
4553
const page = await browser.newPage({ viewport: { width, height: 850 } });
4654
const errors = []; page.on('pageerror', error => errors.push(error.message));
4755
await page.goto(`http://127.0.0.1:${server.address().port}`);
4856
await page.evaluate(async () => {
4957
// Keep read markers stable while checking navigation independently.
5058
window.IntersectionObserver = undefined;
59+
document.getElementById('conversationWorkspace').classList.add('show-message-exchange');
60+
document.body.classList.add('viewing-message-exchange');
5161
const { openMessageExchange } = await import('/static/js/components/messagePanel.js');
5262
await openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice',
5363
participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'],
5464
session_name: 'Unread pagination fixture', shared_unread_count: 2 });
5565
});
66+
const content = page.locator('#messageViewContent');
67+
assert.equal(await content.evaluate(el => el.scrollTop), 0, 'default loading preserves the top');
68+
await content.evaluate(el => { el.scrollTop = el.scrollHeight - el.clientHeight - 10; });
69+
const bottomPosition = await content.evaluate(el => el.scrollTop);
70+
assert.ok(bottomPosition > 0, 'fixture must overflow');
71+
background = true;
72+
await page.evaluate(() => {
73+
window.refreshDone = import('/static/js/components/messagePanel.js').then(panel => panel.refreshOpenMessageExchange());
74+
});
75+
await expect.poll(() => Boolean(pendingPage)).toBe(true);
76+
assert.equal(await content.evaluate(el => el.scrollTop), bottomPosition, 'pending background request must preserve position');
77+
pendingPage(); pendingPage = null;
78+
await page.evaluate(() => window.refreshDone);
79+
assert.equal(await content.evaluate(el => el.scrollTop), bottomPosition, 'background unread must not follow the bottom');
80+
// Reopen the initial fixture, then traverse delayed earlier pages.
81+
background = false;
82+
await page.evaluate(async () => {
83+
const panel = await import('/static/js/components/messagePanel.js');
84+
await panel.openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice',
85+
participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'],
86+
session_name: 'Unread pagination fixture', shared_unread_count: 2 });
87+
});
88+
cursors = [];
89+
await content.evaluate(el => { el.scrollTop = 450; });
90+
const anchor = await content.evaluate(el => {
91+
const top = el.getBoundingClientRect().top;
92+
const item = [...el.querySelectorAll('[data-contribution-id]')].find(item => item.getBoundingClientRect().bottom > top);
93+
return { id: item.dataset.contributionId, offset: item.getBoundingClientRect().top - top };
94+
});
95+
const offset = () => page.locator(`[data-contribution-id="${anchor.id}"]`).evaluate(el =>
96+
el.getBoundingClientRect().top - document.getElementById('messageViewContent').getBoundingClientRect().top);
5697
await page.getByRole('button', { name: 'Jump to most recent unread', exact: true }).click();
98+
await expect.poll(() => Boolean(pendingPage)).toBe(true);
99+
assert.equal(await offset(), anchor.offset, 'pending go-to-unread preserves the visible message');
100+
pendingPage(); pendingPage = null;
101+
await expect.poll(() => Boolean(pendingPage)).toBe(true);
102+
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
103+
assert.ok(Math.abs(await offset() - anchor.offset) <= 1, 'intermediate page preserves the visible message within one CSS pixel');
104+
await expect(page.getByRole('button', { name: 'Loading unread messages…', exact: true })).toBeVisible();
105+
await page.screenshot({ path: path.join(evidence, `loading-${width}.png`) });
106+
pendingPage(); pendingPage = null;
57107
const target = page.locator('[data-contribution-id="Most recent unread"]');
58108
await expect(target).toBeFocused();
59109
await expect(target).toBeInViewport();
60-
assert.deepEqual(cursors, [null, 'middle', 'older']);
110+
assert.deepEqual(cursors, ['middle', 'older']);
61111
assert.deepEqual(errors, []);
62112
await page.screenshot({ path: path.join(evidence, `unread-${width}.png`) });
63-
results.push({ width, cursors: [...cursors], mostRecentUnreadFocused: true, targetInViewport: true });
113+
results.push({ width, cursors: [...cursors], defaultTopPreserved: true, backgroundPosition: bottomPosition, intermediateAnchorOffset: anchor.offset, mostRecentUnreadFocused: true, targetInViewport: true });
64114
await page.close();
65115
}
66116
fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2));

‎tests/frontend/messageExchangeView.test.js‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,74 @@ test('exhausting earlier pages reports stale unread counts and hides the action'
280280
expect(document.querySelector('.message-unread-navigation-status').textContent).toContain('No unread messages remain');
281281
expect(document.querySelector('.message-jump-unread').hidden).toBe(true);
282282
});
283+
284+
test('default loading does not navigate to unread or to the bottom', async () => {
285+
HTMLElement.prototype.scrollIntoView = jest.fn();
286+
const api = require(base + 'apiService.js');
287+
let finish;
288+
api.postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
289+
const opening = require(base + 'components/messagePanel.js').openMessageExchange(row('#V#bob'));
290+
const content = document.getElementById('messageViewContent');
291+
Object.defineProperty(content, 'scrollHeight', { configurable: true, value: 1000 });
292+
content.scrollTop = 30;
293+
finish(unreadResponse());
294+
await opening;
295+
expect(content.scrollTop).toBe(30);
296+
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
297+
});
298+
299+
test('background unread loading preserves even a near-bottom reader and scrolling during the request', async () => {
300+
HTMLElement.prototype.scrollIntoView = jest.fn();
301+
const panel = require(base + 'components/messagePanel.js');
302+
await panel.openMessageExchange(row('#V#bob'));
303+
const content = document.getElementById('messageViewContent');
304+
Object.defineProperties(content, {
305+
scrollHeight: { configurable: true, value: 1000 },
306+
clientHeight: { configurable: true, value: 200 }
307+
});
308+
content.scrollTop = 790;
309+
let finish;
310+
require(base + 'apiService.js').postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
311+
const refresh = panel.refreshOpenMessageExchange();
312+
expect(content.querySelector('[data-contribution-id="one"]')).not.toBeNull();
313+
expect(content.scrollTop).toBe(790);
314+
content.scrollTop = 450;
315+
const updated = unreadResponse();
316+
updated.messages.push({ ...response('two').messages[0], created_at: '2026-09-12T12:00:00Z' });
317+
finish(updated);
318+
await refresh;
319+
expect(content.scrollTop).toBe(450);
320+
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
321+
});
322+
323+
test('deferred unread navigation preserves the visible contribution across intermediate pages before jumping', async () => {
324+
HTMLElement.prototype.scrollIntoView = jest.fn();
325+
const api = require(base + 'apiService.js');
326+
api.postJson.mockResolvedValueOnce(page(['latest'], 'page2'));
327+
await require(base + 'components/messagePanel.js').openMessageExchange({ ...row('#V#bob'), shared_unread_count: 1 });
328+
const content = document.getElementById('messageViewContent');
329+
content.scrollTop = 25;
330+
const geometry = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
331+
if (this.dataset.contributionId === 'latest') {
332+
const top = 100 + (content.querySelector('[data-contribution-id="middle"]') ? 200 : 0) - content.scrollTop;
333+
return { top, bottom: top + 100 };
334+
}
335+
return { top: 0, bottom: 0 };
336+
});
337+
let finishMiddle, finishUnread;
338+
api.postJson.mockImplementationOnce(() => new Promise(resolve => { finishMiddle = resolve; }))
339+
.mockImplementationOnce(() => new Promise(resolve => { finishUnread = resolve; }));
340+
const jump = document.querySelector('.message-jump-unread');
341+
jump.click();
342+
expect(jump.textContent).toBe('Loading unread messages…');
343+
expect(content.scrollTop).toBe(25);
344+
content.scrollTop = 40;
345+
finishMiddle(page(['middle'], 'page3')); await flush();
346+
expect(content.scrollTop).toBe(240);
347+
expect(content.querySelector('[data-contribution-id="latest"]').getBoundingClientRect().top).toBe(60);
348+
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
349+
finishUnread(page(['unread'], null, ['unread'])); await flush();
350+
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1);
351+
expect(document.activeElement.dataset.contributionId).toBe('unread');
352+
geometry.mockRestore();
353+
});

0 commit comments

Comments
 (0)