fix: persist status message and timezone across reconnects
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:
@@ -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;
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user