fix(presence): Do Not Disturb is real "busy" presence and keeps your status (#283)
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m59s
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m59s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
04605f2680
commit
00f962a463
@@ -13,11 +13,13 @@ import {
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
import { isDndPresence } from '../../utils/busyPresence';
|
||||||
|
|
||||||
const PresenceToColor: Record<Presence, MainColor> = {
|
const PresenceToColor: Record<Presence, MainColor> = {
|
||||||
[Presence.Online]: 'Success',
|
[Presence.Online]: 'Success',
|
||||||
[Presence.Unavailable]: 'Warning',
|
[Presence.Unavailable]: 'Warning',
|
||||||
[Presence.Offline]: 'Secondary',
|
[Presence.Offline]: 'Secondary',
|
||||||
|
[Presence.Busy]: 'Critical',
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresenceBadgeProps = {
|
type PresenceBadgeProps = {
|
||||||
@@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||||
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||||
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
// 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 displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||||
const displayStatus = isDnd ? undefined : status;
|
const displayStatus = legacyDnd ? undefined : status;
|
||||||
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
|||||||
|
|
||||||
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
|
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
|
||||||
if (!presence || presence === Presence.Offline) return null;
|
if (!presence || presence === Presence.Offline) return null;
|
||||||
|
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
|
||||||
if (presence === Presence.Unavailable) {
|
if (presence === Presence.Unavailable) {
|
||||||
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
|
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
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 { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -554,7 +558,7 @@ function ProfileStatus() {
|
|||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
// Preserve the user's chosen presence when clearing the status message.
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({
|
writePresence(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
status_msg: '',
|
status_msg: '',
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { SetPresence } from 'matrix-js-sdk';
|
import { SetPresence } from 'matrix-js-sdk';
|
||||||
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/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 IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const ACTIVITY_THROTTLE_MS = 1000;
|
const ACTIVITY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
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
|
* 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
|
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||||
* status writer so setting/clearing a status message never overrides the user's
|
* status writer so setting/clearing a status message never overrides the user's
|
||||||
* chosen presence (e.g. outing an Invisible user as online).
|
* chosen presence (e.g. outing an Invisible user as online).
|
||||||
@@ -24,7 +31,10 @@ export function presenceStateFromSetting(
|
|||||||
hidePresence: boolean,
|
hidePresence: boolean,
|
||||||
): PresenceState {
|
): PresenceState {
|
||||||
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
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';
|
return 'online';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +72,7 @@ export function usePresenceUpdater() {
|
|||||||
// by a heartbeat that changed nothing.
|
// by a heartbeat that changed nothing.
|
||||||
const send = (write: PresenceWrite, label: string) => {
|
const send = (write: PresenceWrite, label: string) => {
|
||||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||||
return mx
|
return writePresence(mx, write)
|
||||||
.setPresence(write)
|
|
||||||
.then(() => deduper.sent(write))
|
.then(() => deduper.sent(write))
|
||||||
.catch((err) => warnPresenceFailure(label, err));
|
.catch((err) => warnPresenceFailure(label, err));
|
||||||
};
|
};
|
||||||
@@ -134,7 +143,22 @@ export function usePresenceUpdater() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (presenceStatus === 'dnd') {
|
if (presenceStatus === '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');
|
setUnavailable('dnd');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncPresence(SetPresence.Online);
|
||||||
|
const status = await resolveStatus();
|
||||||
|
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
|
||||||
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export enum Presence {
|
|||||||
Online = 'online',
|
Online = 'online',
|
||||||
Unavailable = 'unavailable',
|
Unavailable = 'unavailable',
|
||||||
Offline = 'offline',
|
Offline = 'offline',
|
||||||
|
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
|
||||||
|
Busy = 'org.matrix.msc3026.busy',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserPresence = {
|
export type UserPresence = {
|
||||||
@@ -129,6 +131,7 @@ export const usePresenceLabel = (): Record<Presence, string> =>
|
|||||||
[Presence.Online]: 'Online',
|
[Presence.Online]: 'Online',
|
||||||
[Presence.Unavailable]: 'Idle',
|
[Presence.Unavailable]: 'Idle',
|
||||||
[Presence.Offline]: 'Offline',
|
[Presence.Offline]: 'Offline',
|
||||||
|
[Presence.Busy]: 'Do Not Disturb',
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
@@ -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
|
* 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
|
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
|
||||||
* anything, so it stops spending the budget in the first place.
|
* 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<MatrixClient, 'setPresence'> & Partial<Pick<MatrixClient, 'getSafeUserId' | 'http'>>,
|
||||||
|
write: PresenceWrite,
|
||||||
|
): Promise<void> => {
|
||||||
|
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) =>
|
const sleep = (ms: number) =>
|
||||||
new Promise<void>((r) => {
|
new Promise<void>((r) => {
|
||||||
@@ -28,7 +51,7 @@ export async function setPresenceWithRetry(
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await mx.setPresence(write);
|
await writePresence(mx, write);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
|
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
|
||||||
|
|||||||
Reference in New Issue
Block a user