feat(rooms): Mark as Unread (MSC2867) + Low Priority rooms
Two Matrix protocol gaps (Phase A), gate-green (683 tests): - Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread fallback), a new markedUnreadAtom binder that seeds from account data and clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread / Read menu items and lights the row dot for a marked room. Tested. - Low Priority: m.lowpriority room tag mirroring favourites — a context-menu toggle (mutually exclusive with Favorite) and a collapsed Low Priority category sorted to the bottom of the Home room list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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]);
|
||||
};
|
||||
Reference in New Issue
Block a user