From e393c45f5050a1846ab714c6f1d535e755fde057 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 23 Sep 2026 15:31:57 -0400 Subject: [PATCH] fix(status): auto-clear survives a rate-limited presence write (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/pages/client/ClientNonUIFeatures.tsx | 28 ++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 7d3e64ca1..ff1672824 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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(); };