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 -1
View File
@@ -96,8 +96,16 @@ type UserHeroNameProps = {
displayName?: string;
userId: string;
status?: string;
pronouns?: string;
timezone?: string;
};
export function UserHeroName({ displayName, userId, status }: UserHeroNameProps) {
export function UserHeroName({
displayName,
userId,
status,
pronouns,
timezone,
}: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
return (
@@ -111,11 +119,29 @@ export function UserHeroName({ displayName, userId, status }: UserHeroNameProps)
{displayName ?? username ?? userId}
</Text>
</Box>
{pronouns && (
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
<Text
size="T200"
className={classNames(BreakWord, LineClamp2)}
style={{ opacity: 0.6, fontStyle: 'italic' }}
>
{pronouns}
</Text>
</Box>
)}
<Box alignItems="Center" gap="100" wrap="Wrap">
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
</Text>
</Box>
{timezone && (
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
<Text size="T200" className={classNames(BreakWord, LineClamp2)} style={{ opacity: 0.6 }}>
{timezone}
</Text>
</Box>
)}
{status && (
<Box alignItems="Center" gap="100" style={{ marginTop: '2px', overflow: 'hidden' }}>
<Text
@@ -31,6 +31,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
type VerifyDeviceButtonProps = {
userId: string;
@@ -243,6 +244,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId);
const extProfile = useExtendedProfile(userId);
const handleMessage = () => {
closeUserRoomProfile();
@@ -262,7 +264,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Center">
<UserHeroName displayName={displayName} userId={userId} status={presence?.status} />
<UserHeroName
displayName={displayName}
userId={userId}
status={presence?.status}
pronouns={extProfile.pronouns}
timezone={extProfile.timezone}
/>
{showEncryption && <MemberVerificationBadge userId={userId} />}
{userId !== myUserId && (
<Box shrink="No">
@@ -0,0 +1,357 @@
import React, { useCallback, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
// ── Types ─────────────────────────────────────────────────────────────────────
type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const DEFAULT_ACL: ServerAclContent = {
allow: ['*'],
deny: [],
allow_ip_literals: false,
};
// ── Validation ────────────────────────────────────────────────────────────────
/**
* Validate a server name or wildcard pattern.
* Allowed forms:
* - plain hostname / IP: letters, digits, hyphens, dots
* - wildcard prefix: *.example.com (asterisk only at the very start)
* The Matrix spec allows `*` on its own (match-all wildcard).
*/
function isValidServerPattern(value: string): boolean {
if (value === '*') return true;
// Strip leading wildcard
const rest = value.startsWith('*.') ? value.slice(2) : value;
// Must not be empty after stripping wildcard
if (!rest) return false;
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
return /^[A-Za-z0-9.:_-]+$/.test(rest);
}
// ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = {
label: string;
entries: string[];
canEdit: boolean;
onAdd: (value: string) => void;
onRemove: (index: number) => void;
};
function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | undefined>();
const handleAdd = () => {
const raw = inputRef.current?.value ?? '';
const value = raw.trim();
if (!value) return;
if (!isValidServerPattern(value)) {
setError('Invalid server pattern. Use a hostname or *.example.com');
return;
}
setError(undefined);
onAdd(value);
if (inputRef.current) inputRef.current.value = '';
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleAdd();
};
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween">
<Text size="L400">{label}</Text>
{canEdit && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon src={Icons.Plus} size="100" />}
onClick={handleAdd}
>
<Text size="B300">Add</Text>
</Button>
)}
</Box>
{canEdit && (
<Box direction="Column" gap="100">
<input
ref={inputRef}
type="text"
placeholder="e.g. *.example.com or badserver.org"
onKeyDown={handleKeyDown}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
/>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
</Box>
)}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="0"
>
{entries.length === 0 ? (
<Text size="T300" priority="300" style={{ padding: `${config.space.S100} 0` }}>
(empty)
</Text>
) : (
entries.map((entry, i) => (
<Box
key={`${entry}-${i}`}
alignItems="Center"
justifyContent="SpaceBetween"
style={{
padding: `${config.space.S100} 0`,
borderBottom:
i < entries.length - 1 ? `1px solid ${color.Surface.ContainerLine}` : undefined,
}}
>
<Text size="T300" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{entry}
</Text>
{canEdit && (
<IconButton
size="300"
variant="Background"
radii="300"
aria-label={`Remove ${entry}`}
onClick={() => onRemove(i)}
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)}
</Box>
))
)}
</SequenceCard>
</Box>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type RoomServerACLProps = {
requestClose: () => void;
};
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const mx = useMatrixClient();
const room = useRoom();
// Power level checks
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const myUserId = mx.getSafeUserId();
const canEdit = permissions.stateEvent(StateEvent.RoomServerAcl, myUserId);
// Read current ACL from room state
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
// Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals ?? false,
);
// Track whether there are unsaved changes
const isDirty =
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
// Save handler
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, {
allow: allowList,
deny: denyList,
allow_ip_literals: allowIpLiterals,
});
}, [mx, room.roomId, allowList, denyList, allowIpLiterals]),
);
const saving = saveState.status === AsyncStatus.Loading;
const saveError =
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
// Required power level for this state event
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
const myPL = readPowerLevel.user(powerLevels, myUserId);
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Shield} size="200" />
<Text as="h2" size="H3" truncate>
Server ACL
</Text>
</Box>
<Box shrink="No" gap="200" alignItems="Center">
{canEdit && (
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
disabled={saving || !isDirty}
onClick={() => save()}
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
>
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
</Button>
)}
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{/* Info banner */}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<Box gap="200" alignItems="Start">
<Icon src={Icons.Warning} size="200" style={{ flexShrink: 0, marginTop: 2 }} />
<Box direction="Column" gap="100">
<Text size="T300">
Server ACL controls which servers can participate in this room. Changes take
effect immediately.
</Text>
{!canEdit && (
<Text size="T300" style={{ color: color.Critical.Main }}>
You need power level {requiredPL} to edit the ACL (your level: {myPL}).
</Text>
)}
</Box>
</Box>
</SequenceCard>
{/* Save error */}
{saveError && (
<Text size="T300" style={{ color: color.Critical.Main }}>
{saveError}
</Text>
)}
{/* Allow IP literals toggle */}
<Box direction="Column" gap="100">
<Text size="L400">IP Address Access</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<Box alignItems="Center" gap="300">
<input
type="checkbox"
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
onChange={(e) => setAllowIpLiterals(e.target.checked)}
style={{
width: 16,
height: 16,
flexShrink: 0,
cursor: canEdit ? 'pointer' : 'default',
}}
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }}
>
<Text size="T300">Allow IP literal addresses</Text>
</label>
<Text size="T200" priority="300">
When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot
participate.
</Text>
</Box>
</Box>
</SequenceCard>
</Box>
{/* Allowed servers */}
<ServerList
label="Allowed Servers"
entries={allowList}
canEdit={canEdit}
onAdd={(value) => setAllowList((prev) => [...prev, value])}
onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))}
/>
{/* Denied servers */}
<ServerList
label="Denied Servers"
entries={denyList}
canEdit={canEdit}
onAdd={(value) => setDenyList((prev) => [...prev, value])}
onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))}
/>
{/* Note about defaults */}
<Text size="T200" priority="300">
Tip: The default ACL allows all servers (allow: [&quot;*&quot;], deny: []). Adding
&quot;*&quot; to the allow list permits all servers not explicitly denied.
</Text>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
+69 -41
View File
@@ -19,6 +19,10 @@ import { useRoom } from '../../hooks/useRoom';
import { DeveloperTools } from '../common-settings/developer-tools';
import { ExportRoomHistory } from './ExportRoomHistory';
import { RoomActivityLog } from './RoomActivityLog';
import { RoomServerACL } from './RoomServerACL';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { StateEvent } from '../../../types/matrix/room';
type RoomSettingsMenuItem = {
page: RoomSettingsPage;
@@ -26,47 +30,56 @@ type RoomSettingsMenuItem = {
icon: IconSrc;
};
const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
useMemo(
() => [
{
page: RoomSettingsPage.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: RoomSettingsPage.MembersPage,
name: 'Members',
icon: Icons.User,
},
{
page: RoomSettingsPage.PermissionsPage,
name: 'Permissions',
icon: Icons.Lock,
},
{
page: RoomSettingsPage.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: RoomSettingsPage.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
{
page: RoomSettingsPage.ExportPage,
name: 'Export',
icon: Icons.Download,
},
{
page: RoomSettingsPage.ActivityLogPage,
name: 'Activity',
icon: Icons.RecentClock,
},
],
[],
const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
{
page: RoomSettingsPage.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: RoomSettingsPage.MembersPage,
name: 'Members',
icon: Icons.User,
},
{
page: RoomSettingsPage.PermissionsPage,
name: 'Permissions',
icon: Icons.Lock,
},
{
page: RoomSettingsPage.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: RoomSettingsPage.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
{
page: RoomSettingsPage.ExportPage,
name: 'Export',
icon: Icons.Download,
},
{
page: RoomSettingsPage.ActivityLogPage,
name: 'Activity',
icon: Icons.RecentClock,
},
];
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
page: RoomSettingsPage.ServerACLPage,
name: 'Server ACL',
icon: Icons.Shield,
};
function useRoomSettingsMenuItems(canSeeServerACL: boolean): RoomSettingsMenuItem[] {
return useMemo(
() => (canSeeServerACL ? [...BASE_MENU_ITEMS, SERVER_ACL_MENU_ITEM] : BASE_MENU_ITEMS),
[canSeeServerACL],
);
}
type RoomSettingsProps = {
initialPage?: RoomSettingsPage;
@@ -86,12 +99,24 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
// Power level check: show Server ACL menu item to anyone who can read the state
// (i.e. has at least state_default power level, or a custom ACL event power).
// We show it to all users at or above the required power level; read-only view
// for those who cannot edit.
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const myUserId = mx.getSafeUserId();
const myPL = readPowerLevel.user(powerLevels, myUserId);
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
// Show the menu item if user meets the power level OR is a room creator.
const canSeeServerACL = myPL >= requiredPL || creators.has(myUserId);
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<RoomSettingsPage | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
});
const menuItems = useRoomSettingsMenuItems();
const menuItems = useRoomSettingsMenuItems(canSeeServerACL);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -190,6 +215,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.ActivityLogPage && (
<RoomActivityLog requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.ServerACLPage && (
<RoomServerACL requestClose={handlePageRequestClose} />
)}
</PageRoot>
);
}
@@ -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>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export type ExtendedProfile = {
pronouns?: string;
timezone?: string;
};
export const useExtendedProfile = (userId: string): ExtendedProfile => {
const mx = useMatrixClient();
const [extProfile, setExtProfile] = useState<ExtendedProfile>({});
useEffect(() => {
let cancelled = false;
const fetchField = async <T extends Record<string, string>>(
field: string,
): Promise<string | undefined> => {
try {
const res = await mx.http.authedRequest<T>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/${field}`,
);
return res[field as keyof T] as string | undefined;
} catch {
return undefined;
}
};
Promise.all([
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
fetchField<{ 'm.tz': string }>('m.tz'),
]).then(([pronouns, timezone]) => {
if (!cancelled) {
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
}
});
return () => {
cancelled = true;
};
}, [mx, userId]);
return extProfile;
};
+1
View File
@@ -8,6 +8,7 @@ export enum RoomSettingsPage {
DeveloperToolsPage,
ExportPage,
ActivityLogPage,
ServerACLPage,
}
export type RoomSettingsState = {