import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk'; import { ReceiptContent } from 'matrix-js-sdk/lib/@types/read_receipts'; import { useEffect, useState } from 'react'; import { useMatrixClient } from './useMatrixClient'; import { reactionOrEditEvent } from '../utils/room'; // Receipts can land on reaction/edit events which RoomTimeline skips (renders null). // Walk backwards from the receipt event to find the nearest event that IS rendered. function nearestRenderableId( liveEvents: MatrixEvent[], eventIndex: Map, evtId: string, ): string | null { const idx = eventIndex.get(evtId) ?? -1; if (idx === -1) return null; for (let i = idx; i >= 0; i--) { const e = liveEvents[i]; if (!reactionOrEditEvent(e)) return e.getId() ?? null; } return null; } // A `m.receipt` event's content names every user whose receipt moved, keyed by // eventId -> receiptType -> userId. Exported for testing. export function getReceiptUserIds(content: ReceiptContent): string[] { const userIds = new Set(); Object.values(content).forEach((receiptsByType) => { Object.values(receiptsByType).forEach((receiptsByUser) => { Object.keys(receiptsByUser).forEach((userId) => userIds.add(userId)); }); }); return Array.from(userIds); } function computePositions(room: Room, myUserId: string): Map { const map = new Map(); const liveEvents = room.getLiveTimeline().getEvents(); // Build O(1) index once instead of O(T) findIndex per member const eventIndex = new Map(liveEvents.map((e, i) => [e.getId() ?? '', i])); for (const member of room.getJoinedMembers()) { if (member.userId === myUserId) continue; const evtId = room.getEventReadUpTo(member.userId); if (!evtId) continue; const targetId = nearestRenderableId(liveEvents, eventIndex, evtId); if (!targetId) continue; const arr = map.get(targetId); if (arr) arr.push(member.userId); else map.set(targetId, [member.userId]); } return map; } // Recompute positions for only the given users, reusing the previous Map/arrays for // everyone else. Returns the SAME `prevMap` reference when nothing actually changed, // so unaffected `Message`s (the overwhelming majority on every receipt) skip re-render // (Gitea #40). export function computeUpdatedPositions( room: Room, myUserId: string, prevMap: Map, changedUserIds: Iterable, ): Map { const usersToUpdate = new Set(changedUserIds); usersToUpdate.delete(myUserId); if (usersToUpdate.size === 0) return prevMap; const liveEvents = room.getLiveTimeline().getEvents(); const eventIndex = new Map(liveEvents.map((e, i) => [e.getId() ?? '', i])); // Each changed user's current target, so a receipt that didn't actually move // them past the previously-computed nearest renderable event is a true no-op. const oldTargetByUser = new Map(); prevMap.forEach((users, targetId) => { users.forEach((u) => { if (usersToUpdate.has(u)) oldTargetByUser.set(u, targetId); }); }); // Copy lazily: stay on `prevMap` (and its untouched per-event arrays) unless a // user actually moved, so unrelated Messages keep the same array/Map reference. let nextMap = prevMap; const ensureCopy = (): Map => { if (nextMap === prevMap) nextMap = new Map(prevMap); return nextMap; }; usersToUpdate.forEach((userId) => { const evtId = room.getEventReadUpTo(userId); const newTargetId = evtId ? nearestRenderableId(liveEvents, eventIndex, evtId) : null; const oldTargetId = oldTargetByUser.get(userId) ?? null; if (newTargetId === oldTargetId) return; const map = ensureCopy(); if (oldTargetId) { const users = map.get(oldTargetId); if (users) { const filtered = users.filter((u) => u !== userId); if (filtered.length === 0) map.delete(oldTargetId); else map.set(oldTargetId, filtered); } } if (newTargetId) { const users = map.get(newTargetId); map.set(newTargetId, users ? [...users, userId] : [userId]); } }); return nextMap; } export function useRoomReadPositions(room: Room): Map { const mx = useMatrixClient(); const myUserId = mx.getUserId() ?? ''; const [positions, setPositions] = useState(() => computePositions(room, myUserId)); useEffect(() => { setPositions(computePositions(room, myUserId)); let debounceTimer: ReturnType | null = null; // Accumulated across every receipt event that lands during the debounce window so // a burst of receipts still only touches the users actually named in them. const pendingUserIds = new Set(); const onReceipt = (event: MatrixEvent): void => { getReceiptUserIds(event.getContent()).forEach((userId) => pendingUserIds.add(userId), ); if (debounceTimer !== null) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { const userIds = Array.from(pendingUserIds); pendingUserIds.clear(); debounceTimer = null; setPositions((prev) => computeUpdatedPositions(room, myUserId, prev, userIds)); }, 150); }; // RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the // MatrixClient), never on Room — listening on `room` never fired. Subscribe on // the client and filter to this room (Gitea #61). const onMembership = (_ev: MatrixEvent, member: RoomMember): void => { if (member.roomId !== room.roomId) return; setPositions(computePositions(room, myUserId)); }; room.on(RoomEvent.Receipt, onReceipt); mx.on(RoomMemberEvent.Membership, onMembership); return () => { if (debounceTimer !== null) clearTimeout(debounceTimer); room.removeListener(RoomEvent.Receipt, onReceipt); mx.removeListener(RoomMemberEvent.Membership, onMembership); }; }, [room, myUserId, mx]); return positions; }