From 1c5331da4d03483a9c4e6cbf71e62e5d9b5a9da8 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:41:35 +0000 Subject: [PATCH] File zone: one room subscription for every 'read by' row 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 --- src/app/features/albums/receiptStore.test.ts | 116 +++++++++++++++++++ src/app/features/albums/receiptStore.ts | 79 +++++++++++++ src/app/features/albums/useFileReaders.ts | 49 ++------ 3 files changed, 206 insertions(+), 38 deletions(-) create mode 100644 src/app/features/albums/receiptStore.test.ts create mode 100644 src/app/features/albums/receiptStore.ts diff --git a/src/app/features/albums/receiptStore.test.ts b/src/app/features/albums/receiptStore.test.ts new file mode 100644 index 00000000..c97202e5 --- /dev/null +++ b/src/app/features/albums/receiptStore.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/app/features/albums/receiptStore.ts b/src/app/features/albums/receiptStore.ts new file mode 100644 index 00000000..1f69aba9 --- /dev/null +++ b/src/app/features/albums/receiptStore.ts @@ -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(); + +export const receiptStoreOf = (room: Room): ReceiptStore => { + let store = stores.get(room); + if (!store) { + store = make(room); + stores.set(room, store); + } + return store; +}; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts index 76517600..5ade3827 100644 --- a/src/app/features/albums/useFileReaders.ts +++ b/src/app/features/albums/useFileReaders.ts @@ -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([]); - 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] + ); };