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>
117 lines
4.7 KiB
TypeScript
117 lines
4.7 KiB
TypeScript
import { atom, useSetAtom } from 'jotai';
|
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
|
import { useEffect } from 'react';
|
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
|
|
|
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
|
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
|
// the unstable `com.famedly.marked_unread`. We read either and write both so the
|
|
// flag round-trips across the ecosystem.
|
|
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
|
|
|
|
export const readMarkedUnread = (room: Room): boolean => {
|
|
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
|
|
if (typeof stable === 'boolean') return stable;
|
|
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
|
|
};
|
|
|
|
/** Set of room ids the user has explicitly marked as unread. */
|
|
export const markedUnreadAtom = atom<Set<string>>(new Set<string>());
|
|
|
|
/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */
|
|
export const setMarkedUnread = (
|
|
mx: MatrixClient,
|
|
roomId: string,
|
|
unread: boolean,
|
|
): Promise<unknown> =>
|
|
Promise.all([
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
|
// Best-effort mirror for older servers; never fail the primary write on it.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
|
]);
|
|
|
|
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
|
const content = event.getContent();
|
|
return Object.keys(content).some((eventId) =>
|
|
Object.keys(content[eventId] ?? {}).some(
|
|
(receiptType) => content[eventId][receiptType]?.[userId],
|
|
),
|
|
);
|
|
};
|
|
|
|
// True only when OUR receipt in this event is for the main timeline — either
|
|
// unthreaded (no thread_id) or thread_id "main". A receipt scoped to a specific
|
|
// thread (thread_id === <threadRootId>) must NOT clear the whole-room marked
|
|
// flag, since only that one thread was read.
|
|
export const myMainReceiptPresent = (event: MatrixEvent, userId: string): boolean => {
|
|
const content = event.getContent();
|
|
return Object.keys(content).some((eventId) =>
|
|
Object.keys(content[eventId] ?? {}).some((receiptType) => {
|
|
const receipt = content[eventId][receiptType]?.[userId];
|
|
if (!receipt) return false;
|
|
const threadId = (receipt as { thread_id?: string }).thread_id;
|
|
return threadId === undefined || threadId === 'main';
|
|
}),
|
|
);
|
|
};
|
|
|
|
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
|
|
const setAtom = useSetAtom(anAtom);
|
|
|
|
useEffect(() => {
|
|
const seed = new Set<string>();
|
|
mx.getRooms().forEach((room) => {
|
|
if (readMarkedUnread(room)) seed.add(room.roomId);
|
|
});
|
|
setAtom(seed);
|
|
|
|
const syncRoom = (room: Room) => {
|
|
const marked = readMarkedUnread(room);
|
|
setAtom((prev) => {
|
|
if (marked === prev.has(room.roomId)) return prev;
|
|
const next = new Set(prev);
|
|
if (marked) next.add(room.roomId);
|
|
else next.delete(room.roomId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
|
|
syncRoom(room);
|
|
};
|
|
// Reading a room clears its marked-unread flag (MSC2867): when our own
|
|
// MAIN-timeline read receipt lands for a room that's currently marked, clear
|
|
// it. Gated to main/unthreaded receipts so reading a single thread doesn't
|
|
// wipe the whole-room flag. (This also fires for receipts from our other
|
|
// devices; the local read path clears via markAsRead in notifications.ts.)
|
|
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
|
|
const myId = mx.getUserId();
|
|
if (!myId || !readMarkedUnread(room)) return;
|
|
if (myMainReceiptPresent(event, myId)) {
|
|
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
|
}
|
|
};
|
|
const onMembership: RoomEventHandlerMap[RoomEvent.MyMembership] = (room) => {
|
|
if (room.getMyMembership() !== 'join') {
|
|
setAtom((prev) => {
|
|
if (!prev.has(room.roomId)) return prev;
|
|
const next = new Set(prev);
|
|
next.delete(room.roomId);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
mx.on(RoomEvent.AccountData, onAccountData);
|
|
mx.on(RoomEvent.Receipt, onReceipt);
|
|
mx.on(RoomEvent.MyMembership, onMembership);
|
|
return () => {
|
|
mx.removeListener(RoomEvent.AccountData, onAccountData);
|
|
mx.removeListener(RoomEvent.Receipt, onReceipt);
|
|
mx.removeListener(RoomEvent.MyMembership, onMembership);
|
|
};
|
|
}, [mx, setAtom]);
|
|
};
|