Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 00f962a463 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
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
2026-10-19 01:04:27 -04:00
11 changed files with 117 additions and 54 deletions
-3
View File
@@ -12,9 +12,6 @@ regexes = [
'''https?://matrix\.lotusguild\.org''',
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
'''matrix\.lotusguild\.org''',
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
]
paths = [
'''config\.json''',
+6 -2
View File
@@ -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, MainColor> = {
[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 (
@@ -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;
}
@@ -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);
+31 -7
View File
@@ -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;
}
+3
View File
@@ -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, string> =>
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
[Presence.Busy]: 'Do Not Disturb',
}),
[],
);
+11
View File
@@ -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);
});
+21
View File
@@ -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');
+26 -3
View File
@@ -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<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) =>
new Promise<void>((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;
+3 -13
View File
@@ -69,23 +69,13 @@ test('install kinds: anything unknown keeps the in-app updater', async () => {
test('package installs get their package manager’s command, not an Install button', async () => {
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
const pacman = packageUpdateHelp('pacman')!;
// [cinny-desktop #36] signature, then checksum, then install, in that order;
// a local `pacman -U ./file` (not `<url>`, which also wants `<url>.sig`).
const steps = pacman.command!.split(' && ');
assert.equal(steps[0], 'cd "$(mktemp -d)"');
assert.match(
steps[1],
/-fLO \S+\/LotusChat-x86_64\.pkg\.tar\.zst -fLO \S+\/SHA256SUMS\.txt -fLO \S+\/SHA256SUMS\.txt\.minisig$/,
);
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
assert.equal(
steps[2],
'minisign -Vm SHA256SUMS.txt -P RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l',
pacman.command,
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
);
assert.equal(steps[3], 'sha256sum -c --ignore-missing SHA256SUMS.txt');
assert.equal(steps[4], 'sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst');
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
const deb = packageUpdateHelp('deb')!;
assert.match(deb.command!, /minisign -Vm SHA256SUMS\.txt/);
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
assert.equal(packageUpdateHelp('manual')?.command, undefined);
assert.equal(packageUpdateHelp('in-app'), undefined);
+9 -24
View File
@@ -64,38 +64,23 @@ export const toInstallKind = (value: unknown): InstallKind =>
export type PackageUpdateHelp = { command?: string; url: string; download: string };
/** The desktop updater's minisign public key (cinny-desktop tauri.conf.json). */
export const RELEASE_MINISIGN_KEY = 'RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l';
/**
* [cinny-desktop #36] Download a Linux package and install it only after
* checking the release's minisign-signed SHA256SUMS.txt. Runs in a fresh temp
* directory so a stale SHA256SUMS.txt can't be picked up.
*/
const verifiedInstall = (file: string, install: string): string =>
[
`cd "$(mktemp -d)"`,
`curl -fLO ${RELEASE_DOWNLOAD}/${file} -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt.minisig`,
`minisign -Vm SHA256SUMS.txt -P ${RELEASE_MINISIGN_KEY}`,
`sha256sum -c --ignore-missing SHA256SUMS.txt`,
`${install} ./${file}`,
].join(' && ');
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
if (kind === 'pacman') {
// Install the local file: `pacman -U <url>` also fetches `<url>.sig` and
// fails without it (packages aren't signed for pacman), while a local file
// falls under LocalFileSigLevel. Integrity comes from SHA256SUMS.txt.
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
// Download first, then install the local file: `pacman -U <url>` also
// fetches `<url>.sig` and fails without it (we don't sign packages),
// while a local file falls under LocalFileSigLevel (signature optional).
return {
command: verifiedInstall('LotusChat-x86_64.pkg.tar.zst', 'sudo pacman -U'),
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`,
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
url,
download: 'Download package',
};
}
if (kind === 'deb') {
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
return {
command: verifiedInstall('LotusChat-x86_64.deb', 'sudo apt install'),
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`,
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
url,
download: 'Download package',
};
}