Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 32 additions & 18 deletions src/frontend/web/von_interface/static/js/components/messagePanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="loading">Loading conversation…</div>';
if (contentEl && !silent && !_currentMessages.length) contentEl.innerHTML = '<div class="loading">Loading conversation…</div>';
try {
const response = _exchange
? await postJson('/api/messages/exchange', { participant_ids: _exchange.participant_ids, organisation_concept_id: _exchange.organisation_concept_id || null, before })
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -1165,6 +1176,7 @@ async function renderMessages() {
<p class="message-empty-hint">Send the first message!</p>
</div>
`;
onRendered();
return;
}

Expand Down Expand Up @@ -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.
Expand Down
64 changes: 57 additions & 7 deletions tests/browser/messageUnreadNavigation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!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>');
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}`);
}
if (req.url.startsWith('/static/')) {
const file = path.resolve(root, `.${req.url}`);
Expand All @@ -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: [] }));
});
Expand All @@ -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));
Expand Down
71 changes: 71 additions & 0 deletions tests/frontend/messageExchangeView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading