diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
index afdcf1688..d3e0c92df 100644
--- a/src/app/components/user-profile/UserHero.tsx
+++ b/src/app/components/user-profile/UserHero.tsx
@@ -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}
+ {pronouns && (
+
+
+ {pronouns}
+
+
+ )}
@{username}
+ {timezone && (
+
+
+ {timezone}
+
+
+ )}
{status && (
{
closeUserRoomProfile();
@@ -262,7 +264,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
-
+
{showEncryption && }
{userId !== myUserId && (
diff --git a/src/app/features/room-settings/RoomServerACL.tsx b/src/app/features/room-settings/RoomServerACL.tsx
new file mode 100644
index 000000000..3a2600e35
--- /dev/null
+++ b/src/app/features/room-settings/RoomServerACL.tsx
@@ -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(null);
+ const [error, setError] = useState();
+
+ 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) => {
+ if (e.key === 'Enter') handleAdd();
+ };
+
+ return (
+
+
+ {label}
+ {canEdit && (
+ }
+ onClick={handleAdd}
+ >
+ Add
+
+ )}
+
+
+ {canEdit && (
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+
+ {entries.length === 0 ? (
+
+ (empty)
+
+ ) : (
+ entries.map((entry, i) => (
+
+
+ {entry}
+
+ {canEdit && (
+ onRemove(i)}
+ style={{ flexShrink: 0 }}
+ >
+
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
+// ── 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() ?? DEFAULT_ACL;
+
+ // Local draft state — initialised from current ACL
+ const [allowList, setAllowList] = useState(() => currentAcl.allow ?? ['*']);
+ const [denyList, setDenyList] = useState(() => currentAcl.deny ?? []);
+ const [allowIpLiterals, setAllowIpLiterals] = useState(
+ () => 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 (
+
+
+
+
+
+
+ Server ACL
+
+
+
+ {canEdit && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Info banner */}
+
+
+
+
+
+ Server ACL controls which servers can participate in this room. Changes take
+ effect immediately.
+
+ {!canEdit && (
+
+ You need power level {requiredPL} to edit the ACL (your level: {myPL}).
+
+ )}
+
+
+
+
+ {/* Save error */}
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+ {/* Allow IP literals toggle */}
+
+ IP Address Access
+
+
+ setAllowIpLiterals(e.target.checked)}
+ style={{
+ width: 16,
+ height: 16,
+ flexShrink: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ />
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+
+ When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot
+ participate.
+
+
+
+
+
+
+ {/* Allowed servers */}
+ setAllowList((prev) => [...prev, value])}
+ onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))}
+ />
+
+ {/* Denied servers */}
+ setDenyList((prev) => [...prev, value])}
+ onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))}
+ />
+
+ {/* Note about defaults */}
+
+ Tip: The default ACL allows all servers (allow: ["*"], deny: []). Adding
+ "*" to the allow list permits all servers not explicitly denied.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
index 5b98bf033..f5ed8ae5d 100644
--- a/src/app/features/room-settings/RoomSettings.tsx
+++ b/src/app/features/room-settings/RoomSettings.tsx
@@ -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(() => {
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 && (
)}
+ {activePage === RoomSettingsPage.ServerACLPage && (
+
+ )}
);
}
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index ce261c902..8da7ae323 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -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('');
+ const [savedPronouns, setSavedPronouns] = useState('');
+
+ 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 = (evt) => {
+ setPronouns(evt.currentTarget.value);
+ };
+
+ const handleReset = () => {
+ setPronouns(savedPronouns);
+ };
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ if (saving) return;
+ savePronouns(pronouns.trim());
+ };
+
+ const hasChanges = pronouns !== savedPronouns;
+
+ return (
+
+ Pronouns
+
+ }
+ description={
+
+ Shown on your profile. Visible to other users.
+
+ }
+ >
+
+
+
+
+
+
+ )
+ }
+ />
+
+
+
+ {saveState.status === AsyncStatus.Error && (
+
+ Failed to save pronouns. Try again.
+
+ )}
+
+
+ );
+}
+
+function ProfileTimezone() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ const [timezone, setTimezone] = useState('');
+ const [savedTimezone, setSavedTimezone] = useState('');
+
+ 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) => {
+ setTimezone(evt.currentTarget.value);
+ };
+
+ const handleReset = () => {
+ setTimezone(savedTimezone);
+ };
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ if (saving) return;
+ saveTimezone(timezone);
+ };
+
+ const hasChanges = timezone !== savedTimezone;
+
+ return (
+
+ Timezone
+
+ }
+ description={
+
+ Your local timezone. Visible to other users.
+
+ }
+ >
+
+
+
+
+
+ {hasChanges && !saving && (
+
+
+
+ )}
+
+
+ {saveState.status === AsyncStatus.Error && (
+
+ Failed to save timezone. Try again.
+
+ )}
+
+
+ );
+}
+
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
@@ -583,6 +868,8 @@ export function Profile() {
+
+
);
diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx
index f6858529d..48545c663 100644
--- a/src/app/features/settings/notifications/Notifications.tsx
+++ b/src/app/features/settings/notifications/Notifications.tsx
@@ -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) {
+
Block Messages
= {
+ '.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.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 (
+
+ );
+}
+
+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 (
+
+ {deleting ? : }
+
+ );
+}
+
+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 ;
+}
+
+type RuleRowProps = {
+ kind: PushRuleKind;
+ pushRule: IPushRule;
+ custom: boolean;
+};
+
+function RuleRow({ kind, pushRule, custom }: RuleRowProps) {
+ return (
+
+ }
+ after={
+
+
+ {custom && }
+
+ }
+ />
+
+ );
+}
+
+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.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 = (evt) => {
+ evt.preventDefault();
+ if (adding) return;
+ const trimmedId = ruleId.trim();
+ if (!trimmedId) return;
+ doAdd(trimmedId, mode);
+ };
+
+ const handleChange: ChangeEventHandler = (evt) => {
+ setRuleId(evt.currentTarget.value);
+ };
+
+ const handleModeChange: ChangeEventHandler = (evt) => {
+ setMode(evt.target.value as NotificationMode);
+ };
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type RuleSectionProps = {
+ title: string;
+ kind: PushRuleKind;
+ rules: IPushRule[];
+ addForm?: React.ReactNode;
+};
+
+function RuleSection({ title, kind, rules, addForm }: RuleSectionProps) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+
+ setExpanded((v) => !v)}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ before={
+
+ }
+ >
+ {expanded ? 'Collapse' : 'Expand'}
+
+ }
+ />
+ {expanded && addForm && {addForm}}
+ {expanded && rules.length === 0 && !addForm && (
+
+ No rules configured.
+
+ )}
+
+ {expanded &&
+ rules.map((pushRule) => (
+
+ ))}
+
+ );
+}
+
+export function PushRuleEditor() {
+ const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
+ const pushRules = useMemo(
+ () => pushRulesEvt?.getContent() ?? { 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 (
+
+ Advanced Push Rules
+
+
+ }
+ />
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts
new file mode 100644
index 000000000..4f0325abe
--- /dev/null
+++ b/src/app/hooks/useExtendedProfile.ts
@@ -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({});
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const fetchField = async >(
+ field: string,
+ ): Promise => {
+ try {
+ const res = await mx.http.authedRequest(
+ 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;
+};
diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts
index 0afbf1e2f..64a594125 100644
--- a/src/app/state/roomSettings.ts
+++ b/src/app/state/roomSettings.ts
@@ -8,6 +8,7 @@ export enum RoomSettingsPage {
DeveloperToolsPage,
ExportPage,
ActivityLogPage,
+ ServerACLPage,
}
export type RoomSettingsState = {