fix(status): auto-clear survives a rate-limited presence write (#227)

The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.

The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.

Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-23 15:31:57 -04:00
co-authored by Claude Opus 5
parent fdec3ed7f2
commit e393c45f50
+23 -5
View File
@@ -60,6 +60,7 @@ import {
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
@@ -389,21 +390,38 @@ function StatusExpiryMonitor() {
const expiryKey = STATUS_EXPIRY_KEY(userId);
const msgKey = STATUS_MSG_KEY(userId);
// [Gitea #227] The clear must survive a failed write. Synapse rate-limits
// presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
// budget, so a bare setPresence here 429s often — and because the keys used
// to be removed BEFORE the request, a single failure dropped the auto-clear
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
mx.setPresence({
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
}).catch(() => undefined);
})
.then(() => {
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
check();
const interval = setInterval(check, 30_000);
// Poll often enough that a rate-limited retry lands promptly, but not so
// often that a persistent failure hammers the server.
const interval = setInterval(check, 15_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};