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 ?? ''; +};