From 74a5c8cd0d5b73cac1d7c64f2495f05ecc237e8f Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 17:17:38 -0400 Subject: [PATCH] Reconcile unread navigation after settled read receipts --- .../static/js/components/messagePanel.js | 5 +++- tests/browser/messageUnreadNavigation.cjs | 15 +++++++++++- tests/frontend/messageExchangeView.test.js | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) 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 ecb5ff00..18ad0aab 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -295,12 +295,12 @@ function observeDisplayedMessages() { void postJson('/api/messages/read/bulk', { message_ids: ids }).then(async response => { if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId()) return; if (response?.success !== true) throw new Error('Read state was not saved'); + _readRevision += 1; // A full update receipt confirms every submitted ID, including loaded // older pages. Partial receipts require read-back; never clear all IDs. if (response.updated_count === ids.length) { const newlyRead = _currentMessages.filter(message => ids.includes(message.concept_id) && isUnreadMessage(message)).length; - _readRevision += 1; if (_exchange) _exchange.shared_unread_count = Math.max(0, (_exchange.shared_unread_count || 0) - newlyRead); _currentMessages.forEach(message => { @@ -314,6 +314,9 @@ function observeDisplayedMessages() { if (_currentMessages.some(m => ids.includes(m.concept_id) && isUnreadMessage(m))) showReadFailure(); else { root.querySelector('.message-read-status')?.remove(); + // The ensuing catalogue request must capture a settled read; + // otherwise its authoritative count is rejected as in-flight. + ids.forEach(id => _pendingReads.delete(`${generation}:${id}`)); document.dispatchEvent(new CustomEvent('von:conversation-contribution')); } }).catch(() => { diff --git a/tests/browser/messageUnreadNavigation.cjs b/tests/browser/messageUnreadNavigation.cjs index cf7903d2..0545ded7 100644 --- a/tests/browser/messageUnreadNavigation.cjs +++ b/tests/browser/messageUnreadNavigation.cjs @@ -46,6 +46,13 @@ const server = http.createServer(async (req, res) => { }; return; } + if (req.url.startsWith('/api/messages/conversation-catalogue')) { + return res.end(JSON.stringify({ conversations: ['bob', 'carol'].map(other => ({ + session_id: `messages:#V#${other}`, source_kind: 'message_exchange', viewer_id: '#V#alice', + participant_ids: ['#V#alice', `#V#${other}`], other_participant_ids: [`#V#${other}`], + shared_unread_count: readMessages.has(`${other}-final`) ? 0 : 1 + })) })); + } if (req.url === '/api/messages/exchange') { let body = ''; for await (const chunk of req) body += chunk; const { before, participant_ids } = JSON.parse(body); cursors.push(before); @@ -172,7 +179,12 @@ const server = http.createServer(async (req, res) => { participant_ids: ['#V#alice', other], other_participant_ids: [other], session_name: other, shared_unread_count: count }); }, { other, count }); - await open('#V#bob', 1); + await reading.evaluate(async () => { + const { refreshMessageCatalogue } = await import('/static/js/components/conversationCatalogue.js'); + document.addEventListener('von:conversation-contribution', () => void refreshMessageCatalogue()); + }); + // An outdated opening count must converge with the tile after a read. + await open('#V#bob', 2); const unreadButton = reading.locator('.message-jump-unread'); await expect(unreadButton).toBeVisible(); await expect.poll(() => Boolean(pendingRead)).toBe(true); @@ -188,6 +200,7 @@ const server = http.createServer(async (req, res) => { pendingRead(); pendingRead = null; await expect(unreadButton).toBeHidden(); results[results.length - 1].zeroUnreadHiddenWithOlderHistory = true; + results[results.length - 1].postReadCatalogueReconciled = true; results[results.length - 1].switchAndReopenCorrect = true; await reading.close(); } diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index cb0b4e6f..49b43b78 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -443,3 +443,27 @@ test('a catalogue count during an in-flight read is not decremented twice', asyn expect(document.querySelector('.is-unread')).toBeNull(); expect(document.querySelector('.message-jump-unread').hidden).toBe(false); }); + + +test.each([0, 1])('post-read catalogue refresh reconciles stale counts after a receipt of %i', async updatedCount => { + const observers = mockReadObserver(); + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValueOnce(page(['last'], 'older', ['last'])); + const panel = require(base + 'components/messagePanel.js'); + const exchange = { ...row('#V#bob'), shared_unread_count: 2 }; + await panel.openMessageExchange(exchange); + const staleRefresh = panel.captureMessageExchangeUnreadRefresh(); + let reconcile; + document.addEventListener('von:conversation-contribution', () => { + reconcile = panel.captureMessageExchangeUnreadRefresh(); + }, { once: true }); + api.postJson.mockResolvedValueOnce({ success: true, updated_count: updatedCount }) + .mockResolvedValueOnce(page(['last'], 'older')); + observers[0].callback([visibleEntry()]); await flush(); + expect(document.querySelector('.is-unread')).toBeNull(); + expect(reconcile).toBeDefined(); + reconcile([{ ...exchange, shared_unread_count: 0 }]); + expect(document.querySelector('.message-jump-unread').hidden).toBe(true); + staleRefresh([exchange]); + expect(document.querySelector('.message-jump-unread').hidden).toBe(true); +});