fix(unread): stop DM device-verification requests re-lighting as unread
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:
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from 'matrix-js-sdk';
|
||||
import { roomHaveNotification, unreadIsOnlyVerification } from '../../utils/room';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
|
||||
/**
|
||||
* A COMPLETED in-room device-verification request is a plain `m.room.message`
|
||||
* that permanently keeps a DM's server/SDK notification count > 0 (it matches the
|
||||
* default DM push rule and there's no recency gate), so the DM re-lights as unread
|
||||
* on every fresh sync. `getUnreadInfo`'s suppression hides the dot, but the raw
|
||||
* SDK count stays "dirty" (desktop badge, other consumers) and the SDK re-inflates
|
||||
* it on every decrypt. The only durable, SDK-supported fix is a read receipt that
|
||||
* covers the request event.
|
||||
*
|
||||
* This hook sends that receipt — but ONLY when a room's ENTIRE unread span is
|
||||
* verification-flow events (`unreadIsOnlyVerification`), so it can never mark a
|
||||
* real unread message read. It fires at most once per room per session, after the
|
||||
* tail decrypts (the count is only attributable to the request post-decryption).
|
||||
* `markAsRead` honours the user's private-read-receipt setting.
|
||||
*/
|
||||
export const useAutoMarkVerificationRead = (mx: MatrixClient): void => {
|
||||
const doneRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const done = doneRef.current;
|
||||
|
||||
const maybeMark = (room: Room) => {
|
||||
const { roomId } = room;
|
||||
if (done.has(roomId)) return;
|
||||
if (room.getMyMembership() !== 'join') return;
|
||||
// Only touch rooms the SDK actually counts as notifying...
|
||||
if (!roomHaveNotification(room)) return;
|
||||
// ...and only when the whole unread span is a completed verification.
|
||||
if (!unreadIsOnlyVerification(room, mx.getUserId())) return;
|
||||
|
||||
done.add(roomId);
|
||||
markAsRead(mx, roomId, false).catch(() => {
|
||||
// Let a later decrypt/sweep retry on transient failure.
|
||||
done.delete(roomId);
|
||||
});
|
||||
};
|
||||
|
||||
// Sweep once on mount (after initial sync some verification tails are already
|
||||
// decrypted), then re-check whenever an event decrypts — the count only
|
||||
// becomes attributable to the verification request once it's decrypted.
|
||||
mx.getRooms().forEach(maybeMark);
|
||||
|
||||
const onDecrypted = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
const room = roomId ? mx.getRoom(roomId) : null;
|
||||
if (room) maybeMark(room);
|
||||
};
|
||||
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
|
||||
return () => {
|
||||
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||
};
|
||||
}, [mx]);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread'
|
||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||
import { useAutoMarkVerificationRead } from './useAutoMarkVerificationRead';
|
||||
|
||||
export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindMDirectAtom(mx, mDirectAtom);
|
||||
@@ -16,6 +17,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||
useAutoMarkVerificationRead(mx);
|
||||
|
||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||
};
|
||||
|
||||
@@ -254,6 +254,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
mx,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -332,6 +333,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
mx,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user