fix(status): status changes apply at once instead of waiting out the rate limit
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m55s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 33m26s
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m55s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 33m26s
Synapse allows one presence write per 10 s per user (shared by every device). The heartbeat wrote presence at startup and on every tab switch, so a status save right after usually got a 429 and waited 7-25 s, with the whole status section (quick statuses, input, Clear) locked and the Save spinner going. - One shared presence queue (heartbeat + status save), newest write wins: changes made while a write waits out a 429 replace it, so only the latest is sent, as soon as the server allows. - The status UI no longer waits on the server: the status applies locally, Save switches off (it compared with the server echo, so it stayed lit and invited a second, guaranteed-429 write), the controls stay usable, and a short note shows until the write lands. - The heartbeat skips writes the server already reflects (e.g. "online" at startup), so it stops spending the budget for nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
222927b558
commit
8cdf192b56
@@ -38,11 +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,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -419,6 +415,10 @@ function ProfileStatus() {
|
||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||
// dropped echo can't block real cross-device updates forever).
|
||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||
// The status as this device last applied or synced it. "Save" compares with
|
||||
// this, not with the server echo, so it switches off the moment a status is
|
||||
// applied instead of staying lit until the (rate-limited) write lands.
|
||||
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||
@@ -442,7 +442,7 @@ function ProfileStatus() {
|
||||
if (pending) {
|
||||
if (remoteStatus === pending.value) {
|
||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||
} else if (Date.now() - pending.ts < 15_000) {
|
||||
} else if (Date.now() - pending.ts < 30_000) {
|
||||
return; // stale echo of the previous status; ignore within the window
|
||||
} else {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
@@ -451,6 +451,7 @@ function ProfileStatus() {
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
setSavedMsg(remoteStatus);
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
@@ -467,9 +468,10 @@ function ProfileStatus() {
|
||||
useCallback(
|
||||
(msg: string) =>
|
||||
// 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, {
|
||||
// devices) and our heartbeat spends that budget too. The shared queue
|
||||
// waits out a 429 and folds quick successive changes into one write;
|
||||
// the UI doesn't wait on it (the status is applied locally at once).
|
||||
queuePresenceWrite(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),
|
||||
@@ -498,6 +500,7 @@ function ProfileStatus() {
|
||||
(rawMsg: string, clearAfterValue: string) => {
|
||||
statusDirtyRef.current = false;
|
||||
const msg = rawMsg.trim();
|
||||
setSavedMsg(msg);
|
||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
@@ -527,19 +530,17 @@ function ProfileStatus() {
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
applyStatus(statusMsg, clearAfter);
|
||||
};
|
||||
|
||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||
const applyPreset = useCallback(
|
||||
(preset: StatusPreset) => {
|
||||
if (saving) return;
|
||||
setStatusMsg(preset.label);
|
||||
setClearAfter(preset.clearAfter);
|
||||
applyStatus(preset.label, preset.clearAfter);
|
||||
},
|
||||
[saving, applyStatus],
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const handleSaveCurrent = useCallback(() => {
|
||||
@@ -560,17 +561,18 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
setStatusMsg('');
|
||||
setSavedMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
writePresence(mx, {
|
||||
queuePresenceWrite(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||
const hasChanges = statusMsg.trim() !== savedMsg;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
@@ -599,7 +601,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -625,7 +626,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -651,7 +651,7 @@ function ProfileStatus() {
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
outlined
|
||||
disabled={saving || !statusMsg.trim()}
|
||||
disabled={!statusMsg.trim()}
|
||||
onClick={handleSaveCurrent}
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
@@ -662,7 +662,7 @@ function ProfileStatus() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
name="statusMsgInput"
|
||||
@@ -672,7 +672,6 @@ function ProfileStatus() {
|
||||
placeholder="What's on your mind?"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={saving}
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text
|
||||
@@ -733,13 +732,17 @@ function ProfileStatus() {
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
disabled={!hasChanges}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saving && (
|
||||
<Text size="T200" priority="300" role="status">
|
||||
Status set. Sharing it with others, this can take a few seconds.
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{describePresenceError(saveState.error)}
|
||||
@@ -764,7 +767,6 @@ function ProfileStatus() {
|
||||
radii="300"
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">Clear Status</Text>
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user