Files
cinny/src/app/hooks/useUserPresence.ts
T
Lotus CIandClaude Opus 5.5 c247024bcd
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 48s
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m34s
fix(presence): your own status shows at once (and clears) in the member list
Reported: after changing your status it did not appear in the members
drawer until you switched rooms.

The SDK creates our own User object with `new User()` instead of
User.createUser, so its User.* events are never re-emitted on the client.
The shared presence store (PERF-1) listens on the client, so it never heard
our own presence changes; a remount (room switch) re-read the User and
showed it. The store now also refreshes on the ClientEvent.Event the SDK
emits for every sync presence event (after updating the User).

Also read the status from the latest presence event: the SDK only
overwrites presenceStatusMsg with a non-empty status_msg, so a cleared
status lingered (for everyone) until reload.

e2e: set a quick status, close settings, it shows in the drawer; clear it,
it goes. Fails without the fix (status never appears).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 06:28:36 -04:00

165 lines
6.0 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
ClientEvent,
MatrixClient,
MatrixEvent,
User,
UserEvent,
UserEventHandlerMap,
} from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
Online = 'online',
Unavailable = 'unavailable',
Offline = 'offline',
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
Busy = 'org.matrix.msc3026.busy',
}
export type UserPresence = {
presence: Presence;
status?: string;
active: boolean;
lastActiveTs?: number;
};
const getUserPresence = (user: User): UserPresence => ({
presence: user.presence as Presence,
// From the latest presence event rather than user.presenceStatusMsg: the SDK
// only overwrites that field with a non-empty status_msg, so a cleared status
// would otherwise linger until reload.
status: user.events.presence
? (user.events.presence.getContent().status_msg as string | undefined) || undefined
: 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<string, Set<PresenceListener>>();
// 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<string, UserPresence | undefined>();
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());
};
// Our own User object is created by the SDK with `new User()` (not
// User.createUser), so its User.* events are never re-emitted on the client
// and the listeners above miss our own presence changes. The client does
// emit every sync presence event as ClientEvent.Event (after updating the
// User), so refresh from that too.
private handleClientEvent = (event: MatrixEvent): void => {
if (event.getType() !== 'm.presence' || !this.mx) return;
const userId = event.getSender();
if (!userId) return;
const user = this.mx.getUser(userId);
if (user) this.handleEvent(event, user);
};
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.mx.removeListener(ClientEvent.Event, this.handleClientEvent);
this.cache.clear();
}
this.mx = mx;
mx.on(UserEvent.Presence, this.handleEvent);
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
mx.on(ClientEvent.Event, this.handleClientEvent);
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<Presence, string> =>
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',
[Presence.Busy]: 'Do Not Disturb',
}),
[],
);