import { useEffect, useMemo, useState } from 'react'; import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', } export type UserPresence = { presence: Presence; status?: string; active: boolean; lastActiveTs?: number; }; const getUserPresence = (user: User): UserPresence => ({ presence: user.presence as Presence, status: user.presenceStatusMsg, active: user.currentlyActive, lastActiveTs: user.getLastActiveTs(), }); type PresenceListener = () => void; /** * Shared presence store. Previously every avatar's `useUserPresence` registered * its own 3 client listeners (Presence / CurrentlyActive / LastPresenceTs) — on * a big room that meant hundreds of global listeners, each presence event * fanning out across all of them, with churn on every fast scroll (PERF-1). * * This registers exactly 3 listeners total (once, on the first subscriber) and * fans out to per-user subscribers itself. Subscribing on the client rather * than per-`User` object also avoids the SDK's 10-listener-per-User cap. */ class PresenceStore { private mx: MatrixClient | undefined; private started = false; // userId -> set of component callbacks to notify on change. private subscribers = new Map>(); // userId -> last computed presence (stable reference until it changes), only // retained while at least one subscriber for that user is mounted. private cache = new Map(); private handleEvent: UserEventHandlerMap[UserEvent.Presence] = (_event, user) => { const subs = this.subscribers.get(user.userId); if (!subs || subs.size === 0) return; // ignore users nothing is watching this.cache.set(user.userId, getUserPresence(user)); subs.forEach((cb) => cb()); }; private start(mx: MatrixClient): void { if (this.started && this.mx === mx) return; // A different client (re-login without reload) — drop the old listeners. if (this.started && this.mx) { this.mx.removeListener(UserEvent.Presence, this.handleEvent); this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent); this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent); this.cache.clear(); } this.mx = mx; mx.on(UserEvent.Presence, this.handleEvent); mx.on(UserEvent.CurrentlyActive, this.handleEvent); mx.on(UserEvent.LastPresenceTs, this.handleEvent); this.started = true; } subscribe(mx: MatrixClient, userId: string, cb: PresenceListener): () => void { this.start(mx); let subs = this.subscribers.get(userId); if (!subs) { subs = new Set(); this.subscribers.set(userId, subs); } subs.add(cb); return () => { subs.delete(cb); // Only tear down the registry entry if this is still the live set for the // user — keeps unsubscribe idempotent so a double-invoke (or a later // re-subscribe that replaced the set) can't evict a newer subscriber. if (subs.size === 0 && this.subscribers.get(userId) === subs) { this.subscribers.delete(userId); this.cache.delete(userId); } }; } get(mx: MatrixClient, userId: string): UserPresence | undefined { if (this.cache.has(userId)) return this.cache.get(userId); const user = mx.getUser(userId); const presence = user ? getUserPresence(user) : undefined; // Only retain in the cache while something is actually subscribed, so a // render that never commits (and thus never subscribes) can't leave a // dangling entry. handleEvent keeps it fresh thereafter. if (this.subscribers.get(userId)?.size) this.cache.set(userId, presence); return presence; } } const presenceStore = new PresenceStore(); export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); const [presence, setPresence] = useState(() => presenceStore.get(mx, userId)); useEffect(() => { // Re-seed synchronously (userId/mx may have changed, or a late-arriving User // object now exists), then track changes via the shared store. setPresence(presenceStore.get(mx, userId)); return presenceStore.subscribe(mx, userId, () => { setPresence(presenceStore.get(mx, userId)); }); }, [mx, userId]); return presence; }; export const usePresenceLabel = (): Record => useMemo( // Keep this vocabulary aligned with the status setter (PresencePicker in // SettingsTab.tsx): online -> "Online", unavailable -> "Idle". Previously // these read "Active"/"Busy"/"Away", so a user who set "Idle" showed as // "Busy" to others. () => ({ [Presence.Online]: 'Online', [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', }), [], );