From 00f962a4631baffee1b2ec0f29b9bf957b520eae Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 19 Oct 2026 01:04:27 -0400 Subject: [PATCH] fix(presence): Do Not Disturb is real "busy" presence and keeps your status (#283) DND was sent as presence unavailable with the literal status text "dnd": it replaced your status, and other clients showed "dnd". Where the server supports MSC3026 (busy_presence), DND now sends presence org.matrix.msc3026.busy and leaves the status alone (Synapse keeps busy across /sync set_presence=online). Busy renders as red "Do Not Disturb" with the real status line; the legacy unavailable + "dnd" encoding is still recognised and still used on servers without it. Status saves / clears no longer downgrade busy. The SDK rejects non-spec presences, so busy goes through a direct authed PUT. Fixes #283 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/presence/Presence.tsx | 8 +++- .../presence/PresenceRingAvatar.tsx | 1 + src/app/features/settings/account/Profile.tsx | 8 +++- src/app/hooks/usePresenceUpdater.ts | 38 +++++++++++++++---- src/app/hooks/useUserPresence.ts | 3 ++ src/app/utils/busyPresence.test.ts | 11 ++++++ src/app/utils/busyPresence.ts | 21 ++++++++++ src/app/utils/presenceWrite.ts | 29 ++++++++++++-- 8 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 src/app/utils/busyPresence.test.ts create mode 100644 src/app/utils/busyPresence.ts diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 1bdef048b..3d0305e2d 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -13,11 +13,13 @@ import { import React, { ReactNode } from 'react'; import * as css from './styles.css'; import { Presence, usePresenceLabel } from '../../hooks/useUserPresence'; +import { isDndPresence } from '../../utils/busyPresence'; const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Busy]: 'Critical', }; type PresenceBadgeProps = { @@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { // Disturb" to match PresenceRingAvatar and the settings picker (which both // special-case 'dnd' → Critical) — the badge was the lone outlier showing a // yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line. - const isDnd = presence === Presence.Unavailable && status === 'dnd'; + // [Gitea #283] or MSC3026 busy, which keeps a real status line. + const isDnd = isDndPresence(presence, status); + const legacyDnd = isDnd && status === 'dnd'; const displayLabel = isDnd ? 'Do Not Disturb' : label[presence]; - const displayStatus = isDnd ? undefined : status; + const displayStatus = legacyDnd ? undefined : status; const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel; return ( diff --git a/src/app/components/presence/PresenceRingAvatar.tsx b/src/app/components/presence/PresenceRingAvatar.tsx index 3fde97214..57ef58a6c 100644 --- a/src/app/components/presence/PresenceRingAvatar.tsx +++ b/src/app/components/presence/PresenceRingAvatar.tsx @@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence'; function presenceRingColor(presence: Presence | undefined, status?: string): string | null { if (!presence || presence === Presence.Offline) return null; + if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND if (presence === Presence.Unavailable) { return status === 'dnd' ? color.Critical.Main : color.Warning.Main; } diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 0fa983ce9..43acb02a2 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getAccountData, setAccountData } from '../../../utils/accountData'; import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater'; -import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite'; +import { + describePresenceError, + setPresenceWithRetry, + writePresence, +} from '../../../utils/presenceWrite'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; @@ -554,7 +558,7 @@ function ProfileStatus() { localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); // Preserve the user's chosen presence when clearing the status message. - mx.setPresence({ + writePresence(mx, { presence: presenceStateFromSetting(presenceStatus, hidePresence), status_msg: '', }).catch(() => undefined); diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index 74a1ada06..39ee4b5fc 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -1,20 +1,27 @@ import { useEffect, useRef } from 'react'; import { SetPresence } from 'matrix-js-sdk'; -import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite'; +import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite'; import { useMatrixClient } from './useMatrixClient'; import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { + BUSY_PRESENCE, + BUSY_PRESENCE_FEATURE, + isBusyPresenceSupported, + setBusyPresenceSupported, +} from '../utils/busyPresence'; const IDLE_TIMEOUT_MS = 10 * 60 * 1000; const ACTIVITY_THROTTLE_MS = 1000; export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible'; -export type PresenceState = 'online' | 'unavailable' | 'offline'; +export type PresenceState = 'online' | 'unavailable' | 'offline' | typeof BUSY_PRESENCE; /** * Single source of truth for mapping the user's presence preference to the - * Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable', + * Matrix presence value: auto/online → 'online', idle → 'unavailable', dnd → + * MSC3026 busy (or 'unavailable' where the server lacks it), * invisible (or the hidePresence override) → 'offline'. Shared with the Profile * status writer so setting/clearing a status message never overrides the user's * chosen presence (e.g. outing an Invisible user as online). @@ -24,7 +31,10 @@ export function presenceStateFromSetting( hidePresence: boolean, ): PresenceState { if (hidePresence || presenceStatus === 'invisible') return 'offline'; - if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable'; + // [Gitea #283] DND is MSC3026 busy where the server supports it, so status + // writes (save / clear / auto-clear) don't downgrade it to "unavailable". + if (presenceStatus === 'dnd') return isBusyPresenceSupported() ? BUSY_PRESENCE : 'unavailable'; + if (presenceStatus === 'idle') return 'unavailable'; return 'online'; } @@ -62,8 +72,7 @@ export function usePresenceUpdater() { // by a heartbeat that changed nothing. const send = (write: PresenceWrite, label: string) => { if (!deduper.shouldSend(write)) return Promise.resolve(); - return mx - .setPresence(write) + return writePresence(mx, write) .then(() => deduper.sent(write)) .catch((err) => warnPresenceFailure(label, err)); }; @@ -134,7 +143,22 @@ export function usePresenceUpdater() { return undefined; } if (presenceStatus === 'dnd') { - setUnavailable('dnd'); + // [Gitea #283] Real "busy" presence where supported: the status text + // stays yours. /sync reporting "online" doesn't override busy on + // Synapse, so the sync loop can stay online. Older servers keep the + // legacy unavailable + "dnd" text encoding. + mx.doesServerSupportUnstableFeature(BUSY_PRESENCE_FEATURE) + .catch(() => false) + .then(async (busy) => { + setBusyPresenceSupported(busy); + if (!busy) { + setUnavailable('dnd'); + return; + } + syncPresence(SetPresence.Online); + const status = await resolveStatus(); + send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy'); + }); return undefined; } diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 71b68662b..fea6a5526 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -6,6 +6,8 @@ export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', + // [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb. + Busy = 'org.matrix.msc3026.busy', } export type UserPresence = { @@ -129,6 +131,7 @@ export const usePresenceLabel = (): Record => [Presence.Online]: 'Online', [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', + [Presence.Busy]: 'Do Not Disturb', }), [], ); diff --git a/src/app/utils/busyPresence.test.ts b/src/app/utils/busyPresence.test.ts new file mode 100644 index 000000000..6ea525254 --- /dev/null +++ b/src/app/utils/busyPresence.test.ts @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BUSY_PRESENCE, isDndPresence } from './busyPresence'; + +test('isDndPresence: busy, or the legacy unavailable + "dnd" text (#283)', () => { + assert.equal(isDndPresence(BUSY_PRESENCE, '🎮 Gaming'), true); + assert.equal(isDndPresence(BUSY_PRESENCE, undefined), true); + assert.equal(isDndPresence('unavailable', 'dnd'), true); + assert.equal(isDndPresence('unavailable', 'lunch'), false); + assert.equal(isDndPresence('online', 'dnd'), false); +}); diff --git a/src/app/utils/busyPresence.ts b/src/app/utils/busyPresence.ts new file mode 100644 index 000000000..283b824be --- /dev/null +++ b/src/app/utils/busyPresence.ts @@ -0,0 +1,21 @@ +/** + * [Gitea #283] Do Not Disturb as a real presence state: MSC3026 "busy" + * (Synapse `experimental_features.msc3026_enabled`). Before, DND was sent as + * presence `unavailable` with the literal status text "dnd", which replaced + * your status and showed as "dnd" in other clients. + */ +export const BUSY_PRESENCE = 'org.matrix.msc3026.busy'; +export const BUSY_PRESENCE_FEATURE = 'org.matrix.msc3026.busy_presence'; + +let busySupported = false; + +/** Recorded by the presence updater from the server's unstable_features. */ +export const setBusyPresenceSupported = (supported: boolean): void => { + busySupported = supported; +}; + +export const isBusyPresenceSupported = (): boolean => busySupported; + +/** Pure: whether a presence means Do Not Disturb (busy, or the legacy "dnd" text). */ +export const isDndPresence = (presence: string | undefined, status: string | undefined): boolean => + presence === BUSY_PRESENCE || (presence === 'unavailable' && status === 'dnd'); diff --git a/src/app/utils/presenceWrite.ts b/src/app/utils/presenceWrite.ts index 209745380..7d01b22ab 100644 --- a/src/app/utils/presenceWrite.ts +++ b/src/app/utils/presenceWrite.ts @@ -1,4 +1,5 @@ -import { MatrixClient, MatrixError } from 'matrix-js-sdk'; +import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk'; +import { BUSY_PRESENCE } from './busyPresence'; /** * Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per @@ -10,7 +11,29 @@ import { MatrixClient, MatrixError } from 'matrix-js-sdk'; * and `PresenceDeduper` lets the heartbeat skip writes that would not change * anything, so it stops spending the budget in the first place. */ -export type PresenceWrite = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string }; +export type PresenceWrite = { + // [Gitea #283] or MSC3026 busy (Do Not Disturb) + presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE; + status_msg?: string; +}; + +/** + * `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three + * spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight + * to the same endpoint. + */ +export const writePresence = async ( + mx: Pick & Partial>, + write: PresenceWrite, +): Promise => { + if (write.presence !== BUSY_PRESENCE) { + await mx.setPresence(write as IPresenceOpts); + return; + } + if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient'); + const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`; + await mx.http.authedRequest(Method.Put, path, undefined, write); +}; const sleep = (ms: number) => new Promise((r) => { @@ -28,7 +51,7 @@ export async function setPresenceWithRetry( for (;;) { try { // eslint-disable-next-line no-await-in-loop - await mx.setPresence(write); + await writePresence(mx, write); return; } catch (e) { if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;