Files
cinny/src/app/utils/notifications.ts
T
Lotus CIandClaude Opus 5.5 fee3fbb7fc
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
fix(threads): viewing a room no longer reads threads you don't follow (#314)
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
2026-10-19 00:52:04 -04:00

113 lines
5.1 KiB
TypeScript

import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
import { isThreadFollowed } from './room';
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
export { isThreadFollowed };
export type MarkAsReadOptions = {
/**
* [Gitea #217] The read comes from simply looking at the room (timeline at
* the bottom and focused), not an explicit "mark as read". Threads stay
* unread until opened (or "Mark all read"), like Slack and Discord.
* [Gitea #314] That includes threads you don't follow; those no longer
* count toward the room's unread dot (see getUnreadInfo), so leaving them
* unread doesn't keep the room lit.
*/
passive?: boolean;
/**
* The thread open in the thread panel, which sends its own receipts; skip it
* here so each reply isn't receipted twice.
*/
openThreadId?: string;
};
export async function markAsRead(
mx: MatrixClient,
roomId: string,
privateReceipt: boolean,
{ passive = false, openThreadId }: MarkAsReadOptions = {},
) {
const { privateReadReceipts } = getSettings();
const room = mx.getRoom(roomId);
if (!room) return;
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
// receipt-driven auto-clear does NOT fire when the room is already fully read
// (no receipt is sent below in that case), so clear it directly here.
if (readMarkedUnread(room)) {
setMarkedUnread(mx, roomId, false).catch(() => undefined);
}
const receiptType =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
const timeline = room.getLiveTimeline().getEvents();
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
const getLatestValidEvent = () => {
for (let i = timeline.length - 1; i >= 0; i -= 1) {
const latestEvent = timeline[i];
if (latestEvent.getId() === readEventId) return null;
if (!latestEvent.isSending()) return latestEvent;
}
return null;
};
const threads = room.getThreads();
const threadUnread = (thread: Thread) =>
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
// [Gitea #314] A passive read leaves every thread's state alone (it used to
// silently read every thread you don't follow).
const keepThread = (_thread: Thread) => passive;
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
// it, so a passive read while a followed thread is unread must stay scoped
// to the main timeline or the next main message would clear that thread.
// The count can lag: a reply and a newer main message often arrive in the
// same sync, before the thread's count is updated, so also ask whether the
// latest reply has been read.
const myUserId = mx.getUserId();
const hasUnreadReply = (thread: Thread) => {
if (threadUnread(thread) > 0) return true;
const last = thread.lastReply();
const lastId = last?.getId();
if (!last || !lastId || !myUserId || last.getSender() === myUserId) return false;
return !room.hasUserReadEvent(myUserId, lastId);
};
const keepAnyThread = threads.some((thread) => keepThread(thread) && hasUnreadReply(thread));
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
if (latestEvent) {
// Unthreaded receipt: with client threadSupport enabled the SDK would
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
// clears the main timeline + every event up to this one.
await mx.sendReadReceipt(latestEvent, receiptType, !keepAnyThread);
}
// Clear per-thread notification counts too — the room's unread dot sums them,
// so an unread thread reply keeps the dot lit even after the main timeline is
// read (threadSupport moves thread replies out of the main timeline, so the
// unthreaded receipt above doesn't necessarily cover them).
//
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
// NEVER fall back to the thread root: a root event is "in the main timeline",
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
// receipt at that old root event. If the root isn't in the loaded timeline it
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
// returns null -> the room is reported unread on every mark-read call (this was
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
// If a thread's replies aren't loaded (lastReply() null), just skip it.
await Promise.all(
threads.map((thread) => {
if (threadUnread(thread) <= 0) return undefined;
if (keepThread(thread) || thread.id === openThreadId) return undefined;
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending()) return undefined;
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
// via the reply's real threadRootId; it never touches the main marker).
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
}),
);
}