fix(unread): clear rooms whose read receipt already covers the tail
A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.
Add readReceiptCoversTail(room, userId): walking the live timeline newest→
oldest, if we reach the user's read-receipt event without crossing any
notification-worthy event, the room is genuinely read and a lingering Total is
suppressed to {0,0} in getUnreadInfo / getUnreadInfos. Safe by construction —
a real unread sits AFTER the receipt and stops the walk at isNotificationEvent
— and guarded against unread threads (markAsRead clears threads unconditionally)
and off-window receipts (can't confirm → don't suppress). Self-correcting: a
new message becomes the tail and the walk stops suppressing.
Also recognize polls (m.poll.start / msc3381) as notification events so a
poll-only unread is never walked past (closes a pre-existing gap in the
tail scans), and factor the unread-thread guard into roomHasUnreadThread.
Reviewed by 3 agents (false-suppression safety, unread-system regression,
SDK behavior): no real unread is hidden for any standard content, no
regression to the atom/PUT-DELETE paths, and the fix produces {0,0} for the
target scenario and stays resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ import {
|
||||
isNotificationEvent,
|
||||
isVerificationFlowEvent,
|
||||
unreadIsOnlyVerification,
|
||||
readReceiptCoversTail,
|
||||
roomHasUnreadThread,
|
||||
roomHaveNotification,
|
||||
getUnreadInfo,
|
||||
getRoomIconSrc,
|
||||
@@ -478,6 +480,12 @@ const textMsg = (id: string) =>
|
||||
getType: () => 'm.room.message',
|
||||
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
|
||||
});
|
||||
const reactionEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) });
|
||||
const encryptedEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) });
|
||||
const pollEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) });
|
||||
|
||||
test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
|
||||
// timeline oldest→newest: [read msg] then the verification handshake at the tail
|
||||
@@ -544,6 +552,95 @@ test('getUnreadInfo does NOT suppress when a real message is unread alongside a
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
});
|
||||
|
||||
// --- readReceiptCoversTail (UTD / spurious-count suppression) --------------
|
||||
|
||||
test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => {
|
||||
// The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read
|
||||
// receipt, and the receipt itself landed on the trailing reaction.
|
||||
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a real unread message after the receipt → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => {
|
||||
const events = [textMsg('$read'), pollEv('$poll')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('isNotificationEvent recognizes polls (MSC3381)', () => {
|
||||
assert.equal(isNotificationEvent(pollEv('$p')), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => {
|
||||
const events = [textMsg('$read'), encryptedEv('$enc')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: receipt off-window → false', () => {
|
||||
const events = [reactionEv('$r'), textMsg('$new')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: no receipt / null user → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$tail')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false);
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$tail')];
|
||||
const room = mockUnreadRoom(
|
||||
events,
|
||||
'$tail',
|
||||
{ total: 2, highlight: 0 },
|
||||
{
|
||||
$thread: { total: 1, highlight: 0 },
|
||||
},
|
||||
);
|
||||
assert.equal(readReceiptCoversTail(room, '@me:x'), false);
|
||||
});
|
||||
|
||||
test('roomHasUnreadThread reflects per-thread counts', () => {
|
||||
const events = [textMsg('$read')];
|
||||
assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false);
|
||||
assert.equal(
|
||||
roomHasUnreadThread(
|
||||
mockUnreadRoom(
|
||||
events,
|
||||
'$read',
|
||||
{ total: 1, highlight: 0 },
|
||||
{ $t: { total: 1, highlight: 0 } },
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => {
|
||||
// Inflated Total=1 (undecryptable event) but the receipt is on the trailing
|
||||
// reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw
|
||||
// count is trusted (backward compatible).
|
||||
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
|
||||
const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 });
|
||||
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
|
||||
});
|
||||
|
||||
test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => {
|
||||
const events = [textMsg('$read'), textMsg('$new')];
|
||||
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
});
|
||||
|
||||
const mockRoomWithThreadCounts = (
|
||||
total: number,
|
||||
highlight: number,
|
||||
|
||||
Reference in New Issue
Block a user