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 @@ -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 = [];
Expand Down Expand Up @@ -128,13 +128,15 @@ export function resetConversationCatalogue({ preserveSearch = false } = {}) {

export async function refreshMessageCatalogue() {
if (refreshPromise) return refreshPromise;
const reconcileUnread = captureMessageExchangeUnreadRefresh();
const expected = generation;
const org = getSessionScopedOrgId();
refreshPromise = (async () => {
try {
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.` : '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : '';
Expand Down Expand Up @@ -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 ||= {};
Expand Down
53 changes: 52 additions & 1 deletion tests/browser/messageUnreadNavigation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } });
Expand All @@ -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' }
Expand All @@ -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}`);
Expand Down Expand Up @@ -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(); }
Expand Down
62 changes: 62 additions & 0 deletions tests/frontend/messageExchangeView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion tests/frontend/unifiedConversationCatalogue.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
Loading