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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ let _deliveryKeySequence = 0;
let _exchange = null;
let _exchangeGeneration = 0;
let _olderCursor = null;
let _unreadJumpGeneration = null;
let _readObserver = null;
const _exchangeDrafts = new Map();
const _pendingReads = new Set();
Expand Down Expand Up @@ -144,6 +145,57 @@ function updateUnreadMarkers() {
updateMessageNavigation();
}

async function jumpToMostRecentUnread() {
const generation = _exchangeGeneration;
const org = getSessionScopedOrgId();
if (_unreadJumpGeneration === generation || _isLoading) return;
_unreadJumpGeneration = generation;
_readObserver?.disconnect();
_readObserver = null;
const content = _messagesContainer?.querySelector('#messageViewContent');
content?.querySelector('.message-unread-navigation-status')?.remove();
const stillCurrent = () => generation === _exchangeGeneration && org === getSessionScopedOrgId();
updateMessageNavigation();
try {
while (stillCurrent()) {
const targets = content?.querySelectorAll('.is-unread[data-contribution-id]');
const target = targets?.[targets.length - 1];
if (target) {
target.scrollIntoView({ block: 'start' });
focusConversationTarget(target);
return;
}
if (!_olderCursor) {
showUnreadNavigationStatus('No unread messages remain in this conversation.');
return;
}
const cursor = _olderCursor;
const loaded = await loadConversation(_currentConversationUserId, {
silent: true, before: cursor, observeReads: false
});
if (!stillCurrent()) return;
if (!loaded || _olderCursor === cursor) {
showUnreadNavigationStatus('Could not load earlier unread messages. Try jumping again.');
return;
}
}
} finally {
if (_unreadJumpGeneration === generation) _unreadJumpGeneration = null;
if (stillCurrent()) {
updateMessageNavigation();
observeDisplayedMessages();
}
}
}

function showUnreadNavigationStatus(text) {
const status = document.createElement('div');
status.className = 'message-unread-navigation-status';
status.setAttribute('role', 'status');
status.textContent = text;
_messagesContainer?.querySelector('#messageViewContent')?.prepend(status);
}

function updateMessageNavigation() {
const content = _messagesContainer?.querySelector('#messageViewContent');
const compose = _messagesContainer?.querySelector('#messageComposeArea');
Expand All @@ -155,12 +207,7 @@ function updateMessageNavigation() {
const unread = document.createElement('button');
unread.type = 'button';
unread.className = 'message-jump-unread';
unread.textContent = 'Jump to first unread message';
unread.onclick = () => {
const target = content.querySelector('.is-unread');
target?.scrollIntoView({ block: 'start' });
focusConversationTarget(target);
};
unread.onclick = () => void jumpToMostRecentUnread();
const latest = createLatestMessageButton(() => {
content.scrollTop = content.scrollHeight;
focusConversationTarget(content.querySelector('.message-list')?.lastElementChild || content);
Expand All @@ -170,7 +217,12 @@ function updateMessageNavigation() {
compose.prepend(navigation);
content.addEventListener('scroll', updateMessageNavigation, { passive: true });
}
navigation.querySelector('.message-jump-unread').hidden = !content.querySelector('.is-unread');
const unread = navigation.querySelector('.message-jump-unread');
const jumping = _unreadJumpGeneration === _exchangeGeneration;
unread.textContent = jumping ? 'Loading unread messages…' : 'Jump to most recent unread';
unread.disabled = jumping || _isLoading;
unread.hidden = !jumping && !content.querySelector('.is-unread')
&& !(_olderCursor && _exchange?.shared_unread_count > 0);
setNavigationVisible(navigation.querySelector('.chat-scroll-to-end-btn'),
content.scrollHeight - content.scrollTop - content.clientHeight > 60);
}
Expand Down Expand Up @@ -780,9 +832,16 @@ async function loadConversation(userId, { silent = false, before = null, observe
if (contentEl && !silent) contentEl.querySelector('.is-unread')?.scrollIntoView?.({ block: 'start' });
updateMessageNavigation();
if (observeReads) observeDisplayedMessages();
return true;
} catch (_) {
if (generation === _exchangeGeneration && contentEl && !silent) contentEl.innerHTML = '<div class="message-error">Could not load this conversation. Try Refresh.</div>';
} finally { if (generation === _exchangeGeneration) _isLoading = false; }
return false;
} finally {
if (generation === _exchangeGeneration) {
_isLoading = false;
updateMessageNavigation();
}
}
}

function isPaperRecommendationMessage(message) {
Expand Down
68 changes: 68 additions & 0 deletions tests/browser/messageUnreadNavigation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Production message pane and CSS with synthetic paginated HTTP responses.
// No live authentication, messages or read-state writes.
const assert = require('node:assert/strict');
const fs = require('node:fs');
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 evidence = process.argv[2] || '.run/unread-navigation';
fs.mkdirSync(evidence, { recursive: true });
let cursors = [];
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'] } });
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>');
}
if (req.url.startsWith('/static/')) {
const file = path.resolve(root, `.${req.url}`);
if (file.startsWith(root + '/static/') && fs.existsSync(file)) {
res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
return res.end(fs.readFileSync(file));
}
}
res.setHeader('Content-Type', 'application/json');
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' }
: { 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' }));
}
res.end(JSON.stringify({ success: true, messages: [], profiles: [] }));
});
(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch();
try {
const results = [];
for (const width of [390, 1280]) {
cursors = [];
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;
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 });
});
await page.getByRole('button', { name: 'Jump to most recent unread', exact: true }).click();
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(errors, []);
await page.screenshot({ path: path.join(evidence, `unread-${width}.png`) });
results.push({ width, cursors: [...cursors], mostRecentUnreadFocused: true, targetInViewport: true });
await page.close();
}
fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2));
} finally { await browser.close(); server.close(); }
})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
66 changes: 65 additions & 1 deletion tests/frontend/messageExchangeView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ test('scrolling away exposes the shared latest control without waiting for new m
expect(require(base + 'apiService.js').postJson).toHaveBeenCalledTimes(1);
});

test('refresh and pagination preserve scroll position and first unread navigation without bulk acknowledgement', async () => {
test('refresh and pagination preserve scroll position and unread navigation without bulk acknowledgement', async () => {
const api = require(base + 'apiService.js');
api.postJson.mockResolvedValueOnce({ ...unreadResponse(), before: 'older' });
const panel = require(base + 'components/messagePanel.js');
Expand Down Expand Up @@ -216,3 +216,67 @@ test('the visible-read observer excludes the fixed composer overlap', async () =
expect(observers).toHaveLength(2);
expect(global.IntersectionObserver.mock.calls[1][1].rootMargin).toBe('0px 0px -150px 0px');
});

function page(ids, before = null, unread = []) {
return { current_user_id: '#V#alice', before, messages: ids.map((id, index) => ({
...response(id).messages[0], created_at: `2026-09-${String(index + 1).padStart(2, '0')}T12:00:00Z`,
relationships: { '#V#has_sender': ['#V#bob'], '#V#has_recipient': ['#V#alice'] },
concept_data: { content_fallback: id, read_by: unread.includes(id) ? [] : ['#V#alice'] }
})) };
}

test('unloaded unread count exposes navigation that pages backwards to the most recent unread', async () => {
HTMLElement.prototype.scrollIntoView = jest.fn();
const api = require(base + 'apiService.js');
api.postJson.mockResolvedValueOnce(page(['latest'], 'page2'))
.mockResolvedValueOnce(page(['middle'], 'page3'))
.mockResolvedValueOnce(page(['old-unread', 'recent-unread'], 'page4', ['old-unread', 'recent-unread']));
await require(base + 'components/messagePanel.js').openMessageExchange({ ...row('#V#bob'), shared_unread_count: 2 });
const jump = document.querySelector('.message-jump-unread');
expect(jump.hidden).toBe(false);
expect(jump.textContent).toBe('Jump to most recent unread');
jump.click(); await flush();
expect(document.activeElement.dataset.contributionId).toBe('recent-unread');
expect(api.postJson.mock.calls.map(([, body]) => body.before)).toEqual([null, 'page2', 'page3']);
expect(document.querySelectorAll('.is-unread')).toHaveLength(2);
expect(jump.disabled).toBe(false);
});

test('failed unread pagination retains the action for retry', async () => {
HTMLElement.prototype.scrollIntoView = jest.fn();
const api = require(base + 'apiService.js');
api.postJson.mockResolvedValueOnce(page(['latest'], 'older')).mockRejectedValueOnce(new Error('offline'));
await require(base + 'components/messagePanel.js').openMessageExchange({ ...row('#V#bob'), shared_unread_count: 1 });
const jump = document.querySelector('.message-jump-unread');
jump.click(); await flush();
expect(document.querySelector('.message-unread-navigation-status').textContent).toContain('Try jumping again');
expect(jump.disabled).toBe(false);
api.postJson.mockResolvedValueOnce(page(['unread'], null, ['unread']));
jump.click(); await flush();
expect(document.activeElement.dataset.contributionId).toBe('unread');
});

test('switching conversation cancels a pending unread jump without moving focus', async () => {
const api = require(base + 'apiService.js');
api.postJson.mockResolvedValueOnce(page(['latest'], 'older'));
const panel = require(base + 'components/messagePanel.js');
await panel.openMessageExchange({ ...row('#V#bob'), shared_unread_count: 1 });
let finish;
api.postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
document.querySelector('.message-jump-unread').click();
api.postJson.mockResolvedValueOnce(page(['carol']));
await panel.openMessageExchange(row('#V#carol'));
finish(page(['bob-unread'], null, ['bob-unread'])); await flush();
expect(document.querySelector('[data-contribution-id="bob-unread"]')).toBeNull();
expect(document.querySelector('[data-contribution-id="carol"]')).not.toBeNull();
expect(document.activeElement.dataset.contributionId).toBeUndefined();
});

test('exhausting earlier pages reports stale unread counts and hides the action', async () => {
const api = require(base + 'apiService.js');
api.postJson.mockResolvedValueOnce(page(['latest'], 'older')).mockResolvedValueOnce(page(['old']));
await require(base + 'components/messagePanel.js').openMessageExchange({ ...row('#V#bob'), shared_unread_count: 1 });
document.querySelector('.message-jump-unread').click(); await flush();
expect(document.querySelector('.message-unread-navigation-status').textContent).toContain('No unread messages remain');
expect(document.querySelector('.message-jump-unread').hidden).toBe(true);
});
Loading