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:
2026-06-03 23:13:33 -04:00
parent 160db1eaef
commit 51a355fe77
9 changed files with 1157 additions and 43 deletions
@@ -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>
);