CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m59s
DND was sent as presence unavailable with the literal status text "dnd": it replaced your status, and other clients showed "dnd". Where the server supports MSC3026 (busy_presence), DND now sends presence org.matrix.msc3026.busy and leaves the status alone (Synapse keeps busy across /sync set_presence=online). Busy renders as red "Do Not Disturb" with the real status line; the legacy unavailable + "dnd" encoding is still recognised and still used on servers without it. Status saves / clears no longer downgrade busy. The SDK rejects non-spec presences, so busy goes through a direct authed PUT. Fixes #283 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
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',
|
|
// [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,
|
|
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<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());
|
|
};
|
|
|
|
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<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',
|
|
}),
|
|
[],
|
|
);
|