Files
cinny/src/app/utils/notifications.ts
T

119 lines
5.5 KiB
TypeScript
Raw Normal View History

import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
2022-03-18 09:09:14 +05:30
/**
* [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();
2022-03-18 09:09:14 +05:30
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;
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;
};
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);
}),
2025-02-26 21:44:53 +11:00
);
2022-03-18 09:09:14 +05:30
}