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,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'));
|
||||
});
|
||||
Reference in New Issue
Block a user