fix(unread): stop stuck/resurrecting read indicators
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.
Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).
Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.
Tests: 700/700 pass; typecheck + prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,6 @@ import {
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
roomHaveUnread,
|
||||
} from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
@@ -139,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
||||
}
|
||||
if (action.type === 'PUT') {
|
||||
const { unreadInfo } = action;
|
||||
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
|
||||
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
|
||||
// the UnreadNotifications listener firing once the server zeroes counts)
|
||||
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
|
||||
// actually clears. Done before the unreadEqual short-circuit so an
|
||||
// already-stuck { 0, 0 } gets removed too.
|
||||
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
|
||||
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
|
||||
set(
|
||||
baseRoomToUnread,
|
||||
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||
deleteUnreadInfo(
|
||||
draftRoomToUnread,
|
||||
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
||||
unreadInfo.roomId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
||||
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
||||
// Do not update if unread data has not changes
|
||||
@@ -256,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
),
|
||||
);
|
||||
if (isMyReceipt) {
|
||||
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
|
||||
// receipt (reading one thread) would wipe the room's still-valid
|
||||
// main-timeline badge, and if the room was already read no
|
||||
// UnreadNotifications PUT follows to restore it. Recompute instead —
|
||||
// DELETE only when the room is genuinely fully read.
|
||||
const info = getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
);
|
||||
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
} else {
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: info });
|
||||
}
|
||||
// Optimistically clear on our own receipt (upstream cinny behavior).
|
||||
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
|
||||
// server-computed and STALE on the synchronous synthetic receipt echo
|
||||
// (the SDK only zeroes it immediately when the last live event is our own
|
||||
// message), so recomputing PUTs the stale non-zero count back → the dot
|
||||
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
|
||||
// re-asserts the accurate badge (incl. restoring the main badge after a
|
||||
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
|
||||
// DELETE in the reducer.
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||
|
||||
Reference in New Issue
Block a user