fix: persist status message and timezone across reconnects
CI / Build & Quality Checks (push) Successful in 10m36s
Trigger Desktop Build / trigger (push) Successful in 12s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:31:58 -04:00
parent 6a83e67f95
commit b41bfd35c0
3 changed files with 76 additions and 24 deletions
+36 -16
View File
@@ -317,6 +317,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
} }
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [ const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' }, { label: 'Never', value: '0' },
@@ -344,7 +345,9 @@ function ProfileStatus() {
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? ''); const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
);
const [clearAfter, setClearAfter] = useState('0'); const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>(); const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
@@ -354,16 +357,22 @@ function ProfileStatus() {
return stored ? parseInt(stored, 10) : 0; 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(() => { useEffect(() => {
setStatusMsg(presence?.status ?? ''); if (presence?.status) {
}, [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 // Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
useEffect(() => { useEffect(() => {
if (!expiryTs) return undefined; if (!expiryTs) return undefined;
const remaining = expiryTs - Date.now(); const remaining = expiryTs - Date.now();
const clearStatus = () => { const clearStatus = () => {
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0); setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
@@ -403,6 +412,12 @@ function ProfileStatus() {
const msg = statusMsg.trim(); const msg = statusMsg.trim();
saveStatus(msg).catch(() => undefined); saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfter); const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) { if (msg && delayMs > 0) {
const ts = Date.now() + delayMs; const ts = Date.now() + delayMs;
@@ -416,6 +431,7 @@ function ProfileStatus() {
const handleClear = () => { const handleClear = () => {
setStatusMsg(''); setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0); setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
@@ -722,30 +738,34 @@ function ProfileTimezone() {
const [savedTimezone, setSavedTimezone] = useState<string>(''); const [savedTimezone, setSavedTimezone] = useState<string>('');
useEffect(() => { 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 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) => { .then((res) => {
const val = res['m.tz'] ?? ''; const val = res.timezone ?? '';
setTimezone(val); setTimezone(val);
setSavedTimezone(val); setSavedTimezone(val);
}) })
.catch(() => { .catch(() => {
setTimezone(''); /* no stored timezone yet */
setSavedTimezone('');
}); });
}, [mx, userId]); }, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback( const [saveState, saveTimezone] = useAsyncCallback(
useCallback( useCallback(
(value: string) => (value: string) =>
mx.http (mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, { setSavedTimezone(value);
'm.tz': value, }),
}) [mx],
.then(() => {
setSavedTimezone(value);
}),
[mx, userId],
), ),
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
+24 -5
View File
@@ -13,6 +13,7 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const myUserId = mx.getUserId();
const fetchField = async <T extends Record<string, string>>( const fetchField = async <T extends Record<string, string>>(
field: string, field: string,
@@ -28,14 +29,32 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
} }
}; };
Promise.all([ const run = async () => {
fetchField<{ 'm.pronouns': string }>('m.pronouns'), const [pronouns, tzFromProfile] = await Promise.all([
fetchField<{ 'm.tz': string }>('m.tz'), fetchField<{ 'm.pronouns': string }>('m.pronouns'),
]).then(([pronouns, timezone]) => { 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) { if (!cancelled) {
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined }); setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
} }
}); };
run();
return () => { return () => {
cancelled = true; cancelled = true;
+16 -3
View File
@@ -16,10 +16,23 @@ export function usePresenceUpdater() {
const lastActivityRef = useRef(0); const lastActivityRef = useRef(0);
useEffect(() => { useEffect(() => {
const userId = mx.getUserId();
const storedStatus = userId ? localStorage.getItem(`lotus-status-msg-${userId}`) ?? '' : '';
const setOnline = () => const setOnline = () =>
mx.setPresence({ presence: 'online' }).catch(() => undefined); mx.setPresence({
presence: 'online',
...(storedStatus ? { status_msg: storedStatus } : {}),
}).catch(() => undefined);
const setUnavailable = (statusMsg?: string) => 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 = () => const setOffline = () =>
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined); mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
@@ -44,7 +57,7 @@ export function usePresenceUpdater() {
// presenceStatus === 'auto' — original activity-tracking behavior. // presenceStatus === 'auto' — original activity-tracking behavior.
const startIdleTimer = () => { const startIdleTimer = () => {
clearTimeout(idleTimerRef.current); clearTimeout(idleTimerRef.current);
idleTimerRef.current = window.setTimeout(() => { idleTimerRef.current = setTimeout(() => {
isIdleRef.current = true; isIdleRef.current = true;
setUnavailable(); setUnavailable();
}, IDLE_TIMEOUT_MS); }, IDLE_TIMEOUT_MS);