2026-09-26 19:31:00 -04:00
|
|
|
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
|
2026-06-02 19:31:30 -04:00
|
|
|
import { getSettings } from '../state/settings';
|
2026-07-03 22:07:21 -04:00
|
|
|
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
2022-03-18 09:09:14 +05:30
|
|
|
|
2026-09-26 19:31:00 -04:00
|
|
|
/**
|
|
|
|
|
* [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 = {},
|
|
|
|
|
) {
|
2026-06-02 19:31:30 -04:00
|
|
|
const { privateReadReceipts } = getSettings();
|
2022-03-18 09:09:14 +05:30
|
|
|
const room = mx.getRoom(roomId);
|
|
|
|
|
if (!room) return;
|
|
|
|
|
|
2026-07-03 22:07:21 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 16:31:10 -04:00
|
|
|
const receiptType =
|
|
|
|
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
|
|
|
|
|
2022-03-18 09:09:14 +05:30
|
|
|
const timeline = room.getLiveTimeline().getEvents();
|
2024-07-22 16:17:19 +05:30
|
|
|
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
2022-03-18 09:09:14 +05:30
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 19:31:00 -04:00
|
|
|
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));
|
|
|
|
|
|
2026-07-02 16:31:10 -04:00
|
|
|
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
|
2026-07-02 17:09:28 -04:00
|
|
|
// clears the main timeline + every event up to this one.
|
2026-09-26 19:31:00 -04:00
|
|
|
await mx.sendReadReceipt(latestEvent, receiptType, !keepAnyThread);
|
2026-07-02 16:31:10 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:09:28 -04:00
|
|
|
// 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.
|
2026-07-02 16:31:10 -04:00
|
|
|
await Promise.all(
|
|
|
|
|
threads.map((thread) => {
|
2026-09-26 19:31:00 -04:00
|
|
|
if (threadUnread(thread) <= 0) return undefined;
|
|
|
|
|
if (keepThread(thread) || thread.id === openThreadId) return undefined;
|
2026-07-02 17:09:28 -04:00
|
|
|
const lastReply = thread.lastReply();
|
2026-07-02 16:31:10 -04:00
|
|
|
if (!lastReply || lastReply.isSending()) return undefined;
|
2026-07-02 17:09:28 -04:00
|
|
|
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
|
|
|
|
// via the reply's real threadRootId; it never touches the main marker).
|
2026-07-02 16:31:10 -04:00
|
|
|
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
|
|
|
|
}),
|
2025-02-26 21:44:53 +11:00
|
|
|
);
|
2022-03-18 09:09:14 +05:30
|
|
|
}
|