From 51a355fe77f17675305bdddac1d75308ad0e3c33 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 3 Jun 2026 23:13:33 -0400 Subject: [PATCH] feat: extended profile fields, push rule editor, server ACL editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/components/user-profile/UserHero.tsx | 28 +- .../user-profile/UserRoomProfile.tsx | 10 +- .../features/room-settings/RoomServerACL.tsx | 357 +++++++++++++++++ .../features/room-settings/RoomSettings.tsx | 110 ++++-- src/app/features/settings/account/Profile.tsx | 287 ++++++++++++++ .../settings/notifications/Notifications.tsx | 2 + .../settings/notifications/PushRuleEditor.tsx | 359 ++++++++++++++++++ src/app/hooks/useExtendedProfile.ts | 46 +++ src/app/state/roomSettings.ts | 1 + 9 files changed, 1157 insertions(+), 43 deletions(-) create mode 100644 src/app/features/room-settings/RoomServerACL.tsx create mode 100644 src/app/features/settings/notifications/PushRuleEditor.tsx create mode 100644 src/app/hooks/useExtendedProfile.ts 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 && ( + + )} + + + {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 = {