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
119 lines
5.5 KiB
TypeScript
119 lines
5.5 KiB
TypeScript
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);
|
|
}),
|
|
);
|
|
}
|