- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance of the read-marker-corruption regression — opening a thread whose root is old re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression tests. - N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double- counting every ancestor-space aggregate in roomToUnread). - N2 (🔴): notifications/sounds dedupe on the event id, not the unread count — fixes "read a DM, next message never notifies again". - T4 (🟠): the thread notification path no longer re-gates on the room count, so an explicit per-thread "All replies" override in a Mentions-only room fires. - N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no longer light the nav row / pollute unread filters). - N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a threaded receipt can't wipe a room's valid main-timeline badge. - T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on a stale mid/old event). Gates: tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
56 lines
2.0 KiB
TypeScript
56 lines
2.0 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { ReceiptType } from 'matrix-js-sdk';
|
|
import { markThreadAsRead } from './threadReceipt';
|
|
|
|
// The regression this guards: sending a receipt for the thread ROOT (when
|
|
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
|
|
// receipt at an old event and drags the room's read marker backwards. It must
|
|
// only ever receipt a genuine loaded reply.
|
|
|
|
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
|
|
|
const setup = (lastReply: any) => {
|
|
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
|
|
const thread = { id: '$root', lastReply: () => lastReply } as any;
|
|
const mx = {
|
|
sendReadReceipt: async (e: any, type: ReceiptType) => {
|
|
calls.push({ eventId: e.getId(), type });
|
|
return {};
|
|
},
|
|
} as any;
|
|
return { mx, thread, calls };
|
|
};
|
|
|
|
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
|
|
const { mx, thread, calls } = setup(null);
|
|
await markThreadAsRead(mx, thread, false);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
|
|
const { mx, thread, calls } = setup(evt('$root'));
|
|
await markThreadAsRead(mx, thread, false);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test('genuine loaded reply → threaded receipt at that reply', async () => {
|
|
const { mx, thread, calls } = setup(evt('$reply'));
|
|
await markThreadAsRead(mx, thread, false);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].eventId, '$reply');
|
|
assert.equal(calls[0].type, ReceiptType.Read);
|
|
});
|
|
|
|
test('sending reply is skipped', async () => {
|
|
const { mx, thread, calls } = setup(evt('$reply', true));
|
|
await markThreadAsRead(mx, thread, false);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test('private flag uses ReadPrivate', async () => {
|
|
const { mx, thread, calls } = setup(evt('$reply'));
|
|
await markThreadAsRead(mx, thread, true);
|
|
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
|
|
});
|