Files
cinny/src/app/utils/notifications.test.ts
T
Lotus CIandClaude Opus 5.5 a8db61f79f fix(threads): viewing a room no longer marks threads you follow read (#217)
markAsRead ran on every room visit (timeline at the bottom and focused) and
sent a threaded receipt for every unread thread, so a reply in a thread you
started or replied in lost its unread badge the moment you glanced at the
room, without opening the thread.

Reads from just viewing the timeline are now "passive":
- threads you follow (started, replied in, or were mentioned in) stay unread
  until their panel is opened; other threads are still cleared so they don't
  keep the room dot lit forever;
- while a followed thread has an unread reply, the main receipt is scoped to
  the main timeline instead of unthreaded, because an unthreaded receipt
  also reads every older thread reply (the next main message would clear the
  thread anyway). The check also asks whether the latest reply is read, since
  the thread's count lags when the reply and a main message share a sync;
- the thread open in the panel is skipped, as the panel sends its own
  receipt (was two identical receipts per reply).

Explicit "mark as read" (room menu, Escape, bulk actions) still clears
everything. Unit tests for each rule plus a local-homeserver e2e that checks
the server's per-thread count survives a reply + newer main message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 19:31:00 -04:00

291 lines
10 KiB
TypeScript

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<string, unknown> = {}) =>
({ id, lastReply: () => lastReply, ...extra }) as any;
type RoomOpts = {
timeline?: any[];
readUpTo?: string | null;
threads?: any[];
threadUnread?: Record<string, number>;
threadHighlight?: Record<string, number>;
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]],
);
});