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
+80 -5
View File
@@ -237,6 +237,54 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
return true;
};
// In-room device verification is a small burst at the tail of a DM: the
// `m.key.verification.request` message plus the `m.key.verification.*` flow
// (ready/start/key/mac/done/cancel). A COMPLETED request keeps the server/SDK
// Total notification count > 0 forever — it's a plain `m.room.message`, so it
// matches the default DM push rule and there's no recency gate — so the DM
// re-lights as unread on every fresh sync.
export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
// getType() returns the CLEAR type once decrypted; while still encrypted we
// can't tell, so this returns false and callers treat that as "not confirmed".
const eType = mEvent.getType();
if (eType.startsWith('m.key.verification.')) return true;
if (eType === 'm.room.message') {
return mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE;
}
return false;
};
// True iff a room's ENTIRE unread span (tail → the user's read receipt) is
// nothing but verification-flow events — i.e. the only "unread" is a completed
// device verification, not a real message. Conservative: returns false when the
// read marker isn't in the loaded timeline (can't confirm the span) or while the
// tail is still encrypted (undecryptable → unknown), so it never suppresses or
// auto-reads a genuine unread message.
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
// A real unread THREAD reply also drives the room's notification count, and
// `markAsRead` clears every thread unconditionally — so a room with ANY unread
// thread is never "only verification": suppressing/auto-reading here would hide
// or wrongly ack a genuine thread reply. Reject before scanning the main timeline.
const hasUnreadThread = room
.getThreads()
.some(
(thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
);
if (hasUnreadThread) return false;
const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents();
let sawVerification = false;
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i];
if (!event) return false;
if (event.getId() === readUpToId) return sawVerification;
if (isNotificationEvent(event) && !isVerificationFlowEvent(event)) return false;
if (isVerificationFlowEvent(event)) sawVerification = true;
}
return false;
};
export const roomHaveNotification = (room: Room): boolean => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -263,7 +311,11 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
export const getUnreadInfo = (
room: Room,
mutedThreads?: Set<string>,
mx?: MatrixClient,
): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -280,10 +332,26 @@ export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInf
if (highlight < 0) highlight = 0;
}
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a room whose entire unread span is a completed device verification:
// the SDK/server Total stays > 0 forever for the trailing verification-request
// message (default DM push rule, no recency gate), but it isn't real unread. A
// highlight is never a verification request, so only the highlight-free case is
// guarded. Requires `mx` (backward-compatible for callers/tests without it).
if (
mx &&
resolvedTotal > 0 &&
highlight === 0 &&
unreadIsOnlyVerification(room, mx.getUserId())
) {
return { roomId: room.roomId, highlight: 0, total: 0 };
}
return {
roomId: room.roomId,
highlight,
total: highlight > total ? highlight : total,
total: resolvedTotal,
};
};
@@ -298,13 +366,20 @@ export const getUnreadInfos = (
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
const info = getUnreadInfo(room, mutedThreads);
const info = getUnreadInfo(room, mutedThreads, mx);
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
// roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep it
// only if there's real unread (count > 0) or a genuine unread marker.
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
// only if there's real unread (count > 0) or a genuine unread marker — and
// NOT when the only unread is a completed device verification (roomHaveUnread
// can fall through to `true` for a still-encrypted/off-window verification
// tail, which getUnreadInfo above already suppressed to {0,0}).
if (
info.total > 0 ||
info.highlight > 0 ||
(roomHaveUnread(mx, room) && !unreadIsOnlyVerification(room, mx.getUserId()))
) {
unread.push(info);
}
}