feat: extended profile fields, push rule editor, server ACL editor
P2-8: Pronouns (m.pronouns) and Timezone (m.tz) fields in Settings →
Account → Profile; saved via MSC4133 PUT /profile/{userId}/{field};
useExtendedProfile hook fetches both in parallel; UserHero displays
pronouns below display name and timezone string below username
P2-11: Full push rule editor in Settings → Notifications below keyword
rules; covers override/room/sender/underride rule kinds; enable/disable
toggle per rule, human-readable labels for built-in rules, delete button
for custom rules, add-rule form for room and sender rules
P2-12: Server ACL viewer/editor in room settings (Server ACL tab);
reads m.room.server_acl state event; allow/deny server lists with
wildcard validation; allow IP literals toggle; power-level gated
(edit requires sufficient PL, otherwise read-only view)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
PopOut,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -566,6 +567,290 @@ function ProfileStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Sao_Paulo',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Africa/Cairo',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Singapore',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
function ProfilePronouns() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [pronouns, setPronouns] = useState<string>('');
|
||||
const [savedPronouns, setSavedPronouns] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<{ 'm.pronouns': string }>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
)
|
||||
.then((res) => {
|
||||
const val = res['m.pronouns'] ?? '';
|
||||
setPronouns(val);
|
||||
setSavedPronouns(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setPronouns('');
|
||||
setSavedPronouns('');
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, savePronouns] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
mx.http
|
||||
.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
undefined,
|
||||
{ 'm.pronouns': value },
|
||||
)
|
||||
.then(() => {
|
||||
setSavedPronouns(value);
|
||||
}),
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setPronouns(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPronouns(savedPronouns);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
savePronouns(pronouns.trim());
|
||||
};
|
||||
|
||||
const hasChanges = pronouns !== savedPronouns;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Pronouns
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Shown on your profile. Visible to other users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
name="pronounsInput"
|
||||
aria-label="Pronouns"
|
||||
value={pronouns}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. they/them, she/her"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
maxLength={64}
|
||||
readOnly={saving}
|
||||
after={
|
||||
hasChanges &&
|
||||
!saving && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
aria-label="Reset pronouns"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save pronouns. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTimezone() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [timezone, setTimezone] = useState<string>('');
|
||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`)
|
||||
.then((res) => {
|
||||
const val = res['m.tz'] ?? '';
|
||||
setTimezone(val);
|
||||
setSavedTimezone(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setTimezone('');
|
||||
setSavedTimezone('');
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
mx.http
|
||||
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||
'm.tz': value,
|
||||
})
|
||||
.then(() => {
|
||||
setSavedTimezone(value);
|
||||
}),
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setTimezone(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTimezone(savedTimezone);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
saveTimezone(timezone);
|
||||
};
|
||||
|
||||
const hasChanges = timezone !== savedTimezone;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Timezone
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Your local timezone. Visible to other users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<select
|
||||
name="timezoneInput"
|
||||
aria-label="Timezone"
|
||||
value={timezone}
|
||||
onChange={handleSelectChange}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
colorScheme: 'dark',
|
||||
fontSize: '0.875rem',
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">— select timezone —</option>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<option
|
||||
key={tz}
|
||||
value={tz}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
{hasChanges && !saving && (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
size="400"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
aria-label="Reset timezone"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save timezone. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
export function Profile() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
@@ -583,6 +868,8 @@ export function Profile() {
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
<ProfileStatus />
|
||||
<ProfilePronouns />
|
||||
<ProfileTimezone />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SystemNotification } from './SystemNotification';
|
||||
import { AllMessagesNotifications } from './AllMessages';
|
||||
import { SpecialMessagesNotifications } from './SpecialMessages';
|
||||
import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||
import { PushRuleEditor } from './PushRuleEditor';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
@@ -37,6 +38,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||
<AllMessagesNotifications />
|
||||
<SpecialMessagesNotifications />
|
||||
<KeywordMessagesNotifications />
|
||||
<PushRuleEditor />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Block Messages</Text>
|
||||
<SequenceCard
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, 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 { 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);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
|
||||
setMode(evt.target.value as NotificationMode);
|
||||
};
|
||||
|
||||
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">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid currentColor',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
}}
|
||||
>
|
||||
{ADD_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{MODE_LABELS[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user