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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user