From b41bfd35c0540b2d35c0cfb05c772de22a815a50 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 10 Jun 2026 21:31:58 -0400 Subject: [PATCH] fix: persist status message and timezone across reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status message: Synapse clears status_msg when a user goes offline/reconnects. Fix by caching to localStorage and re-sending on setOnline(). The sync effect no longer overwrites the local value with an empty server event. Timezone: PUT /profile/{userId}/m.tz is MSC1769 (unstable) and not supported by standard Synapse — save/load silently fails. Fix by using Matrix account data (im.lotus.timezone) instead, which is fully supported. Own profile falls back to account data; other users still try the m.tz profile endpoint (for federated servers that support it). Co-Authored-By: Claude Sonnet 4.6 --- src/app/features/settings/account/Profile.tsx | 52 +++++++++++++------ src/app/hooks/useExtendedProfile.ts | 29 +++++++++-- src/app/hooks/usePresenceUpdater.ts | 19 +++++-- 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 8da7ae323..1b4bf4a99 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -317,6 +317,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { } const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; +const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; const CLEAR_AFTER_OPTIONS = [ { label: 'Never', value: '0' }, @@ -344,7 +345,9 @@ function ProfileStatus() { const userId = mx.getUserId()!; const presence = useUserPresence(userId); - const [statusMsg, setStatusMsg] = useState(presence?.status ?? ''); + const [statusMsg, setStatusMsg] = useState( + presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '', + ); const [clearAfter, setClearAfter] = useState('0'); const [emojiAnchor, setEmojiAnchor] = useState(); @@ -354,16 +357,22 @@ function ProfileStatus() { return stored ? parseInt(stored, 10) : 0; }); - // Sync input when another device changes the status + // Sync input when another device changes the status. + // Only update if the server actually has a value — ignore empty sync events + // caused by Synapse clearing status_msg on reconnect. useEffect(() => { - setStatusMsg(presence?.status ?? ''); - }, [presence?.status]); + if (presence?.status) { + setStatusMsg(presence.status); + localStorage.setItem(STATUS_MSG_KEY(userId), presence.status); + } + }, [presence?.status, userId]); // Drive the auto-clear timer off expiryTs so re-saving cancels the old timer useEffect(() => { if (!expiryTs) return undefined; const remaining = expiryTs - Date.now(); const clearStatus = () => { + localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); setExpiryTs(0); mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); @@ -403,6 +412,12 @@ function ProfileStatus() { const msg = statusMsg.trim(); saveStatus(msg).catch(() => undefined); + if (msg) { + localStorage.setItem(STATUS_MSG_KEY(userId), msg); + } else { + localStorage.removeItem(STATUS_MSG_KEY(userId)); + } + const delayMs = getMsFromOption(clearAfter); if (msg && delayMs > 0) { const ts = Date.now() + delayMs; @@ -416,6 +431,7 @@ function ProfileStatus() { const handleClear = () => { setStatusMsg(''); + localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); setExpiryTs(0); mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); @@ -722,30 +738,34 @@ function ProfileTimezone() { const [savedTimezone, setSavedTimezone] = useState(''); useEffect(() => { + const cached = mx.getAccountData('im.lotus.timezone' as any)?.getContent<{ timezone: string }>(); + if (cached?.timezone) { + setTimezone(cached.timezone); + setSavedTimezone(cached.timezone); + } + // Also fetch from server in case account data hasn't synced yet mx.http - .authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`) + .authedRequest<{ timezone: string }>( + Method.Get, + `/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`, + ) .then((res) => { - const val = res['m.tz'] ?? ''; + const val = res.timezone ?? ''; setTimezone(val); setSavedTimezone(val); }) .catch(() => { - setTimezone(''); - setSavedTimezone(''); + /* no stored timezone yet */ }); }, [mx, userId]); const [saveState, saveTimezone] = useAsyncCallback( useCallback( (value: string) => - mx.http - .authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, { - 'm.tz': value, - }) - .then(() => { - setSavedTimezone(value); - }), - [mx, userId], + (mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => { + setSavedTimezone(value); + }), + [mx], ), ); const saving = saveState.status === AsyncStatus.Loading; diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts index 4f0325abe..8eae8d1f7 100644 --- a/src/app/hooks/useExtendedProfile.ts +++ b/src/app/hooks/useExtendedProfile.ts @@ -13,6 +13,7 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => { useEffect(() => { let cancelled = false; + const myUserId = mx.getUserId(); const fetchField = async >( field: string, @@ -28,14 +29,32 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => { } }; - Promise.all([ - fetchField<{ 'm.pronouns': string }>('m.pronouns'), - fetchField<{ 'm.tz': string }>('m.tz'), - ]).then(([pronouns, timezone]) => { + const run = async () => { + const [pronouns, tzFromProfile] = await Promise.all([ + fetchField<{ 'm.pronouns': string }>('m.pronouns'), + fetchField<{ 'm.tz': string }>('m.tz'), + ]); + + let timezone = tzFromProfile; + // Standard Synapse doesn't support m.tz — fall back to account data for own profile + if (!timezone && userId === myUserId) { + try { + const res = await mx.http.authedRequest<{ timezone: string }>( + Method.Get, + `/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`, + ); + timezone = res.timezone || undefined; + } catch { + // not set yet + } + } + if (!cancelled) { setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined }); } - }); + }; + + run(); return () => { cancelled = true; diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index 484649897..b2f43bfe7 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -16,10 +16,23 @@ export function usePresenceUpdater() { const lastActivityRef = useRef(0); useEffect(() => { + const userId = mx.getUserId(); + const storedStatus = userId ? localStorage.getItem(`lotus-status-msg-${userId}`) ?? '' : ''; + const setOnline = () => - mx.setPresence({ presence: 'online' }).catch(() => undefined); + mx.setPresence({ + presence: 'online', + ...(storedStatus ? { status_msg: storedStatus } : {}), + }).catch(() => undefined); const setUnavailable = (statusMsg?: string) => - mx.setPresence({ presence: 'unavailable', ...(statusMsg ? { status_msg: statusMsg } : {}) }).catch(() => undefined); + mx.setPresence({ + presence: 'unavailable', + ...(statusMsg + ? { status_msg: statusMsg } + : storedStatus + ? { status_msg: storedStatus } + : {}), + }).catch(() => undefined); const setOffline = () => mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined); @@ -44,7 +57,7 @@ export function usePresenceUpdater() { // presenceStatus === 'auto' — original activity-tracking behavior. const startIdleTimer = () => { clearTimeout(idleTimerRef.current); - idleTimerRef.current = window.setTimeout(() => { + idleTimerRef.current = setTimeout(() => { isIdleRef.current = true; setUnavailable(); }, IDLE_TIMEOUT_MS);