import { test } from 'node:test'; import assert from 'node:assert/strict'; import { NotificationCountType, ReceiptType } from 'matrix-js-sdk'; import { markAsRead } from './notifications'; // markAsRead sends an unthreaded read receipt at the latest main-timeline event, // plus a THREADED receipt at each unread thread's latest loaded reply. The // regression these tests guard against: a thread whose replies aren't loaded // (lastReply() === null) must NOT produce a receipt for the thread root — that // resolves to a MAIN receipt at an old event and permanently unreads the room. type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean }; const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any; const thread = (id: string, lastReply: any, extra: Record = {}) => ({ id, lastReply: () => lastReply, ...extra }) as any; type RoomOpts = { timeline?: any[]; readUpTo?: string | null; threads?: any[]; threadUnread?: Record; threadHighlight?: Record; readEvents?: string[]; markedUnread?: boolean; }; const setup = (opts: RoomOpts) => { const calls: ReceiptCall[] = []; const accountDataWrites: Array<{ type: string; content: any }> = []; const room = { getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }), getEventReadUpTo: () => opts.readUpTo ?? null, getThreads: () => opts.threads ?? [], hasUserReadEvent: (_userId: string, eventId: string) => (opts.readEvents ?? []).includes(eventId), getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) => (type === NotificationCountType.Highlight ? opts.threadHighlight : opts.threadUnread)?.[ threadId ] ?? 0, getAccountData: (type: string) => opts.markedUnread && type === 'm.marked_unread' ? { getContent: () => ({ unread: true }) } : undefined, }; const mx = { getRoom: () => room, getUserId: () => '@me:server', sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => { calls.push({ eventId: event.getId(), receiptType, unthreaded }); return {}; }, setRoomAccountData: async (_roomId: string, type: string, content: any) => { accountDataWrites.push({ type, content }); return {}; }, } as any; return { mx, calls, accountDataWrites }; }; test('main timeline: unthreaded receipt at the latest event', async () => { const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' }); await markAsRead(mx, '!r:server', false); assert.equal(calls.length, 1); assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true }); }); test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => { const t = thread('$root', null); // replies not loaded const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [t], threadUnread: { $root: 3 }, }); await markAsRead(mx, '!r:server', false); // Only the main unthreaded receipt — never a receipt for the thread root. assert.equal(calls.length, 1); assert.equal(calls[0].eventId, 'b'); assert.equal(calls[0].unthreaded, true); assert.ok(!calls.some((c) => c.eventId === '$root')); }); test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => { const t = thread('$root', evt('$reply')); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [t], threadUnread: { $root: 1 }, }); await markAsRead(mx, '!r:server', false); const main = calls.find((c) => c.eventId === 'b'); const threaded = calls.find((c) => c.eventId === '$reply'); assert.ok(main && main.unthreaded === true); assert.ok(threaded && threaded.unthreaded === false); assert.equal(calls.length, 2); }); test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => { const t = thread('$root', evt('$reply')); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'b', // latest main event already read → getLatestValidEvent() null threads: [t], threadUnread: { $root: 2 }, }); await markAsRead(mx, '!r:server', false); assert.equal(calls.length, 1); assert.equal(calls[0].eventId, '$reply'); assert.equal(calls[0].unthreaded, false); }); test('everything read: no receipts sent', async () => { const t = thread('$root', evt('$reply')); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'b', threads: [t], threadUnread: { $root: 0 }, // thread read too }); await markAsRead(mx, '!r:server', false); assert.equal(calls.length, 0); }); test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => { const { mx, calls, accountDataWrites } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'b', // nothing newer → no receipt markedUnread: true, }); await markAsRead(mx, '!r:server', false); assert.equal(calls.length, 0); // no receipt (the stuck-dot case) // ...but the marked-unread flag is cleared directly (both keys, unread:false) assert.ok( accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false), ); }); test('not marked-unread: markAsRead does not touch account data', async () => { const { mx, accountDataWrites } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', }); await markAsRead(mx, '!r:server', false); assert.equal(accountDataWrites.length, 0); }); test('sending thread reply is skipped', async () => { const t = thread('$root', evt('$reply', true)); // isSending → skip const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'b', threads: [t], threadUnread: { $root: 1 }, }); await markAsRead(mx, '!r:server', false); assert.equal(calls.length, 0); }); test('private receipt flag uses ReadPrivate', async () => { const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' }); await markAsRead(mx, '!r:server', true); assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate); }); // [Gitea #217] A passive read (just looking at the room) keeps threads the user // follows unread; an explicit mark-as-read still clears everything. const sender = (userId: string) => ({ getSender: () => userId }); test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => { const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true }); const other = thread('$other', evt('$r2')); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [mine, other], threadUnread: { $mine: 1, $other: 1 }, }); await markAsRead(mx, '!r:server', false, { passive: true }); assert.deepEqual( calls.map((c) => [c.eventId, c.unthreaded]), [ ['b', false], // main-scoped: unthreaded would also read $mine's reply ['$r2', false], ], ); }); test('passive: threads I started or was mentioned in stay unread', async () => { const started = thread('$started', evt('$r1'), { rootEvent: sender('@me:server') }); const byTimeline = thread('$t', evt('$r2'), { timeline: [sender('@me:server')] }); const mentioned = thread('$mention', evt('$r3')); const { mx, calls } = setup({ timeline: [evt('a')], readUpTo: 'a', threads: [started, byTimeline, mentioned], threadUnread: { $started: 1, $t: 1, $mention: 1 }, threadHighlight: { $mention: 1 }, }); await markAsRead(mx, '!r:server', false, { passive: true }); assert.equal(calls.length, 0); }); test('passive: no followed thread unread → main receipt stays unthreaded', async () => { const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') }); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [other], threadUnread: { $other: 1 }, }); await markAsRead(mx, '!r:server', false, { passive: true }); assert.deepEqual( calls.map((c) => [c.eventId, c.unthreaded]), [ ['b', true], ['$r2', false], ], ); }); test('explicit mark as read clears followed threads too', async () => { const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true }); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [mine], threadUnread: { $mine: 1 }, }); await markAsRead(mx, '!r:server', false); assert.deepEqual( calls.map((c) => [c.eventId, c.unthreaded]), [ ['b', true], ['$r1', false], ], ); }); test('passive: the thread open in the panel is left to the panel', async () => { const open = thread('$open', evt('$r1')); const other = thread('$other', evt('$r2')); const { mx, calls } = setup({ timeline: [evt('a')], readUpTo: 'a', threads: [open, other], threadUnread: { $open: 1, $other: 1 }, }); await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' }); assert.deepEqual( calls.map((c) => c.eventId), ['$r2'], ); }); test('passive: a followed thread whose count lags is still protected', async () => { // The reply and a newer main message arrived in the same sync; the thread's // count is still 0 but its latest reply (from bob) is unread. const reply = { ...evt('$r1'), getSender: () => '@bob:server' }; const mine = thread('$mine', reply, { hasCurrentUserParticipated: true }); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [mine], threadUnread: { $mine: 0 }, }); await markAsRead(mx, '!r:server', false, { passive: true }); assert.deepEqual( calls.map((c) => [c.eventId, c.unthreaded]), [['b', false]], ); }); test('passive: followed thread already read → main receipt stays unthreaded', async () => { const reply = { ...evt('$r1'), getSender: () => '@bob:server' }; const mine = thread('$mine', reply, { hasCurrentUserParticipated: true }); const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [mine], readEvents: ['$r1'], }); await markAsRead(mx, '!r:server', false, { passive: true }); assert.deepEqual( calls.map((c) => [c.eventId, c.unthreaded]), [['b', true]], ); });