diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index 223bd44e3..9fc2e463d 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -38,6 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
+import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -424,7 +425,10 @@ function ProfileStatus() {
const [saveState, saveStatus] = useAsyncCallback(
useCallback(
(msg: string) =>
- mx.setPresence({
+ // Synapse allows ONE presence write per 10 s per user (shared by all
+ // devices) and our heartbeat spends that budget too — wait out a 429
+ // instead of failing the user's save.
+ setPresenceWithRetry(mx, {
// Derive presence from the user's chosen setting so writing a status
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
presence: presenceStateFromSetting(presenceStatus, hidePresence),
@@ -690,7 +694,7 @@ function ProfileStatus() {
{saveState.status === AsyncStatus.Error && (
- Failed to save status — server may be rate limiting. Try again.
+ {describePresenceError(saveState.error)}
)}
diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts
index b9513fcbe..becb5865d 100644
--- a/src/app/hooks/usePresenceUpdater.ts
+++ b/src/app/hooks/usePresenceUpdater.ts
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
+import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -33,9 +34,13 @@ export function usePresenceUpdater() {
const idleTimerRef = useRef | undefined>(undefined);
const isIdleRef = useRef(false);
const lastActivityRef = useRef(0);
+ const deduperRef = useRef(new PresenceDeduper());
useEffect(() => {
const userId = mx.getUserId();
+ const deduper = deduperRef.current;
+ // A manual status save changes what the next heartbeat should carry.
+ deduper.reset();
// Read status from localStorage at call time so manual updates from the
// Profile settings are never overwritten by a stale closure value.
@@ -49,28 +54,32 @@ export function usePresenceUpdater() {
console.warn(`Failed to set presence to "${presence}":`, reason);
};
+ // Synapse allows ONE presence write per 10 s per user (rc_presence, shared
+ // by all devices) and /sync already keeps us online — only send when the
+ // state or status actually changes, so a manual status save isn't 429'd
+ // by a heartbeat that changed nothing.
+ const send = (write: PresenceWrite, label: string) => {
+ if (!deduper.shouldSend(write)) return Promise.resolve();
+ return mx
+ .setPresence(write)
+ .then(() => deduper.sent(write))
+ .catch((err) => warnPresenceFailure(label, err));
+ };
const setOnline = () => {
const status = readStatus();
- return mx
- .setPresence({
- presence: 'online',
- ...(status ? { status_msg: status } : {}),
- })
- .catch((err) => warnPresenceFailure('online', err));
+ return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
};
const setUnavailable = (statusMsg?: string) => {
const status = readStatus();
- return mx
- .setPresence({
+ return send(
+ {
presence: 'unavailable',
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
- })
- .catch((err) => warnPresenceFailure('unavailable', err));
+ },
+ 'unavailable',
+ );
};
- const setOffline = () =>
- mx
- .setPresence({ presence: 'offline', status_msg: '' })
- .catch((err) => warnPresenceFailure('offline', err));
+ const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
// Manual presence overrides — no activity tracking needed.
if (hidePresence || presenceStatus === 'invisible') {
diff --git a/src/app/utils/presenceWrite.test.ts b/src/app/utils/presenceWrite.test.ts
new file mode 100644
index 000000000..2a7333ffc
--- /dev/null
+++ b/src/app/utils/presenceWrite.test.ts
@@ -0,0 +1,81 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { MatrixError } from 'matrix-js-sdk';
+import { PresenceDeduper, describePresenceError, setPresenceWithRetry } from './presenceWrite';
+
+const limited = (retryMs = 3000) =>
+ new MatrixError(
+ { errcode: 'M_LIMIT_EXCEEDED', error: 'Too Many Requests', retry_after_ms: retryMs },
+ 429,
+ );
+
+test('429 is retried after Retry-After, then succeeds', async () => {
+ let n = 0;
+ const waits: number[] = [];
+ const mx = {
+ setPresence: async () => {
+ n += 1;
+ if (n < 3) throw limited(3000);
+ return {};
+ },
+ };
+ await setPresenceWithRetry(
+ mx,
+ { presence: 'online', status_msg: 'hi' },
+ {
+ sleepFn: async (ms) => {
+ waits.push(ms);
+ },
+ },
+ );
+ assert.equal(n, 3);
+ assert.deepEqual(waits, [3000, 3000]);
+});
+
+test('gives up once the total wait would exceed the cap; other errors are not retried', async () => {
+ const mx = {
+ setPresence: async () => {
+ throw limited(20_000);
+ },
+ };
+ await assert.rejects(
+ () =>
+ setPresenceWithRetry(
+ mx,
+ { presence: 'online' },
+ { maxWaitMs: 25_000, sleepFn: async () => undefined },
+ ),
+ /Too Many/,
+ );
+ let calls = 0;
+ const forbidden = {
+ setPresence: async () => {
+ calls += 1;
+ throw new MatrixError({ errcode: 'M_FORBIDDEN', error: 'nope' }, 403);
+ },
+ };
+ await assert.rejects(() => setPresenceWithRetry(forbidden, { presence: 'online' }));
+ assert.equal(calls, 1);
+});
+
+test('deduper skips identical writes and notices changes', () => {
+ const d = new PresenceDeduper();
+ assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), true);
+ d.sent({ presence: 'online', status_msg: 'a' });
+ assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), false);
+ assert.equal(d.shouldSend({ presence: 'unavailable', status_msg: 'a' }), true);
+ assert.equal(d.shouldSend({ presence: 'online', status_msg: 'b' }), true);
+ d.reset();
+ assert.equal(d.shouldSend({ presence: 'online', status_msg: 'a' }), true);
+});
+
+test('error wording', () => {
+ assert.match(describePresenceError(limited()), /once every 10 seconds/);
+ assert.equal(
+ describePresenceError(
+ new MatrixError({ errcode: 'M_FORBIDDEN', error: 'Presence is disabled' }, 403),
+ ),
+ 'Failed to save status: Presence is disabled',
+ );
+ assert.match(describePresenceError(new TypeError('Failed to fetch')), /couldn't reach/);
+});
diff --git a/src/app/utils/presenceWrite.ts b/src/app/utils/presenceWrite.ts
new file mode 100644
index 000000000..209745380
--- /dev/null
+++ b/src/app/utils/presenceWrite.ts
@@ -0,0 +1,73 @@
+import { MatrixClient, MatrixError } from 'matrix-js-sdk';
+
+/**
+ * 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 = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string };
+
+const sleep = (ms: number) =>
+ new Promise((r) => {
+ setTimeout(r, ms);
+ });
+
+export async function setPresenceWithRetry(
+ mx: Pick,
+ write: PresenceWrite,
+ opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise } = {},
+): Promise {
+ 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 mx.setPresence(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.";
+};