fix(timeline): thread panel shows read receipts; receipts recompute incrementally

- 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
This commit is contained in:
2026-09-15 21:32:06 -04:00
co-authored by Claude Opus 5
parent e0861849b7
commit 9e566807b3
4 changed files with 259 additions and 85 deletions
@@ -0,0 +1,88 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room, MatrixEvent } from 'matrix-js-sdk';
import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { computeUpdatedPositions, getReceiptUserIds } from './useRoomReadPositions';
// Fake, renderable (no reaction/edit relation) event.
const fakeEvent = (id: string) =>
({
getId: () => id,
getRelation: () => null,
}) as unknown as MatrixEvent;
// Minimal fake room: a fixed live timeline plus a mutable per-user read-up-to map,
// which the test mutates between calls to simulate new receipts arriving.
const makeFakeRoom = (eventIds: string[]) => {
const readUpTo = new Map<string, string>();
const events = eventIds.map(fakeEvent);
const room = {
getLiveTimeline: () => ({ getEvents: () => events }),
getEventReadUpTo: (userId: string) => readUpTo.get(userId) ?? null,
} as unknown as Room;
return { room, readUpTo };
};
test('getReceiptUserIds collects every userId across events/types in the content', () => {
const content = {
$a: {
[ReceiptType.Read]: { '@alice:hs': { ts: 1 }, '@bob:hs': { ts: 2 } },
},
$b: {
[ReceiptType.ReadPrivate]: { '@carol:hs': { ts: 3 } },
},
};
const ids = getReceiptUserIds(content).sort();
assert.deepEqual(ids, ['@alice:hs', '@bob:hs', '@carol:hs']);
});
test('getReceiptUserIds returns empty for an empty receipt content', () => {
assert.deepEqual(getReceiptUserIds({}), []);
});
test('computeUpdatedPositions returns the SAME map reference when no named user changed position', () => {
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
readUpTo.set('@alice:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
// Alice's read-up-to did not move; recompute should be a no-op reference-wise.
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.equal(next, prev);
});
test('computeUpdatedPositions moves only the named user, leaving other entries untouched by reference', () => {
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
readUpTo.set('@alice:hs', '$a');
readUpTo.set('@bob:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs', '@bob:hs']);
const bArrayBefore = prev.get('$a');
// Only alice moves to $b.
readUpTo.set('@alice:hs', '$b');
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.notEqual(next, prev);
assert.deepEqual(next.get('$a'), ['@bob:hs']);
assert.deepEqual(next.get('$b'), ['@alice:hs']);
// Untouched arrays for other targets should keep their old reference where possible.
assert.notEqual(next.get('$a'), bArrayBefore); // this one WAS filtered, expected to change
});
test('computeUpdatedPositions ignores changedUserIds not present after filtering myUserId', () => {
const { room } = makeFakeRoom(['$a']);
const prev = new Map([['$a', ['@bob:hs']]]);
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@me:hs']);
assert.equal(next, prev);
});
test('computeUpdatedPositions removes a user with no read-up-to event from the map', () => {
const { room, readUpTo } = makeFakeRoom(['$a']);
readUpTo.set('@alice:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
readUpTo.delete('@alice:hs');
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.equal(next.has('$a'), false);
});
+81 -2
View File
@@ -1,4 +1,5 @@
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';
@@ -19,6 +20,18 @@ function nearestRenderableId(
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();
@@ -37,6 +50,64 @@ function computePositions(room: Room, myUserId: string): Map<string, string[]> {
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() ?? '';
@@ -45,11 +116,19 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
useEffect(() => {
setPositions(computePositions(room, myUserId));
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const onReceipt = (): void => {
// 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(() => {
setPositions(computePositions(room, myUserId));
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