From d9c797f3f544e8cf413db971dbffc45d55e01e25 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 12 Oct 2026 12:39:38 -0400 Subject: [PATCH] fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lotus encodes DND as status_msg "dnd". After switching back to Auto, the heartbeat read the server's status, adopted "dnd" as the user's own and kept re-sending it, so the custom status was lost and other clients kept showing "dnd"; the Settings status field showed "dnd" too. pickHeartbeatStatus (unit-tested) never adopts the "dnd" sentinel and falls back to the device's real status; Profile neither mirrors it into the local status nor shows it in the field. Verified on the local Synapse: "🎮 Gaming" → DND (unavailable/dnd) → Auto → online/"🎮 Gaming" (before: online/"dnd"). Still open in #283: while in DND other clients show the literal "dnd"; that needs MSC3026 busy presence on the homeserver (matrix#19). Refs #283 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/account/Profile.tsx | 8 +++-- src/app/utils/ownStatusSync.test.ts | 31 +++++++++++++++++++ src/app/utils/ownStatusSync.ts | 12 +++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 5a287395a..0fa983ce9 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -57,7 +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 { DND_STATUS_SENTINEL, noteLocalStatusWrite } from '../../../utils/ownStatusSync'; import { ProfileDecoration } from './ProfileDecoration'; import { EmojiBoard } from '../../../components/emoji-board'; import { useStatusPresets } from '../../../hooks/useStatusPresets'; @@ -396,7 +396,8 @@ function ProfileStatus() { const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus'); const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); - const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? ''; + const serverStatus = presence?.status === DND_STATUS_SENTINEL ? undefined : presence?.status; + const initialStatus = serverStatus ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? ''; const [statusMsg, setStatusMsg] = useState(initialStatus); // True while the user has unsaved local edits — prevents a server presence // echo from overwriting what the user is currently typing/inserting. @@ -442,6 +443,9 @@ function ProfileStatus() { pendingAppliedRef.current = null; // window elapsed — accept external changes } } + // [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user + // typed: don't show it in the status field or keep it as their status. + if (remoteStatus === DND_STATUS_SENTINEL) return; if (remoteStatus) { setStatusMsg(remoteStatus); localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus); diff --git a/src/app/utils/ownStatusSync.test.ts b/src/app/utils/ownStatusSync.test.ts index 6c256a2b6..e17601622 100644 --- a/src/app/utils/ownStatusSync.test.ts +++ b/src/app/utils/ownStatusSync.test.ts @@ -48,3 +48,34 @@ test("a fresh local save wins over a server read that hasn't caught up", () => { 'previous', ); }); + +test('pickHeartbeatStatus: the "dnd" sentinel is never adopted as the user status (#283)', () => { + const base = { lastLocalWriteAt: 0, now: 100_000 }; + // leaving DND: the server still says "dnd" → restore the real local status + assert.equal( + pickHeartbeatStatus({ + ...base, + local: '🎮 Gaming', + server: { presence: 'unavailable', status_msg: 'dnd' }, + }), + '🎮 Gaming', + ); + // a local copy that had absorbed the sentinel becomes empty, not "dnd" + assert.equal( + pickHeartbeatStatus({ + ...base, + local: 'dnd', + server: { presence: 'online', status_msg: 'dnd' }, + }), + '', + ); + // ordinary statuses are unaffected + assert.equal( + pickHeartbeatStatus({ + ...base, + local: 'old', + server: { presence: 'online', status_msg: 'new' }, + }), + 'new', + ); +}); diff --git a/src/app/utils/ownStatusSync.ts b/src/app/utils/ownStatusSync.ts index 7d80c5502..d56324602 100644 --- a/src/app/utils/ownStatusSync.ts +++ b/src/app/utils/ownStatusSync.ts @@ -9,6 +9,9 @@ * overridden by a server read that hasn't caught up yet. */ +/** The status_msg Lotus sends for Do Not Disturb (see usePresenceUpdater). */ +export const DND_STATUS_SENTINEL = 'dnd'; + /** Trust this device's own copy for this long after it saved or cleared it. */ export const LOCAL_WRITE_GRACE_MS = 15_000; @@ -37,7 +40,12 @@ export const pickHeartbeatStatus = ({ lastLocalWriteAt: number; now: number; }): string => { - if (!server || server.presence === 'offline') return local; - if (now - writtenAt < LOCAL_WRITE_GRACE_MS) return local; + // [Gitea #283] Lotus encodes Do Not Disturb as status_msg "dnd", so a device + // leaving DND must not adopt that sentinel as the user's status — keep (and + // so restore) the real one it holds locally. + const mine = local === DND_STATUS_SENTINEL ? '' : local; + if (!server || server.presence === 'offline') return mine; + if (now - writtenAt < LOCAL_WRITE_GRACE_MS) return mine; + if (server.status_msg === DND_STATUS_SENTINEL) return mine; return server.status_msg ?? ''; };