import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk'; import { getSettings } from '../state/settings'; import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread'; /** * [Gitea #217] Threads the user is following: they started it, replied in it, * or were mentioned in it. `hasCurrentUserParticipated` comes from the server's * thread bundle and lags a reply we just sent, so our own loaded events count too. */ export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean { if (thread.hasCurrentUserParticipated) return true; if (userId && thread.rootEvent?.getSender() === userId) return true; if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true; return ( (room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0 ); } 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 the user * follows stay unread until their panel is opened, like Slack and Discord; * the rest are cleared so they don't keep the room dot lit forever. */ 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; const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId()); // [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); }), ); }