File zone: one room subscription for every 'read by' row
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s

receiptStore.ts holds one Timeline/Redaction/TimelineReset listener set per room while any row is mounted and a snapshot of the receipt events; useFileReaders derives each row's readers from it with readersOf (same rules). 200 rows register one listener per event, not 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-11 22:41:35 +00:00
commit 1c5331da4d
3 changed files with 206 additions and 38 deletions

View file

@ -0,0 +1,116 @@
import { EventEmitter } from 'events';
import { describe, expect, it, vi } from 'vitest';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { receiptStoreOf } from './receiptStore';
import { readersOf } from './fileReceipt';
const HASH = 'a'.repeat(64);
type Fake = {
id: string;
type: string;
sender: string;
ts: number;
content: object;
redacted?: boolean;
};
const event = (f: Fake) =>
({
getId: () => f.id,
getType: () => f.type,
getSender: () => f.sender,
getTs: () => f.ts,
getContent: () => f.content,
isRedacted: () => !!f.redacted,
} as unknown as MatrixEvent);
const receipt = (id: string, sender: string, fileId = '$f', over: Partial<Fake> = {}): Fake => ({
id,
type: 'org.gamecult.file_received',
sender,
ts: 1,
content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } },
...over,
});
const fakeRoom = (initial: Fake[] = []) => {
const emitter = new EventEmitter();
emitter.setMaxListeners(0);
let events = initial;
const room = Object.assign(emitter, {
getUnfilteredTimelineSet: () => ({
getLiveTimeline: () => ({ getEvents: () => events.map(event) }),
}),
}) as unknown as Room;
return {
room,
emitter,
set: (next: Fake[]) => {
events = next;
},
};
};
const listeners = (e: EventEmitter) =>
[RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n));
describe('receiptStoreOf: one subscription per room', () => {
it('registers one listener per event for 200 file rows, and none once they are gone', () => {
const { room, emitter } = fakeRoom();
const store = receiptStoreOf(room);
const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined));
expect(listeners(emitter)).toEqual([1, 1, 1]);
off.forEach((o) => o());
expect(listeners(emitter)).toEqual([0, 0, 0]);
});
it('is one store per room', () => {
const a = fakeRoom();
const b = fakeRoom();
expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room));
expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room));
});
it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => {
const { room, emitter, set } = fakeRoom();
const store = receiptStoreOf(room);
const cb = vi.fn();
store.subscribe(cb);
expect(store.getSnapshot()).toEqual([]);
set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]);
emitter.emit(RoomEvent.Timeline);
expect(cb).not.toHaveBeenCalled();
set([receipt('$r', '@void:gamecult.org')]);
emitter.emit(RoomEvent.Timeline);
expect(cb).toHaveBeenCalledTimes(1);
const snap = store.getSnapshot();
expect(
readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender)
).toEqual(['@void:gamecult.org']);
emitter.emit(RoomEvent.Timeline);
expect(store.getSnapshot()).toBe(snap);
});
it('rescans on a redaction and on a timeline reset', () => {
const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]);
const store = receiptStoreOf(room);
const cb = vi.fn();
store.subscribe(cb);
expect(store.getSnapshot()).toHaveLength(1);
set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]);
emitter.emit(RoomEvent.Redaction);
expect(cb).toHaveBeenCalledTimes(1);
expect(
readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org')
).toEqual([]);
set([]);
emitter.emit(RoomEvent.TimelineReset);
expect(cb).toHaveBeenCalledTimes(2);
expect(store.getSnapshot()).toEqual([]);
});
it('picks up what is already loaded when the first row subscribes', () => {
const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]);
const store = receiptStoreOf(room);
store.subscribe(() => undefined);
expect(store.getSnapshot()).toHaveLength(1);
});
});

View file

@ -0,0 +1,79 @@
import type { MatrixEvent, Room } from 'matrix-js-sdk';
import { RoomEvent } from 'matrix-js-sdk';
import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt';
/**
* One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a
* callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events
* themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per
* Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts
* or one's redaction state does, so rows re-render for a receipt, not for every timeline event.
*/
export type ReceiptStore = {
subscribe: (onChange: () => void) => () => void;
getSnapshot: () => ReceiptEvent[];
};
const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({
event_id: e.getId(),
type: e.getType(),
sender: e.getSender(),
origin_server_ts: e.getTs(),
content: e.getContent(),
unsigned: e.isRedacted() ? { redacted_because: true } : undefined,
});
const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean =>
a.length === b.length &&
a.every(
(r, i) =>
r.event_id === b[i].event_id &&
!r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because
);
// A redaction removes a tick and a reset drops the window: both rescan, not only a new event.
const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const;
const make = (room: Room): ReceiptStore => {
const subscribers = new Set<() => void>();
let snapshot: ReceiptEvent[] = [];
const read = (): boolean => {
const next = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter((e) => e.getType() === FILE_RECEIVED)
.map(asReceiptEvent);
if (same(snapshot, next)) return false;
snapshot = next;
return true;
};
const scan = () => {
if (read()) subscribers.forEach((cb) => cb());
};
return {
subscribe: (onChange) => {
subscribers.add(onChange);
if (subscribers.size === 1) {
EVENTS.forEach((ev) => room.on(ev, scan));
read(); // the first row sees what is already loaded; it reads the snapshot after subscribing
}
return () => {
subscribers.delete(onChange);
if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan));
};
},
getSnapshot: () => snapshot,
};
};
const stores = new WeakMap<Room, ReceiptStore>();
export const receiptStoreOf = (room: Room): ReceiptStore => {
let store = stores.get(room);
if (!store) {
store = make(room);
stores.set(room, store);
}
return store;
};

View file

@ -1,20 +1,13 @@
import { useEffect, useState } from 'react';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { useMemo, useSyncExternalStore } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { FILE_RECEIVED, FileReader, ReceiptEvent, readersOf } from './fileReceipt';
const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({
event_id: e.getId(),
type: e.getType(),
sender: e.getSender(),
origin_server_ts: e.getTs(),
content: e.getContent(),
unsigned: e.isRedacted() ? { redacted_because: true } : undefined,
});
import { FileReader, readersOf } from './fileReceipt';
import { receiptStoreOf } from './receiptStore';
/**
* Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both
* count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`.
* The room's one shared subscription (receiptStore.ts) feeds every file row.
*/
export const useFileReaders = (
room: Room,
@ -23,30 +16,10 @@ export const useFileReaders = (
): FileReader[] => {
const mx = useMatrixClient();
const server = mx.getDomain() ?? '';
const [readers, setReaders] = useState<FileReader[]>([]);
useEffect(() => {
const scan = () => {
const receipts = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter((e) => e.getType() === FILE_RECEIVED)
.map(asReceiptEvent);
const next = readersOf(receipts, fileId, content, server);
setReaders((cur) =>
cur.length === next.length &&
cur.every((r, i) => r.sender === next[i].sender && r.ts === next[i].ts)
? cur
: next
);
};
scan();
// A redaction removes a tick and a reset drops the window: both rescan, not only a new event.
const events = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const;
events.forEach((ev) => room.on(ev, scan));
return () => {
events.forEach((ev) => room.off(ev, scan));
};
}, [room, fileId, content, server]);
return readers;
const store = receiptStoreOf(room);
const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot);
return useMemo(
() => readersOf(receipts, fileId, content, server),
[receipts, fileId, content, server]
);
};