diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index a2f3f930c..223bd44e3 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -751,22 +751,32 @@ function ProfilePronouns() { const [pronouns, setPronouns] = useState(''); const [savedPronouns, setSavedPronouns] = useState(''); + // 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 = (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(''); const [savedTimezone, setSavedTimezone] = useState(''); + // 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" /> diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx index 8dd5fdaad..2a93a2ed6 100644 --- a/src/app/features/settings/account/ProfileDecoration.tsx +++ b/src/app/features/settings/account/ProfileDecoration.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, Text, Spinner, color } from 'folds'; import { Method } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -67,24 +67,47 @@ export function ProfileDecoration() { const [current, setCurrent] = useState(null); const [selected, setSelected] = useState(null); + // Distinguish "confirmed no decoration" from "failed to load": a fetch + // failure must not be shown as (and saved over) "None". + const [loadError, setLoadError] = useState(false); + const [loading, setLoading] = useState(true); + // True once the user has picked/cleared a decoration — guards against the + // mount-time fetch below clobbering a fresh selection if it resolves late + // (mirrors ProfileStatus's statusDirtyRef in Profile.tsx). + const dirtyRef = useRef(false); - useEffect(() => { + const fetchDecoration = useCallback(() => { + let cancelled = false; + setLoading(true); // Fetch the whole profile, not the `/{field}` sub-resource: an unset field // 404s (a console error for anyone without a decoration). The full profile // returns 200 with all fields incl. custom MSC4133 ones — read it out. mx.http .authedRequest>(Method.Get, `/profile/${encodeURIComponent(userId)}`) .then((res) => { + if (cancelled) return; + setLoadError(false); + setLoading(false); + if (dirtyRef.current) return; const val = (res[PROFILE_FIELD] as string | undefined) ?? null; setCurrent(val); setSelected(val); }) .catch(() => { - setCurrent(null); - setSelected(null); + if (cancelled) return; + setLoading(false); + // Do NOT touch current/selected here — a network failure is not proof + // there's no decoration, and defaulting to null risks the user saving + // "None" over a real, still-set decoration (see #46). + setLoadError(true); }); + return () => { + cancelled = true; + }; }, [mx, userId]); + useEffect(() => fetchDecoration(), [fetchDecoration]); + const [saveState, save] = useAsyncCallback( useCallback( async (slug: string | null) => { @@ -105,16 +128,27 @@ export function ProfileDecoration() { const hasChanges = selected !== current; const handleSelect = (slug: string) => { + dirtyRef.current = true; setSelected((prev) => (prev === slug ? null : slug)); }; - const handleClear = () => setSelected(null); + const handleClear = () => { + dirtyRef.current = true; + setSelected(null); + }; const handleSave = () => { - if (!hasChanges || saving) return; + // Refuse to save while the initial load failed: `current`/`selected` are + // not known-good, so saving could silently overwrite a real decoration. + if (!hasChanges || saving || loadError) return; save(selected); }; + const handleRetry = () => { + dirtyRef.current = false; + fetchDecoration(); + }; + return ( - {selected - ? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find( - (d) => d.slug === selected, - )?.name ?? selected) - : 'None'} + {loadError + ? 'Failed to load' + : selected + ? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find( + (d) => d.slug === selected, + )?.name ?? selected) + : 'None'} - {selected && ( + {selected && !loadError && ( + + )} + {saveState.status === AsyncStatus.Error && ( Failed to save. Try again.