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(); +});