- ReadPositionsContext is provided once at Room level so the thread panel (a sibling of RoomView) gets real positions instead of the empty default; own thread messages no longer sit on "Sent" forever (#38). - Receipt events only recompute the users they name, merged into the previous map with reference equality preserved for untouched rows, so a receipt no longer re-renders every message (#40). Unit-tested. Fixes #38 Fixes #40 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
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<string, number>,
|
|
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<string>();
|
|
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<string, string[]> {
|
|
const map = new Map<string, string[]>();
|
|
const liveEvents = room.getLiveTimeline().getEvents();
|
|
// Build O(1) index once instead of O(T) findIndex per member
|
|
const eventIndex = new Map<string, number>(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<string, string[]>,
|
|
changedUserIds: Iterable<string>,
|
|
): Map<string, string[]> {
|
|
const usersToUpdate = new Set(changedUserIds);
|
|
usersToUpdate.delete(myUserId);
|
|
if (usersToUpdate.size === 0) return prevMap;
|
|
|
|
const liveEvents = room.getLiveTimeline().getEvents();
|
|
const eventIndex = new Map<string, number>(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<string, string>();
|
|
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<string, string[]> => {
|
|
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<string, string[]> {
|
|
const mx = useMatrixClient();
|
|
const myUserId = mx.getUserId() ?? '';
|
|
const [positions, setPositions] = useState(() => computePositions(room, myUserId));
|
|
|
|
useEffect(() => {
|
|
setPositions(computePositions(room, myUserId));
|
|
let debounceTimer: ReturnType<typeof setTimeout> | 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<string>();
|
|
const onReceipt = (event: MatrixEvent): void => {
|
|
getReceiptUserIds(event.getContent<ReceiptContent>()).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;
|
|
}
|