Files
cinny/src/app/utils/presenceWrite.ts
T
Lotus CIandClaude Opus 5.5 00f962a463
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
fix(presence): Do Not Disturb is real "busy" presence and keeps your status (#283)
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
2026-10-19 01:04:27 -04:00

97 lines
3.4 KiB
TypeScript

import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk';
import { BUSY_PRESENCE } from './busyPresence';
/**
* Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per
* user by default (`rc_presence.per_user`: 0.1/s, burst 1) — and the limit is
* shared across all of a user's devices. Our presence heartbeat (online/away on
* visibility and activity changes) can therefore make a manual status save 429.
*
* `setPresenceWithRetry` waits out `Retry-After` (bounded) instead of failing,
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
* anything, so it stops spending the budget in the first place.
*/
export type PresenceWrite = {
// [Gitea #283] or MSC3026 busy (Do Not Disturb)
presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE;
status_msg?: string;
};
/**
* `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three
* spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight
* to the same endpoint.
*/
export const writePresence = async (
mx: Pick<MatrixClient, 'setPresence'> & Partial<Pick<MatrixClient, 'getSafeUserId' | 'http'>>,
write: PresenceWrite,
): Promise<void> => {
if (write.presence !== BUSY_PRESENCE) {
await mx.setPresence(write as IPresenceOpts);
return;
}
if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient');
const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`;
await mx.http.authedRequest(Method.Put, path, undefined, write);
};
const sleep = (ms: number) =>
new Promise<void>((r) => {
setTimeout(r, ms);
});
export async function setPresenceWithRetry(
mx: Pick<MatrixClient, 'setPresence'>,
write: PresenceWrite,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<void> {
const maxWaitMs = opts.maxWaitMs ?? 25_000;
const sleepFn = opts.sleepFn ?? sleep;
let waited = 0;
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
await writePresence(mx, write);
return;
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
const wait = Math.min(e.getRetryAfterMs() ?? 10_500, 15_000);
if (waited + wait > maxWaitMs) throw e;
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
}
}
}
const presenceKey = (write: PresenceWrite): string => `${write.presence}|${write.status_msg ?? ''}`;
/** Remembers the last write that succeeded so identical heartbeats are skipped. */
export class PresenceDeduper {
private last: string | null = null;
shouldSend(write: PresenceWrite): boolean {
return presenceKey(write) !== this.last;
}
sent(write: PresenceWrite): void {
this.last = presenceKey(write);
}
reset(): void {
this.last = null;
}
}
/** A user-facing sentence for a failed status save. */
export const describePresenceError = (e: unknown): string => {
if (e instanceof MatrixError) {
if (e.httpStatus === 429) {
return 'The server limits how often presence can change (once every 10 seconds, shared by all your devices). Please wait a moment and try again.';
}
const serverText = (e.data as { error?: string } | undefined)?.error;
if (serverText) return `Failed to save status: ${serverText}`;
}
return "Failed to save status — couldn't reach the server. Try again.";
};