fix(threads): viewing a room no longer reads threads you don't follow (#314)
CI / Build & Quality Checks (pull_request) Successful in 1m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m31s

A passive read (just looking at the room) sent a threaded receipt for
every unread thread you don't follow, so the Threads panel's Unread
filter and Mark all read only ever worked for followed threads.
Decision (2026-10-19): leave them unread until opened, but stop
counting unfollowed threads toward the room's unread dot
(getUnreadInfo subtracts them like muted threads; a mention makes a
thread followed, so highlights are never hidden). isThreadFollowed
moves to utils/room. Adds a Playwright regression.

Fixes #314

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-19 00:52:04 -04:00
co-authored by Claude Opus 5.5
parent 04605f2680
commit fee3fbb7fc
5 changed files with 135 additions and 29 deletions
+17 -11
View File
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
// 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 () => {
test('passive: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
const other = thread('$other', evt('$r2'));
const { mx, calls } = setup({
@@ -181,10 +181,9 @@ test('passive: a thread I replied in stays unread; main receipt is scoped to mai
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],
],
// main-scoped (unthreaded would read the threads' replies), and no thread
// receipts: [Gitea #314] unfollowed threads used to be read silently.
[['b', false]],
);
});
@@ -203,7 +202,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
assert.equal(calls.length, 0);
});
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
@@ -214,10 +213,16 @@ test('passive: no followed thread unread → main receipt stays unthreaded', asy
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', true],
['$r2', false],
],
[['b', false]],
);
});
test('passive: no thread unread → main receipt stays unthreaded', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [] });
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[['b', true]],
);
});
@@ -249,9 +254,10 @@ test('passive: the thread open in the panel is left to the panel', async () => {
threadUnread: { $open: 1, $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
// The open thread is left to the panel, and [#314] the other one stays unread.
assert.deepEqual(
calls.map((c) => c.eventId),
['$r2'],
[],
);
});