From 706dfac6c2e80e7588b2841d92b9137bb8e17b6d Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 17:04:31 -0400 Subject: [PATCH] Hide unread navigation after the final confirmed message read --- .../js/components/conversationCatalogue.js | 4 +- .../static/js/components/messagePanel.js | 23 +++++++ tests/browser/messageUnreadNavigation.cjs | 53 +++++++++++++++- tests/frontend/messageExchangeView.test.js | 62 +++++++++++++++++++ .../unifiedConversationCatalogue.test.js | 2 +- 5 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js index d05dc657..1ea11996 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js +++ b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js @@ -5,7 +5,7 @@ import { showToast } from '../utils/toast.js'; import { getJson, postJson } from '../apiService.js'; import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js'; import { participantAvatar, openParticipantProfile } from './participantProfile.js'; -import { openMessageExchange, refreshOpenMessageExchange, showMessageComposer, resetMessagePanelContext } from './messagePanel.js'; +import { openMessageExchange, refreshOpenMessageExchange, showMessageComposer, resetMessagePanelContext, captureMessageExchangeUnreadRefresh } from './messagePanel.js'; let rows = []; let catalogueRows = []; @@ -128,6 +128,7 @@ export function resetConversationCatalogue({ preserveSearch = false } = {}) { export async function refreshMessageCatalogue() { if (refreshPromise) return refreshPromise; + const reconcileUnread = captureMessageExchangeUnreadRefresh(); const expected = generation; const org = getSessionScopedOrgId(); refreshPromise = (async () => { @@ -135,6 +136,7 @@ export async function refreshMessageCatalogue() { const data = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&all_contexts=${allContexts}&include_imported=${showImported}`); if (expected !== generation || org !== getSessionScopedOrgId()) return; const incomingRows = data.conversations || []; + reconcileUnread(incomingRows); more = data.has_more === true; if (!nextCursor || catalogueRows.length <= sourceLimit) nextCursor = data.next_cursor; sourceError = data.coverage_complete === false ? `Could not refresh ${Object.entries(data.coverage || {}).filter(([, ok]) => !ok).map(([name]) => name).join(' and ')}. The other conversations remain available; refresh to retry.` : ''; 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 f1e45ff7..ecb5ff00 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -66,6 +66,24 @@ let _unreadJumpGeneration = null; let _readObserver = null; const _exchangeDrafts = new Map(); const _pendingReads = new Set(); +let _readRevision = 0; + +// Bind catalogue read-back to the selection and acknowledgements at request +// time, so a response started before a read cannot restore its old count. +export function captureMessageExchangeUnreadRefresh() { + const exchange = _exchange; + const revision = _readRevision; + const readPending = _pendingReads.size > 0; + return rows => { + if (!exchange || exchange !== _exchange || revision !== _readRevision + || readPending || _pendingReads.size > 0) return; + const row = rows.find(item => item.source_kind === 'message_exchange' + && item.session_id === exchange.session_id && item.viewer_id === exchange.viewer_id); + if (!Number.isFinite(row?.shared_unread_count)) return; + exchange.shared_unread_count = Math.max(0, row.shared_unread_count); + updateMessageNavigation(); + }; +} function exchangeScope(row = _exchange) { return row ? `${row.browser_scope ?? getSessionScopedOrgId() ?? ''}:${row.viewer_id}:${row.session_id}` : ''; @@ -280,6 +298,11 @@ function observeDisplayedMessages() { // 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 => { if (!ids.includes(message.concept_id)) return; message.concept_data ||= {}; diff --git a/tests/browser/messageUnreadNavigation.cjs b/tests/browser/messageUnreadNavigation.cjs index a075c362..cf7903d2 100644 --- a/tests/browser/messageUnreadNavigation.cjs +++ b/tests/browser/messageUnreadNavigation.cjs @@ -13,6 +13,9 @@ let cursors = []; let sent = []; let background = false; let pendingPage = null; +let zeroTransition = false; +let pendingRead = null; +const readMessages = new Set(); 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}: ${'A realistic message for checking the reading position. '.repeat(5)}`, read_by: unread ? [] : ['#V#alice'] } }); @@ -34,9 +37,23 @@ const server = http.createServer(async (req, res) => { sent.push(JSON.parse(body)); return res.end(JSON.stringify({ success: true, message_id: '#V#fixture_sent' })); } + if (req.url === '/api/messages/read/bulk' && zeroTransition) { + let body = ''; for await (const chunk of req) body += chunk; + const { message_ids } = JSON.parse(body); + pendingRead = () => { + message_ids.forEach(id => readMessages.add(id)); + res.end(JSON.stringify({ success: true, updated_count: message_ids.length })); + }; + return; + } 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 { before, participant_ids } = JSON.parse(body); cursors.push(before); + if (zeroTransition) { + const id = participant_ids.includes('#V#carol') ? 'carol-final' : 'bob-final'; + return res.end(JSON.stringify({ current_user_id: '#V#alice', before: 'read-history', + messages: [message(id, !readMessages.has(id), '15')] })); + } 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}`, index === 5, '12')), before: 'older' } @@ -57,6 +74,7 @@ const server = http.createServer(async (req, res) => { cursors = []; sent = []; background = false; + zeroTransition = 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}`); @@ -139,6 +157,39 @@ const server = http.createServer(async (req, res) => { assert.deepEqual(errors, []); results.push({ width, cursors: [...cursors], sendArrow: true, sendTarget: box, sends: sent.length, labelledLatestNavigation: true, defaultTopPreserved: true, backgroundPosition: bottomPosition, intermediateAnchorOffset: anchor.offset, firstUnreadFocused: true, targetInViewport: true }); await page.close(); + + // Real IntersectionObserver and confirmed HTTP receipt: the last + // unread disappears while an older-history cursor still exists. + zeroTransition = true; + readMessages.clear(); + const reading = await browser.newPage({ viewport: { width, height: 850 } }); + await reading.goto(`http://127.0.0.1:${server.address().port}`); + const open = (other, count) => reading.evaluate(async ({ other, count }) => { + document.getElementById('conversationWorkspace').classList.add('show-message-exchange'); + document.body.classList.add('viewing-message-exchange'); + const panel = await import('/static/js/components/messagePanel.js'); + await panel.openMessageExchange({ session_id: `messages:${other}`, viewer_id: '#V#alice', + participant_ids: ['#V#alice', other], other_participant_ids: [other], + session_name: other, shared_unread_count: count }); + }, { other, count }); + await open('#V#bob', 1); + const unreadButton = reading.locator('.message-jump-unread'); + await expect(unreadButton).toBeVisible(); + await expect.poll(() => Boolean(pendingRead)).toBe(true); + pendingRead(); pendingRead = null; + await expect(reading.locator('.is-unread')).toHaveCount(0); + await expect(unreadButton).toBeHidden(); + await expect(reading.getByRole('button', { name: 'Load earlier messages', exact: true })).toBeVisible(); + await reading.screenshot({ path: path.join(evidence, `zero-unread-${width}.png`) }); + await open('#V#carol', 1); + await expect(unreadButton).toBeVisible(); + await expect.poll(() => Boolean(pendingRead)).toBe(true); + await open('#V#bob', 0); + pendingRead(); pendingRead = null; + await expect(unreadButton).toBeHidden(); + results[results.length - 1].zeroUnreadHiddenWithOlderHistory = true; + results[results.length - 1].switchAndReopenCorrect = true; + await reading.close(); } fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2)); } finally { await browser.close(); server.close(); } diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index 85395b57..cb0b4e6f 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -381,3 +381,65 @@ test('deferred unread navigation preserves the visible contribution across inter expect(document.activeElement.dataset.contributionId).toBe('unread'); geometry.mockRestore(); }); + +test('confirmed reads hide navigation at zero even with older history and ignore stale catalogue replies', async () => { + const observers = mockReadObserver(); + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValueOnce(page(['first', 'last'], 'older', ['first', '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(); + const jump = document.querySelector('.message-jump-unread'); + const entry = visibleEntry(); + api.postJson.mockResolvedValue({ success: true, updated_count: 1 }); + observers[0].callback([entry]); await flush(); + expect(jump.hidden).toBe(false); + observers[0].callback([{ ...entry, target: document.querySelector('[data-contribution-id="last"]') }]); await flush(); + expect(document.querySelector('.is-unread')).toBeNull(); + expect(jump.hidden).toBe(true); + staleRefresh([exchange]); + expect(jump.hidden).toBe(true); + api.postJson.mockResolvedValueOnce(page(['carol'], 'older', ['carol'])); + await panel.openMessageExchange({ ...row('#V#carol'), shared_unread_count: 1 }); + expect(jump.hidden).toBe(false); + api.postJson.mockResolvedValueOnce(page(['first', 'last'], 'older')); + await panel.openMessageExchange({ ...exchange, shared_unread_count: 0 }); + expect(jump.hidden).toBe(true); +}); + +test('catalogue read-back reconciles unread elsewhere without treating omitted rows as zero', async () => { + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValue(page(['latest'], 'older')); + const panel = require(base + 'components/messagePanel.js'); + const exchange = { ...row('#V#bob'), shared_unread_count: 1 }; + await panel.openMessageExchange(exchange); + const reconcile = panel.captureMessageExchangeUnreadRefresh(); + const jump = document.querySelector('.message-jump-unread'); + reconcile([]); + expect(jump.hidden).toBe(false); + reconcile([{ ...exchange, shared_unread_count: 0 }]); + expect(jump.hidden).toBe(true); + await panel.openMessageExchange({ ...row('#V#carol'), shared_unread_count: 1 }); + reconcile([{ ...exchange, shared_unread_count: 0 }]); + expect(jump.hidden).toBe(false); +}); + +test('a catalogue count during an in-flight read is not decremented twice', async () => { + const observers = mockReadObserver(); + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValueOnce(page(['last-loaded'], 'older', ['last-loaded'])); + const panel = require(base + 'components/messagePanel.js'); + const exchange = { ...row('#V#bob'), shared_unread_count: 2 }; + await panel.openMessageExchange(exchange); + const refreshBeforeRead = panel.captureMessageExchangeUnreadRefresh(); + let finish; + api.postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + observers[0].callback([visibleEntry()]); + const refreshDuringRead = panel.captureMessageExchangeUnreadRefresh(); + refreshBeforeRead([{ ...exchange, shared_unread_count: 1 }]); + finish({ success: true, updated_count: 1 }); await flush(); + refreshDuringRead([{ ...exchange, shared_unread_count: 1 }]); + expect(document.querySelector('.is-unread')).toBeNull(); + expect(document.querySelector('.message-jump-unread').hidden).toBe(false); +}); diff --git a/tests/frontend/unifiedConversationCatalogue.test.js b/tests/frontend/unifiedConversationCatalogue.test.js index 5b890eb5..d101357a 100644 --- a/tests/frontend/unifiedConversationCatalogue.test.js +++ b/tests/frontend/unifiedConversationCatalogue.test.js @@ -1,7 +1,7 @@ jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), postJson: jest.fn() })); jest.mock('../../src/frontend/web/von_interface/static/js/utils/sessionScopedStorage.js', () => ({ getSessionScopedOrgId: jest.fn(() => '#V#lab') })); jest.mock('../../src/frontend/web/von_interface/static/js/components/messagePanel.js', () => ({ - openMessageExchange: jest.fn(), refreshOpenMessageExchange: jest.fn(), showMessageComposer: jest.fn(), resetMessagePanelContext: jest.fn() + openMessageExchange: jest.fn(), refreshOpenMessageExchange: jest.fn(), showMessageComposer: jest.fn(), resetMessagePanelContext: jest.fn(), captureMessageExchangeUnreadRefresh: jest.fn(() => jest.fn()) })); const api = require('../../src/frontend/web/von_interface/static/js/apiService.js');