Files
cinny/src/app/state/room/markedUnread.ts
T

98 lines
3.7 KiB
TypeScript
Raw Normal View History

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';
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],
),
);
};
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 read
// receipt lands for a room that's currently marked, clear it.
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
const myId = mx.getUserId();
if (!myId || !readMarkedUnread(room)) return;
if (receiptIsMine(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]);
};