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>
59 lines
2.5 KiB
TypeScript
59 lines
2.5 KiB
TypeScript
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]);
|
|
};
|