From f62c5d5778dcb152a8e5a38db6db35f1e7e03fa0 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 24 Sep 2026 11:50:13 -0400 Subject: [PATCH] fix(status): a status cleared on another device stays cleared (#187 DP3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status messages are saved per device and re-sent with every presence heartbeat (a presence write without status_msg clears it on Synapse). A device never receives its own user's presence changes made on other devices, so the DP3 fix in db864326 — mirroring remote changes from the Profile page — could never fire: device B kept a status that device A had cleared and re-published it on its next state change. Heartbeats now reconcile with the server first: GET our own presence, send the server's current status_msg and bring the local copy in line. Falls back to the local copy when the read fails, when the server shows us offline (invisible mode clears the status by design), and for 15 s after this device saved/cleared its own status (a server read that hasn't caught up yet can't override a fresh save). Verified with two sessions of the same user against local Synapse: B sets "dp3 old status" → A clears it → B goes hidden→visible → server stays "" and B's local copy is removed (before: back to "dp3 old status"). A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/settings/account/Profile.tsx | 3 ++ src/app/hooks/usePresenceUpdater.ts | 33 ++++++++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/utils/ownStatusSync.test.ts | 50 +++++++++++++++++++ src/app/utils/ownStatusSync.ts | 43 ++++++++++++++++ 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/ownStatusSync.test.ts create mode 100644 src/app/utils/ownStatusSync.ts diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index b134f4510..0243f2629 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -57,6 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; +import { noteLocalStatusWrite } from '../../../utils/ownStatusSync'; import { ProfileDecoration } from './ProfileDecoration'; import { EmojiBoard } from '../../../components/emoji-board'; import { useStatusPresets } from '../../../hooks/useStatusPresets'; @@ -472,6 +473,7 @@ function ProfileStatus() { const msg = rawMsg.trim(); // Guard against a stale presence echo reverting this value (see the sync effect). pendingAppliedRef.current = { value: msg, ts: Date.now() }; + noteLocalStatusWrite(); saveStatus(msg).catch(() => undefined); if (msg) { @@ -525,6 +527,7 @@ function ProfileStatus() { const handleClear = () => { statusDirtyRef.current = false; pendingAppliedRef.current = { value: '', ts: Date.now() }; + noteLocalStatusWrite(); setStatusMsg(''); localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index becb5865d..da28812f1 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite'; import { useMatrixClient } from './useMatrixClient'; +import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; @@ -65,12 +66,36 @@ export function usePresenceUpdater() { .then(() => deduper.sent(write)) .catch((err) => warnPresenceFailure(label, err)); }; - const setOnline = () => { - const status = readStatus(); + // #187 DP3: a device never sees its own presence changes made on other + // devices, so its saved status could be one another device already + // cleared. Ask the server what the status is now before re-sending it, and + // bring the local copy in line (falls back to the local copy on failure). + const resolveStatus = async (): Promise => { + const local = readStatus(); + if (!userId) return local; + const server = await mx.getPresence(userId).catch(() => undefined); + const status = pickHeartbeatStatus({ + local, + server, + lastLocalWriteAt: getLastLocalStatusWrite(), + now: Date.now(), + }); + if (status !== local) { + if (status) localStorage.setItem(`lotus-status-msg-${userId}`, status); + else { + localStorage.removeItem(`lotus-status-msg-${userId}`); + localStorage.removeItem(`lotus-status-expiry-${userId}`); + } + } + return status; + }; + + const setOnline = async () => { + const status = await resolveStatus(); return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online'); }; - const setUnavailable = (statusMsg?: string) => { - const status = readStatus(); + const setUnavailable = async (statusMsg?: string) => { + const status = await resolveStatus(); return send( { presence: 'unavailable', diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 1bb404c37..a6675e420 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -61,6 +61,7 @@ import { unmuteRoom, } from '../../features/room-nav/muteTimers'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; +import { noteLocalStatusWrite } from '../../utils/ownStatusSync'; import { setPresenceWithRetry } from '../../utils/presenceWrite'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { dismissToastAtom, toastQueueAtom } from '../../state/toast'; @@ -413,6 +414,7 @@ function StatusExpiryMonitor() { status_msg: '', }) .then(() => { + noteLocalStatusWrite(); localStorage.removeItem(msgKey); localStorage.removeItem(expiryKey); }) diff --git a/src/app/utils/ownStatusSync.test.ts b/src/app/utils/ownStatusSync.test.ts new file mode 100644 index 000000000..6c256a2b6 --- /dev/null +++ b/src/app/utils/ownStatusSync.test.ts @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LOCAL_WRITE_GRACE_MS, pickHeartbeatStatus } from './ownStatusSync'; + +const now = 1_000_000; +const old = { lastLocalWriteAt: 0, now }; + +test('a status cleared on another device is not re-published (DP3)', () => { + assert.equal( + pickHeartbeatStatus({ ...old, local: 'stale', server: { presence: 'online', status_msg: '' } }), + '', + ); + assert.equal(pickHeartbeatStatus({ ...old, local: 'stale', server: { presence: 'online' } }), ''); +}); + +test('a status set on another device is kept', () => { + assert.equal( + pickHeartbeatStatus({ + ...old, + local: 'stale', + server: { presence: 'unavailable', status_msg: 'new' }, + }), + 'new', + ); +}); + +test('falls back to the local copy when the read failed or we are invisible', () => { + assert.equal(pickHeartbeatStatus({ ...old, local: 'mine', server: undefined }), 'mine'); + assert.equal( + pickHeartbeatStatus({ ...old, local: 'mine', server: { presence: 'offline', status_msg: '' } }), + 'mine', + ); +}); + +test("a fresh local save wins over a server read that hasn't caught up", () => { + const server = { presence: 'online', status_msg: 'previous' }; + assert.equal( + pickHeartbeatStatus({ local: 'just saved', server, lastLocalWriteAt: now - 2000, now }), + 'just saved', + ); + assert.equal( + pickHeartbeatStatus({ + local: 'just saved', + server, + lastLocalWriteAt: now - LOCAL_WRITE_GRACE_MS - 1, + now, + }), + 'previous', + ); +}); diff --git a/src/app/utils/ownStatusSync.ts b/src/app/utils/ownStatusSync.ts new file mode 100644 index 000000000..7d80c5502 --- /dev/null +++ b/src/app/utils/ownStatusSync.ts @@ -0,0 +1,43 @@ +/** + * Status messages are saved per device (`lotus-status-msg-`) and + * re-sent with every presence heartbeat, because a presence write that omits + * `status_msg` clears it on Synapse. #187 DP3: a device never receives its own + * user's presence changes made on other devices, so a status cleared elsewhere + * stayed in its local copy and was re-published on the next heartbeat. The + * heartbeat now reconciles with the server first (see usePresenceUpdater); this + * module only tracks this device's own writes so that a fresh local save isn't + * overridden by a server read that hasn't caught up yet. + */ + +/** Trust this device's own copy for this long after it saved or cleared it. */ +export const LOCAL_WRITE_GRACE_MS = 15_000; + +let lastLocalWriteAt = 0; + +/** Call whenever this device saves or clears its own status. */ +export const noteLocalStatusWrite = (now: number = Date.now()): void => { + lastLocalWriteAt = now; +}; + +export const getLastLocalStatusWrite = (): number => lastLocalWriteAt; + +/** + * Which status a heartbeat should send, given what the server says now. + * `server` is undefined when the read failed. An offline server presence + * (invisible mode) carries an empty status by design, so it isn't a clear. + */ +export const pickHeartbeatStatus = ({ + local, + server, + lastLocalWriteAt: writtenAt, + now, +}: { + local: string; + server: { presence: string; status_msg?: string } | undefined; + lastLocalWriteAt: number; + now: number; +}): string => { + if (!server || server.presence === 'offline') return local; + if (now - writtenAt < LOCAL_WRITE_GRACE_MS) return local; + return server.status_msg ?? ''; +};