Every ReadReceiptAvatars row and every useMemberAvatar registered its own global RoomStateEvent.Members listener — ~6 per receipt row — each firing on any membership / display-name / avatar change in ANY room. Add a module-level MemberChangeStore (mirroring the PERF-1 presence store) that registers exactly ONE global Members listener and fans out to subscribers keyed by roomId|userId. Two hooks: useRoomMemberChange (single) and useRoomMembersChange (multi, one effect). useMemberAvatar and ReadReceiptAvatars use them; behavior (re-render triggers) is byte-for-byte equivalent. Unsubscribe is idempotent via a set-identity guard; the multi-hook key is order-independent. Unit-tested (key-scoped fan-out, single shared listener, idempotent unsubscribe). Reviewed by two passes (lifecycle/closure + behavioral equivalence) — clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { MatrixClient, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
import { useForceUpdate } from './useForceUpdate';
|
|
|
|
type MemberListener = () => void;
|
|
|
|
const memberKey = (roomId: string, userId: string): string => `${roomId}|${userId}`;
|
|
|
|
/**
|
|
* Shared room-member-change store. Previously every ReadReceiptAvatars row and
|
|
* every useMemberAvatar registered its own global `RoomStateEvent.Members`
|
|
* listener — ~6 per receipt row — each firing on ANY membership / display-name /
|
|
* avatar change in ANY room (PERF-3). This registers exactly ONE listener total
|
|
* and fans out to subscribers keyed by `roomId|userId`.
|
|
*
|
|
* (`RoomStateEvent.Members` is used rather than `RoomMemberEvent` because the
|
|
* latter has no dedicated avatar signal.)
|
|
*/
|
|
class MemberChangeStore {
|
|
private mx: MatrixClient | undefined;
|
|
|
|
private started = false;
|
|
|
|
private subscribers = new Map<string, Set<MemberListener>>();
|
|
|
|
private handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
|
event,
|
|
_state,
|
|
member,
|
|
) => {
|
|
const roomId = event.getRoomId();
|
|
if (!roomId) return;
|
|
const subs = this.subscribers.get(memberKey(roomId, member.userId));
|
|
if (!subs) return;
|
|
subs.forEach((cb) => cb());
|
|
};
|
|
|
|
private start(mx: MatrixClient): void {
|
|
if (this.started && this.mx === mx) return;
|
|
if (this.started && this.mx) {
|
|
this.mx.removeListener(RoomStateEvent.Members, this.handleMembers);
|
|
}
|
|
this.mx = mx;
|
|
mx.on(RoomStateEvent.Members, this.handleMembers);
|
|
this.started = true;
|
|
}
|
|
|
|
subscribe(mx: MatrixClient, roomId: string, userId: string, cb: MemberListener): () => void {
|
|
this.start(mx);
|
|
const key = memberKey(roomId, userId);
|
|
let subs = this.subscribers.get(key);
|
|
if (!subs) {
|
|
subs = new Set();
|
|
this.subscribers.set(key, subs);
|
|
}
|
|
subs.add(cb);
|
|
return () => {
|
|
subs.delete(cb);
|
|
// Only drop the registry entry if this is still the live set for the key,
|
|
// keeping unsubscribe idempotent against a double-invoke / re-subscribe.
|
|
if (subs.size === 0 && this.subscribers.get(key) === subs) {
|
|
this.subscribers.delete(key);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
export const memberChangeStore = new MemberChangeStore();
|
|
|
|
/**
|
|
* Re-render the caller whenever the given room member's state (display name,
|
|
* avatar, or membership) changes, via the shared single-listener store.
|
|
*/
|
|
export const useRoomMemberChange = (roomId: string, userId: string): void => {
|
|
const mx = useMatrixClient();
|
|
const [, forceUpdate] = useForceUpdate();
|
|
useEffect(
|
|
() => memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
|
|
[mx, roomId, userId, forceUpdate],
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Multi-user variant: re-render when ANY of `userIds` changes. Subscribes to
|
|
* each id through the shared store from a single effect (no per-id hook).
|
|
*/
|
|
export const useRoomMembersChange = (roomId: string, userIds: string[]): void => {
|
|
const mx = useMatrixClient();
|
|
const [, forceUpdate] = useForceUpdate();
|
|
// Stable, order-independent dependency for the set of ids (the array identity
|
|
// is unstable, and re-ordering the same members shouldn't churn subscriptions).
|
|
const idsKey = [...userIds].sort().join(',');
|
|
useEffect(() => {
|
|
const unsubs = userIds.map((userId) =>
|
|
memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
|
|
);
|
|
return () => unsubs.forEach((unsub) => unsub());
|
|
// `userIds` is captured via the stable `idsKey`; re-subscribes when it changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mx, roomId, idsKey, forceUpdate]);
|
|
};
|