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:
2026-07-03 00:04:47 -04:00
co-authored by Claude Opus 4.8
parent 5b94a44eb3
commit d46b91b1b8
8 changed files with 301 additions and 10 deletions
+2
View File
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent } from 'matrix-js-sdk';
import { receiptIsMine, setMarkedUnread } from './markedUnread';
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
// must land on BOTH the stable `m.marked_unread` and the unstable
// `com.famedly.marked_unread` key so it round-trips across servers/clients.
const ME = '@me:server';
const OTHER = '@friend:server';
const receiptEvent = (content: object): MatrixEvent =>
({ getContent: () => content }) as MatrixEvent;
test('receiptIsMine: true when the receipt content carries our user id', () => {
const event = receiptEvent({
$abc: { 'm.read': { [ME]: { ts: 1 } } },
});
assert.equal(receiptIsMine(event, ME), true);
});
test('receiptIsMine: false when only another user has a receipt', () => {
const event = receiptEvent({
$abc: { 'm.read': { [OTHER]: { ts: 1 } } },
});
assert.equal(receiptIsMine(event, ME), false);
});
test('receiptIsMine: tolerates empty / malformed content', () => {
assert.equal(receiptIsMine(receiptEvent({}), ME), false);
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
});
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
const calls: Array<{ type: string; content: unknown }> = [];
const mx = {
setRoomAccountData: (_roomId: string, type: string, content: unknown) => {
calls.push({ type, content });
return Promise.resolve();
},
} as any;
await setMarkedUnread(mx, '!room:server', true);
const types = calls.map((c) => c.type).sort();
assert.deepEqual(types, ['com.famedly.marked_unread', 'm.marked_unread']);
assert.ok(calls.every((c) => (c.content as { unread: boolean }).unread === true));
});
test('setMarkedUnread(false) clears both keys and does not reject if the unstable write fails', async () => {
const seen: string[] = [];
const mx = {
setRoomAccountData: (_roomId: string, type: string) => {
seen.push(type);
// Simulate an older server rejecting the unstable key — must not reject.
if (type === 'com.famedly.marked_unread') return Promise.reject(new Error('unknown type'));
return Promise.resolve();
},
} as any;
await assert.doesNotReject(() => setMarkedUnread(mx, '!room:server', false));
assert.ok(seen.includes('m.marked_unread'));
});
+97
View File
@@ -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]);
};