fix(status): a status cleared on another device stays cleared (#187 DP3)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 11:50:13 -04:00
co-authored by Claude Opus 5.5
parent cccd78fd43
commit f62c5d5778
5 changed files with 127 additions and 4 deletions
@@ -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));
+29 -4
View File
@@ -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<string> => {
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',
@@ -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);
})
+50
View File
@@ -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',
);
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Status messages are saved per device (`lotus-status-msg-<user>`) 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 ?? '';
};