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>
67 lines
3.1 KiB
TypeScript
67 lines
3.1 KiB
TypeScript
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
|
import { getSettings } from '../state/settings';
|
|
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
|
|
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
|
const { privateReadReceipts } = getSettings();
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return;
|
|
|
|
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
|
|
// receipt-driven auto-clear does NOT fire when the room is already fully read
|
|
// (no receipt is sent below in that case), so clear it directly here.
|
|
if (readMarkedUnread(room)) {
|
|
setMarkedUnread(mx, roomId, false).catch(() => undefined);
|
|
}
|
|
|
|
const receiptType =
|
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
|
|
|
const timeline = room.getLiveTimeline().getEvents();
|
|
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
|
|
|
const getLatestValidEvent = () => {
|
|
for (let i = timeline.length - 1; i >= 0; i -= 1) {
|
|
const latestEvent = timeline[i];
|
|
if (latestEvent.getId() === readEventId) return null;
|
|
if (!latestEvent.isSending()) return latestEvent;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
|
|
if (latestEvent) {
|
|
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
|
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
|
// clears the main timeline + every event up to this one.
|
|
await mx.sendReadReceipt(latestEvent, receiptType, true);
|
|
}
|
|
|
|
// Clear per-thread notification counts too — the room's unread dot sums them,
|
|
// so an unread thread reply keeps the dot lit even after the main timeline is
|
|
// read (threadSupport moves thread replies out of the main timeline, so the
|
|
// unthreaded receipt above doesn't necessarily cover them).
|
|
//
|
|
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
|
|
// NEVER fall back to the thread root: a root event is "in the main timeline",
|
|
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
|
|
// receipt at that old root event. If the root isn't in the loaded timeline it
|
|
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
|
|
// returns null -> the room is reported unread on every mark-read call (this was
|
|
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
|
|
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
|
const threads = room.getThreads();
|
|
await Promise.all(
|
|
threads.map((thread) => {
|
|
const unread =
|
|
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
|
if (unread <= 0) return undefined;
|
|
const lastReply = thread.lastReply();
|
|
if (!lastReply || lastReply.isSending()) return undefined;
|
|
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
|
// via the reply's real threadRootId; it never touches the main marker).
|
|
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
|
}),
|
|
);
|
|
}
|