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
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:
parent
4876d31428
commit
1c5331da4d
3 changed files with 206 additions and 38 deletions
116
src/app/features/albums/receiptStore.test.ts
Normal file
116
src/app/features/albums/receiptStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
src/app/features/albums/receiptStore.ts
Normal file
79
src/app/features/albums/receiptStore.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue