From 9bb8c19312f5ed0c161464bdd32870c04e051d84 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 13:09:17 -0400 Subject: [PATCH] Load earlier messages when jumping to the most recent unread --- .../static/js/components/messagePanel.js | 75 +++++++++++++++++-- tests/browser/messageUnreadNavigation.cjs | 68 +++++++++++++++++ tests/frontend/messageExchangeView.test.js | 66 +++++++++++++++- 3 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 tests/browser/messageUnreadNavigation.cjs 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 15a60cf8..081a8b15 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -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(); @@ -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'); @@ -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); @@ -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); } @@ -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 = '
Could not load this conversation. Try Refresh.
'; - } finally { if (generation === _exchangeGeneration) _isLoading = false; } + return false; + } finally { + if (generation === _exchangeGeneration) { + _isLoading = false; + updateMessageNavigation(); + } + } } function isPaperRecommendationMessage(message) { diff --git a/tests/browser/messageUnreadNavigation.cjs b/tests/browser/messageUnreadNavigation.cjs new file mode 100644 index 00000000..190dbc1b --- /dev/null +++ b/tests/browser/messageUnreadNavigation.cjs @@ -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('
'); + } + 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; }); diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index bd7a0ab6..1f5675b8 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -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'); @@ -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); +});