- PushRuleEditor: the enable Switch initialized its state from pushRule.enabled
once (useState initializer), so a rule toggled on another device left the
Switch stale until remount. A useEffect now resyncs on pushRule.enabled
change. pushRule flows from useAccountData(m.push_rules), which re-renders on
sync, so the resync is genuinely reached; no optimistic-update conflict (the
toggle sets state only after the PUT resolves).
- About: the "Homeserver Support" panel fetched /.well-known/matrix/support from
the client-API URL (mx.getHomeserverUrl()). Per MSC1929 that file lives at the
MXID server-name host (like /.well-known/matrix/client), which differs on
delegated/split-domain servers. Now fetched from https://{mx.getDomain()};
identical target for non-delegated servers (incl. Lotus), spec-correct for
delegated ones, and degrades gracefully (catch → panel hidden) otherwise.
Bug-hunt findings from LOTUS_TODO. Two review agents; both confirmed effective
and non-regressing (full account-data re-render chain traced; CORS/host edge
weighed). Gate-green (tsc, eslint, prettier, 914 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
import React, {
|
|
ChangeEventHandler,
|
|
FormEventHandler,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
|
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
|
import { useAccountData } from '../../../hooks/useAccountData';
|
|
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
|
import { SequenceCard } from '../../../components/sequence-card';
|
|
import { SequenceCardStyle } from '../styles.css';
|
|
import { SettingTile } from '../../../components/setting-tile';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
import {
|
|
getNotificationModeActions,
|
|
NotificationMode,
|
|
useNotificationModeActions,
|
|
} from '../../../hooks/useNotificationMode';
|
|
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
|
|
|
const RULE_LABELS: Record<string, string> = {
|
|
'.m.rule.master': 'Disable all notifications',
|
|
'.m.rule.suppress_notices': 'Suppress bot notices',
|
|
'.m.rule.invite_for_me': 'Invited to a room',
|
|
'.m.rule.member_event': 'Member events (joins/leaves)',
|
|
'.m.rule.is_user_mention': '@mention',
|
|
'.m.rule.contains_display_name': 'Message contains my name',
|
|
'.m.rule.is_room_mention': 'Room @mention',
|
|
'.m.rule.tombstone': 'Room upgrade',
|
|
'.m.rule.reaction': 'Reactions',
|
|
'.m.rule.room_one_to_one': 'DM messages',
|
|
'.m.rule.message': 'All messages',
|
|
'.m.rule.encrypted': 'Encrypted messages',
|
|
};
|
|
|
|
function getRuleLabel(ruleId: string): string {
|
|
return RULE_LABELS[ruleId] ?? ruleId;
|
|
}
|
|
|
|
const MODE_LABELS: Record<NotificationMode, string> = {
|
|
[NotificationMode.NotifyLoud]: 'Notify Loud',
|
|
[NotificationMode.Notify]: 'Notify Silent',
|
|
[NotificationMode.OFF]: 'Disable',
|
|
};
|
|
|
|
const ADD_MODES: NotificationMode[] = [
|
|
NotificationMode.NotifyLoud,
|
|
NotificationMode.Notify,
|
|
NotificationMode.OFF,
|
|
];
|
|
|
|
type RuleEnableToggleProps = {
|
|
kind: PushRuleKind;
|
|
pushRule: IPushRule;
|
|
};
|
|
|
|
function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
|
const mx = useMatrixClient();
|
|
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
|
|
|
// Re-sync when the rule changes externally (e.g. toggled on another device →
|
|
// account-data sync). The useState initializer only runs once, so without
|
|
// this the Switch would show a stale value.
|
|
useEffect(() => {
|
|
setEnabled(pushRule.enabled !== false);
|
|
}, [pushRule.enabled]);
|
|
|
|
const [toggleState, toggle] = useAsyncCallback(
|
|
useCallback(
|
|
async (value: boolean) => {
|
|
await mx.setPushRuleEnabled('global', kind, pushRule.rule_id, value);
|
|
setEnabled(value);
|
|
},
|
|
[mx, kind, pushRule.rule_id],
|
|
),
|
|
);
|
|
|
|
const toggling = toggleState.status === AsyncStatus.Loading;
|
|
|
|
return (
|
|
<Switch
|
|
variant="Primary"
|
|
value={enabled}
|
|
onChange={toggling ? undefined : toggle}
|
|
aria-label={enabled ? 'Disable rule' : 'Enable rule'}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type RuleDeleteButtonProps = {
|
|
kind: PushRuleKind;
|
|
pushRule: IPushRule;
|
|
};
|
|
|
|
function RuleDeleteButton({ kind, pushRule }: RuleDeleteButtonProps) {
|
|
const mx = useMatrixClient();
|
|
|
|
const [deleteState, doDelete] = useAsyncCallback(
|
|
useCallback(
|
|
() => mx.deletePushRule('global', kind, pushRule.rule_id),
|
|
[mx, kind, pushRule.rule_id],
|
|
),
|
|
);
|
|
|
|
const deleting = deleteState.status === AsyncStatus.Loading;
|
|
|
|
return (
|
|
<IconButton
|
|
onClick={doDelete}
|
|
size="300"
|
|
radii="Pill"
|
|
variant="Critical"
|
|
fill="Soft"
|
|
disabled={deleting}
|
|
aria-label="Delete rule"
|
|
>
|
|
{deleting ? <Spinner size="100" /> : <Icon src={Icons.Delete} size="100" />}
|
|
</IconButton>
|
|
);
|
|
}
|
|
|
|
type RuleModeSwitcherProps = {
|
|
kind: PushRuleKind;
|
|
pushRule: IPushRule;
|
|
};
|
|
|
|
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
|
|
const mx = useMatrixClient();
|
|
const getModeActions = useNotificationModeActions();
|
|
|
|
const handleChange = useCallback(
|
|
async (mode: NotificationMode) => {
|
|
const actions = getModeActions(mode);
|
|
await mx.setPushRuleActions('global', kind, pushRule.rule_id, actions);
|
|
},
|
|
[mx, getModeActions, kind, pushRule.rule_id],
|
|
);
|
|
|
|
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
|
|
}
|
|
|
|
type RuleRowProps = {
|
|
kind: PushRuleKind;
|
|
pushRule: IPushRule;
|
|
custom: boolean;
|
|
};
|
|
|
|
function RuleRow({ kind, pushRule, custom }: RuleRowProps) {
|
|
return (
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<SettingTile
|
|
title={getRuleLabel(pushRule.rule_id)}
|
|
before={<RuleEnableToggle kind={kind} pushRule={pushRule} />}
|
|
after={
|
|
<Box gap="200" alignItems="Center">
|
|
<RuleModeSwitcher kind={kind} pushRule={pushRule} />
|
|
{custom && <RuleDeleteButton kind={kind} pushRule={pushRule} />}
|
|
</Box>
|
|
}
|
|
/>
|
|
</SequenceCard>
|
|
);
|
|
}
|
|
|
|
type AddRuleFormProps = {
|
|
kind: PushRuleKind.RoomSpecific | PushRuleKind.SenderSpecific;
|
|
placeholder: string;
|
|
label: string;
|
|
};
|
|
|
|
function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
|
|
const mx = useMatrixClient();
|
|
const [ruleId, setRuleId] = useState('');
|
|
const [mode, setMode] = useState<NotificationMode>(NotificationMode.Notify);
|
|
|
|
const [addState, doAdd] = useAsyncCallback(
|
|
useCallback(
|
|
async (id: string, notifyMode: NotificationMode) => {
|
|
const actions = getNotificationModeActions(notifyMode);
|
|
await mx.addPushRule('global', kind, id, { actions });
|
|
setRuleId('');
|
|
},
|
|
[mx, kind],
|
|
),
|
|
);
|
|
|
|
const adding = addState.status === AsyncStatus.Loading;
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
evt.preventDefault();
|
|
if (adding) return;
|
|
const trimmedId = ruleId.trim();
|
|
if (!trimmedId) return;
|
|
doAdd(trimmedId, mode);
|
|
};
|
|
|
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
|
setRuleId(evt.currentTarget.value);
|
|
};
|
|
|
|
return (
|
|
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
|
|
<Text size="T200" priority="300">
|
|
{label}
|
|
</Text>
|
|
<Box gap="200" alignItems="Center">
|
|
<Box grow="Yes">
|
|
<Input
|
|
required
|
|
aria-label={placeholder}
|
|
placeholder={placeholder}
|
|
value={ruleId}
|
|
onChange={handleChange}
|
|
variant="Secondary"
|
|
radii="300"
|
|
readOnly={adding}
|
|
style={{ paddingRight: config.space.S200 }}
|
|
/>
|
|
</Box>
|
|
<Box shrink="No">
|
|
<SettingsSelect
|
|
value={mode}
|
|
options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
|
|
onChange={setMode}
|
|
aria-label="Notification mode"
|
|
/>
|
|
</Box>
|
|
<Button
|
|
size="400"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
outlined
|
|
radii="300"
|
|
type="submit"
|
|
disabled={adding}
|
|
>
|
|
{adding && <Spinner variant="Secondary" size="300" />}
|
|
<Text size="B400">Add</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type RuleSectionProps = {
|
|
title: string;
|
|
kind: PushRuleKind;
|
|
rules: IPushRule[];
|
|
addForm?: React.ReactNode;
|
|
};
|
|
|
|
function RuleSection({ title, kind, rules, addForm }: RuleSectionProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<Box direction="Column" gap="100">
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<SettingTile
|
|
title={title}
|
|
description={`${rules.length} rule${rules.length !== 1 ? 's' : ''}`}
|
|
after={
|
|
<Button
|
|
onClick={() => setExpanded((v) => !v)}
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
outlined
|
|
before={
|
|
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
|
|
}
|
|
>
|
|
<Text size="B300">{expanded ? 'Collapse' : 'Expand'}</Text>
|
|
</Button>
|
|
}
|
|
/>
|
|
{expanded && addForm && <Box direction="Column">{addForm}</Box>}
|
|
{expanded && rules.length === 0 && !addForm && (
|
|
<Text size="T200" priority="300">
|
|
No rules configured.
|
|
</Text>
|
|
)}
|
|
</SequenceCard>
|
|
{expanded &&
|
|
rules.map((pushRule) => (
|
|
<RuleRow
|
|
key={pushRule.rule_id}
|
|
kind={kind}
|
|
pushRule={pushRule}
|
|
custom={pushRule.default === false}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function PushRuleEditor() {
|
|
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
|
const pushRules = useMemo(
|
|
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
|
[pushRulesEvt],
|
|
);
|
|
|
|
const overrideRules = useMemo(() => pushRules.global[PushRuleKind.Override] ?? [], [pushRules]);
|
|
const roomRules = useMemo(() => pushRules.global[PushRuleKind.RoomSpecific] ?? [], [pushRules]);
|
|
const senderRules = useMemo(
|
|
() => pushRules.global[PushRuleKind.SenderSpecific] ?? [],
|
|
[pushRules],
|
|
);
|
|
const underrideRules = useMemo(() => pushRules.global[PushRuleKind.Underride] ?? [], [pushRules]);
|
|
|
|
return (
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Advanced Push Rules</Text>
|
|
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
|
<RuleSection
|
|
title="Room Rules"
|
|
kind={PushRuleKind.RoomSpecific}
|
|
rules={roomRules}
|
|
addForm={
|
|
<AddRuleForm
|
|
kind={PushRuleKind.RoomSpecific}
|
|
placeholder="!roomid:server"
|
|
label="Add a per-room notification rule by room ID"
|
|
/>
|
|
}
|
|
/>
|
|
<RuleSection
|
|
title="Sender Rules"
|
|
kind={PushRuleKind.SenderSpecific}
|
|
rules={senderRules}
|
|
addForm={
|
|
<AddRuleForm
|
|
kind={PushRuleKind.SenderSpecific}
|
|
placeholder="@user:server"
|
|
label="Add a per-user notification rule by user ID"
|
|
/>
|
|
}
|
|
/>
|
|
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
|
</Box>
|
|
);
|
|
}
|