fix(profile): don't clobber edits with a slow fetch; show decoration load errors

Pronouns, timezone and avatar decoration applied the mount-time fetch
result unconditionally, overwriting a value the user had already edited;
the decoration panel also showed "None" on any fetch failure and let the
user save over a real decoration. Add cancelled/dirty guards (mirroring
ProfileStatus) and an explicit load-error state with Retry.

Fixes #46
Fixes #47

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:06 -04:00
co-authored by Claude Opus 5
parent 32e5fec6f3
commit 6b9c8de393
2 changed files with 98 additions and 15 deletions
+29 -2
View File
@@ -751,22 +751,32 @@ function ProfilePronouns() {
const [pronouns, setPronouns] = useState<string>('');
const [savedPronouns, setSavedPronouns] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const pronounsDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
mx.http
.authedRequest<{ 'm.pronouns': string }>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
)
.then((res) => {
if (cancelled || pronounsDirtyRef.current) return;
const val = res['m.pronouns'] ?? '';
setPronouns(val);
setSavedPronouns(val);
})
.catch(() => {
if (cancelled || pronounsDirtyRef.current) return;
setPronouns('');
setSavedPronouns('');
});
return () => {
cancelled = true;
};
}, [mx, userId]);
const [saveState, savePronouns] = useAsyncCallback(
@@ -788,10 +798,12 @@ function ProfilePronouns() {
const saving = saveState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
pronounsDirtyRef.current = true;
setPronouns(evt.currentTarget.value);
};
const handleReset = () => {
pronounsDirtyRef.current = true;
setPronouns(savedPronouns);
};
@@ -875,10 +887,15 @@ function ProfileTimezone() {
const [timezone, setTimezone] = useState<string>('');
const [savedTimezone, setSavedTimezone] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const timezoneDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone) {
if (cached?.timezone && !timezoneDirtyRef.current) {
setTimezone(cached.timezone);
setSavedTimezone(cached.timezone);
}
@@ -889,6 +906,7 @@ function ProfileTimezone() {
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
)
.then((res) => {
if (cancelled || timezoneDirtyRef.current) return;
const val = res.timezone ?? '';
setTimezone(val);
setSavedTimezone(val);
@@ -896,6 +914,9 @@ function ProfileTimezone() {
.catch(() => {
/* no stored timezone yet */
});
return () => {
cancelled = true;
};
}, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback(
@@ -921,7 +942,13 @@ function ProfileTimezone() {
);
const saving = saveState.status === AsyncStatus.Loading;
const handleChange = (value: string) => {
timezoneDirtyRef.current = true;
setTimezone(value);
};
const handleReset = () => {
timezoneDirtyRef.current = true;
setTimezone(savedTimezone);
};
@@ -955,7 +982,7 @@ function ProfileTimezone() {
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
onChange={handleChange}
disabled={saving}
aria-label="Timezone"
/>