fix(unread): stop DM device-verification requests re-lighting as unread
CI / Build & Quality Checks (push) Successful in 11m11s
CI / Trigger Desktop Build (push) Successful in 10s

A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.

Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
  whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
  via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
  Conservative: never suppresses when the read marker is off-window, the tail
  is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
  the request (the only SDK-durable lever), once per room per session, gated on
  the same verification-only predicate so it can never ack a real message.

unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.

Reviewed by 5 agents; the thread-scope guard closes the one bug they found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 01:18:31 -04:00
co-authored by Claude Opus 4.8
parent 1176bea0ee
commit b2678d5c6d
6 changed files with 277 additions and 6 deletions
+134
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
EventTimeline,
JoinRule,
MatrixClient,
MatrixEvent,
NotificationCountType,
Room,
@@ -24,6 +25,8 @@ import {
isMutedRule,
findMutedRule,
isNotificationEvent,
isVerificationFlowEvent,
unreadIsOnlyVerification,
roomHaveNotification,
getUnreadInfo,
getRoomIconSrc,
@@ -410,6 +413,137 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
// --- verification-flow unread suppression --------------------------------
test('isVerificationFlowEvent', () => {
// the in-room request (m.room.message + verification msgtype)
assert.equal(
isVerificationFlowEvent(
mockEvent({
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
}),
),
true,
);
// the handshake events (their own m.key.verification.* types)
['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => {
assert.equal(
isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })),
true,
);
});
// a normal message is not verification flow
assert.equal(
isVerificationFlowEvent(
mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }),
),
false,
);
// a still-encrypted event can't be classified → false (conservative)
assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false);
});
const mockUnreadRoom = (
events: MatrixEvent[],
readUpToId: string | null,
counts: { total: number; highlight: number } = { total: 0, highlight: 0 },
threadCounts: Record<string, { total: number; highlight: number }> = {},
): Room =>
({
roomId: '!r:x',
getEventReadUpTo: () => readUpToId,
getLiveTimeline: () => ({ getEvents: () => events }),
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? counts.total : counts.highlight,
getThreads: () => Object.keys(threadCounts).map((id) => ({ id })),
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient;
const verifRequest = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
});
const verifPhase = (id: string, phase: string) =>
mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` });
const textMsg = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
});
test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
// timeline oldest→newest: [read msg] then the verification handshake at the tail
const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true);
});
test('unreadIsOnlyVerification: a real unread message in the span → false', () => {
// an unread text message sits between the read marker and the verification tail
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => {
// the read marker isn't in the loaded timeline
const events = [verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false);
});
test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => {
const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' });
const events = [textMsg('$read'), encryptedTail];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: no userId → false', () => {
const events = [textMsg('$read'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false);
});
test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => {
// markAsRead clears every thread, so a verification-only main timeline must NOT
// count as "only verification" when a thread still has a genuine unread reply.
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(
events,
'$read',
{ total: 2, highlight: 0 },
{
$thread: { total: 1, highlight: 0 },
},
);
assert.equal(unreadIsOnlyVerification(room, '@me:x'), false);
});
test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
// Without mx, the raw count is trusted (backward compatible).
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
// With mx, the verification-only count is suppressed.
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});
test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 });
});
test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => {
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
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,