diff --git a/package-lock.json b/package-lock.json index 6db1e02d..2be89b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,8 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", @@ -12112,6 +12113,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 33fe68c1..57a81e05 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "slate-dom": "0.112.2", "slate-history": "0.110.3", "slate-react": "0.112.1", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "zod": "4.1.8" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..ef8d01a1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor'; const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; +export type AccountDataDeleteCallback = (type: string) => Promise; type AccountDataInfo = { type: string; @@ -83,8 +84,7 @@ function AccountDataEdit({ if ( !typeStr || - parsedContent === null || - defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + parsedContent === null ) { return; } @@ -121,7 +121,7 @@ function AccountDataEdit({ aria-disabled={submitting} > - Account Data + Field Name void; + requestClose: () => void; + onEdit?: () => void; + submitDelete?: AccountDataDeleteCallback; }; -function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { +function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) { + const [deleteState, deleteCallback] = useAsyncCallback(useCallback( + async () => { + if (submitDelete !== undefined) { + await submitDelete(type); + requestClose(); + } + }, + [type, submitDelete, requestClose], + )); + const deleting = deleteState.status === AsyncStatus.Loading; + return ( - Account Data + Field Name - + {onEdit && ( + + )} + {submitDelete && ( + + )} JSON Content @@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) export type AccountDataEditorProps = { type?: string; - content?: object; - submitChange: AccountDataSubmitCallback; + content?: unknown; + submitChange?: AccountDataSubmitCallback; + submitDelete?: AccountDataDeleteCallback; requestClose: () => void; }; @@ -252,6 +280,7 @@ export function AccountDataEditor({ type, content, submitChange, + submitDelete, requestClose, }: AccountDataEditorProps) { const [data, setData] = useState({ @@ -301,7 +330,7 @@ export function AccountDataEditor({ - {edit ? ( + {(edit && submitChange) ? ( setEdit(true)} + requestClose={requestClose} + onEdit={submitChange ? () => setEdit(true) : undefined} + submitDelete={submitDelete} /> )} diff --git a/src/app/components/CollapsibleCard.tsx b/src/app/components/CollapsibleCard.tsx new file mode 100644 index 00000000..95c02964 --- /dev/null +++ b/src/app/components/CollapsibleCard.tsx @@ -0,0 +1,54 @@ +import React, { ReactNode } from 'react'; +import { Button, Icon, Icons, Text } from 'folds'; +import { SequenceCard } from './sequence-card'; +import { SequenceCardStyle } from '../features/settings/styles.css'; +import { SettingTile } from './setting-tile'; + +type CollapsibleCardProps = { + expand: boolean; + setExpand: (expand: boolean) => void; + title?: ReactNode; + description?: ReactNode; + before?: ReactNode; + children?: ReactNode; +}; + +export function CollapsibleCard({ + expand, + setExpand, + title, + description, + before, + children, +}: CollapsibleCardProps) { + return ( + + setExpand(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && children} + + ); +} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 53e6618b..d65006bd 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -19,6 +19,13 @@ import { Box, Scroll, Avatar, + TooltipProvider, + Tooltip, + Badge, + Overlay, + OverlayBackdrop, + OverlayCenter, + Modal, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMxIdServer } from '../../utils/matrix'; @@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; +import { useInterval } from '../../hooks/useInterval'; +import { TextViewer } from '../text-viewer'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; +import { settingsAtom } from '../../state/settings'; +import { useSetting } from '../../state/hooks/settings'; export function ServerChip({ server }: { server: string }) { const mx = useMatrixClient(); @@ -436,15 +448,24 @@ export function IgnoredUserAlert() { ); } -export function OptionsChip({ userId }: { userId: string }) { +export function OptionsChip({ + userId, + extendedProfile, +}: { + userId: string; + extendedProfile: ExtendedProfile | null; +}) { const mx = useMatrixClient(); - const [cords, setCords] = useState(); + const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools'); - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); + const [profileFieldsOpen, setProfileFieldsOpen] = useState(false); + const [menuCoords, setMenuCoords] = useState(); + + const openMenu: MouseEventHandler = (evt) => { + setMenuCoords(evt.currentTarget.getBoundingClientRect()); }; - const close = () => setCords(undefined); + const closeMenu = () => setMenuCoords(undefined); const ignoredUsers = useIgnoredUsers(); const ignored = ignoredUsers.includes(userId); @@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) { const ignoring = ignoreState.status === AsyncStatus.Loading; return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - toggleIgnore(); - close(); - }} - before={ - ignoring ? ( - - ) : ( - - ) - } - disabled={ignoring} - > - {ignored ? 'Unblock User' : 'Block User'} - -
-
- - } - > - - {ignoring ? ( - - ) : ( - - )} - -
+ <> + {extendedProfile && ( + }> + + setProfileFieldsOpen(false), + escapeDeactivates: stopPropagation, + }} + > + + setProfileFieldsOpen(false)} + /> + + + + + )} + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + toggleIgnore(); + closeMenu(); + }} + before={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + {ignored ? 'Unblock User' : 'Block User'} + + {extendedProfile && developerToolsEnabled && ( + { + setProfileFieldsOpen(true); + closeMenu(); + }} + before={} + > + View Profile Fields + + )} +
+
+ + } + > + + {ignoring ? ( + + ) : ( + + )} + +
+ + ); +} + +export function TimezoneChip({ timezone }: { timezone: string }) { + const shortFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: undefined, + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const longFormat = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + dateStyle: 'long', + timeStyle: 'short', + timeZone: timezone, + }), + [timezone] + ); + const [shortTime, setShortTime] = useState(shortFormat.format()); + const [longTime, setLongTime] = useState(longFormat.format()); + const updateTime = useCallback(() => { + setShortTime(shortFormat.format()); + setLongTime(longFormat.format()); + }, [setShortTime, setLongTime, shortFormat, longFormat]); + + useEffect(() => { + updateTime(); + }, [timezone, updateTime]); + + useInterval(updateTime, 1000); + + return ( + + + + Timezone: + + {timezone} + + + {longTime} + + + } + > + {(triggerRef) => ( + } + > + + {shortTime} + + + )} + ); } diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb748..54e40402 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; +import { ExtendedProfile } from '../../hooks/useExtendedProfile'; type UserHeroProps = { userId: string; @@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { type UserHeroNameProps = { displayName?: string; userId: string; + extendedProfile?: ExtendedProfile; }; -export function UserHeroName({ displayName, userId }: UserHeroNameProps) { +export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) { const username = getMxIdLocalPart(userId); + const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"]; return ( @@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) { {displayName ?? username ?? userId} - + @{username} + {pronouns && ยท {pronouns.map(({ summary }) => summary).join(", ")}} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201ec..cb54083e 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,5 +1,5 @@ import { Box, Button, config, Icon, Icons, Text } from 'folds'; -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { UserHero, UserHeroName } from './UserHero'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; @@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; @@ -22,6 +22,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 UserRoomProfileProps = { userId: string; @@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const timezone = useMemo(() => { + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[]; + const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz']; + if (profileTimezone && supportedTimezones.includes(profileTimezone)) { + return profileTimezone; + } + return undefined; + + }, [extendedProfile]); const presence = useUserPresence(userId); + useEffect(() => { + refreshExtendedProfile(); + }, [refreshExtendedProfile]); + const handleMessage = () => { closeUserRoomProfile(); const directSearchParam: DirectCreateSearchParams = { @@ -77,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( - } - /> - {expandState && ( - - - Events - Total: {roomState.size} - - - setComposeEvent({ stateKey: '' })} - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(roomState.keys()) - .sort() - .map((eventType) => { - const expanded = eventType === expandStateType; - const stateKeyToEvents = roomState.get(eventType); - if (!stateKeyToEvents) return null; + + + Add New + + + + {Array.from(roomState.keys()) + .sort() + .map((eventType) => { + const expanded = eventType === expandStateType; + const stateKeyToEvents = roomState.get(eventType); + if (!stateKeyToEvents) return null; - return ( - - - setExpandStateType(expanded ? undefined : eventType) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={ - - } - after={{stateKeyToEvents.size}} + return ( + + + setExpandStateType(expanded ? undefined : eventType) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={ + + } + after={{stateKeyToEvents.size}} + > + + + {eventType} + + + + {expanded && ( +
- - - {eventType} - - - - {expanded && ( -
+ setComposeEvent({ type: eventType, stateKey: '' }) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={} > - - setComposeEvent({ type: eventType, stateKey: '' }) - } - variant="Surface" - fill="None" - size="300" - radii="0" - before={} - > - - - Add New - - - - {Array.from(stateKeyToEvents.keys()) - .sort() - .map((stateKey) => ( - { - setOpenStateEvent({ - type: eventType, - stateKey, - }); - }} - key={stateKey} - variant="Surface" - fill="None" - size="300" - radii="0" - after={} - > - - - {stateKey ? `"${stateKey}"` : 'Default'} - - - - ))} -
- )} - - ); - })} - - - )} - - + + Add New + + + + {Array.from(stateKeyToEvents.keys()) + .sort() + .map((stateKey) => ( + { + setOpenStateEvent({ + type: eventType, + stateKey, + }); + }} + key={stateKey} + variant="Surface" + fill="None" + size="300" + radii="0" + after={} + > + + + {stateKey ? `"${stateKey}"` : 'Default'} + + + + ))} +
+ )} +
+ ); + })} +
+
+ + - setExpandAccountData(!expandAccountData)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expandAccountData ? 'Collapse' : 'Expand'} - - } - /> - {expandAccountData && ( - - - Events - Total: {accountData.size} - - - } - onClick={() => setAccountDataType(null)} - > - - - Add New - - - - {Array.from(accountData.keys()) - .sort() - .map((type) => ( - } - onClick={() => setAccountDataType(type)} - > - - - {type} - - - - ))} - + + + Events + Total: {accountData.size} - )} - + + } + onClick={() => setAccountDataType(null)} + > + + + Add New + + + + {Array.from(accountData.keys()) + .sort() + .map((type) => ( + } + onClick={() => setAccountDataType(type)} + > + + + {type} + + + + ))} + + +
)}
diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 767a967e..1a618a68 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -7,8 +7,6 @@ import { Chip, color, config, - Icon, - Icons, Input, Spinner, Text, @@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { StateEvent } from '../../../../types/matrix/room'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { getMxIdServer } from '../../../utils/matrix'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; type RoomPublishedAddressesProps = { permissions: RoomPermissionsAPI; @@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); return ( - - setExpand(!expand)} - size="300" - variant="Secondary" - fill="Soft" - outlined - radii="300" - before={ - - } - > - - {expand ? 'Collapse' : 'Expand'} + + {localAliasesState.status === AsyncStatus.Loading && ( + + + Loading... + + )} + {localAliasesState.status === AsyncStatus.Success && + (localAliasesState.data.length === 0 ? ( + + No Addresses + + ) : ( + + ))} + {localAliasesState.status === AsyncStatus.Error && ( + + + {localAliasesState.error.message} - - } - /> - {expand && ( - - {localAliasesState.status === AsyncStatus.Loading && ( - - - Loading... - - )} - {localAliasesState.status === AsyncStatus.Success && - (localAliasesState.data.length === 0 ? ( - - No Addresses - - ) : ( - - ))} - {localAliasesState.status === AsyncStatus.Error && ( - - - {localAliasesState.error.message} - - - )} - - )} + + )} + {expand && } - + ); } diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..fc8e1a7f 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { Account } from './account'; import { useUserProfile } from '../../hooks/useUserProfile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; -import { nameInitials } from '../../utils/common'; import { Notifications } from './notifications'; import { Devices } from './devices'; import { EmojisStickers } from './emojis-stickers'; @@ -99,9 +98,8 @@ type SettingsProps = { export function Settings({ initialPage, requestClose }: SettingsProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const userId = mx.getUserId() as string; const profile = useUserProfile(userId); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {nameInitials(displayName)}} + renderFallback={() => } /> diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index e982a799..71f5773f 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,324 +1,283 @@ -import React, { - ChangeEventHandler, - FormEventHandler, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { - Box, - Text, - IconButton, - Icon, - Icons, - Input, - Avatar, - Button, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, - Dialog, - Header, - config, - Spinner, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { Box, Text, Button, config, Spinner, Line } from 'folds'; +import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; -import { UserAvatar } from '../../../components/user-avatar'; +import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { nameInitials } from '../../../utils/common'; +import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; +import { + ExtendedProfile, + profileEditsAllowed, + useExtendedProfile, +} from '../../../hooks/useExtendedProfile'; +import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useFilePicker } from '../../../hooks/useFilePicker'; -import { useObjectURL } from '../../../hooks/useObjectURL'; -import { stopPropagation } from '../../../utils/keyboard'; -import { ImageEditor } from '../../../components/image-editor'; -import { ModalWide } from '../../../styles/Modal.css'; -import { createUploadAtom, UploadSuccess } from '../../../state/upload'; -import { CompactUploadCardRenderer } from '../../../components/upload-card'; +import { CutoutCard } from '../../../components/cutout-card'; +import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips'; +import { SequenceCardStyle } from '../styles.css'; +import { useUserProfile } from '../../../hooks/useUserProfile'; +import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; +import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; +import { withSearchParam } from '../../../pages/pathUtils'; import { useCapabilities } from '../../../hooks/useCapabilities'; +import { ProfileAvatar } from './fields/ProfileAvatar'; +import { ProfileTextField } from './fields/ProfileTextField'; +import { ProfilePronouns } from './fields/ProfilePronouns'; +import { ProfileTimezone } from './fields/ProfileTimezone'; -type ProfileProps = { - profile: UserProfile; - userId: string; -}; -function ProfileAvatar({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const capabilities = useCapabilities(); - const [alertRemove, setAlertRemove] = useState(false); - const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; +function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { + const accountManagementActions = useAccountManagementActions(); - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl - ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; + const openProviderProfileSettings = useCallback(() => { + const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; + if (!authUrl) return; - const [imageFile, setImageFile] = useState(); - const imageFileURL = useObjectURL(imageFile); - const uploadAtom = useMemo(() => { - if (imageFile) return createUploadAtom(imageFile); - return undefined; - }, [imageFile]); - - const pickFile = useFilePicker(setImageFile, false); - - const handleRemoveUpload = useCallback(() => { - setImageFile(undefined); - }, []); - - const handleUploaded = useCallback( - (upload: UploadSuccess) => { - const { mxc } = upload; - mx.setAvatarUrl(mxc); - handleRemoveUpload(); - }, - [mx, handleRemoveUpload] - ); - - const handleRemoveAvatar = () => { - mx.setAvatarUrl(''); - setAlertRemove(false); - }; + window.open( + withSearchParam(authUrl, { + action: accountManagementActions.profile, + }), + '_blank' + ); + }, [authMetadata, accountManagementActions]); return ( - - Avatar - - } - after={ - - {nameInitials(defaultDisplayName)}} - /> - - } - > - {uploadAtom ? ( - - - - ) : ( - + + pickFile('image/*')} size="300" variant="Secondary" fill="Soft" - outlined radii="300" - disabled={disableSetAvatar} + outlined + onClick={openProviderProfileSettings} > - Upload + Open - {avatarUrl && ( - - )} - - )} - - {imageFileURL && ( - }> - - - - - - - - - )} - - }> - - setAlertRemove(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
- + } + > + Change profile settings in your homeserver's account dashboard. + + ); } -function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const capabilities = useCapabilities(); - const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; +/// Context props which are passed to every field element. +/// Right now this is only a flag for if the profile is being saved. +export type FieldContext = { busy: boolean }; - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const [displayName, setDisplayName] = useState(defaultDisplayName); +/// Field editor elements for the pre-MSC4133 profile fields. This should only +/// ever contain keys for `displayname` and `avatar_url`. +const LEGACY_FIELD_ELEMENTS = { + avatar_url: ProfileAvatar, + displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => ( + + ), +}; - const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) - ); - const changingDisplayName = changeState.status === AsyncStatus.Loading; - - useEffect(() => { - setDisplayName(defaultDisplayName); - }, [defaultDisplayName]); - - const handleChange: ChangeEventHandler = (evt) => { - const name = evt.currentTarget.value; - setDisplayName(name); - }; - - const handleReset = () => { - setDisplayName(defaultDisplayName); - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (changingDisplayName) return; - - const target = evt.target as HTMLFormElement | undefined; - const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; - const name = displayNameInput?.value; - if (!name) return; - - changeDisplayName(name); - }; - - const hasChanges = displayName !== defaultDisplayName; - return ( - - Display Name - - } - > - - - - - - - ) - } - /> - - - - - - ); -} +/// Field editor elements for MSC4133 extended profile fields. +/// These will appear in the UI in the order they are defined in this map. +const EXTENDED_FIELD_ELEMENTS = { + 'io.fsky.nyx.pronouns': ProfilePronouns, + 'us.cloke.msc4175.tz': ProfileTimezone, +}; export function Profile() { const mx = useMatrixClient(); - const userId = mx.getUserId()!; - const profile = useUserProfile(userId); + const userId = mx.getUserId() as string; + const server = getMxIdServer(userId); + const authMetadata = useAuthMetadata(); + const accountManagementActions = useAccountManagementActions(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); + + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const extendedProfileSupported = extendedProfile !== null; + const legacyProfile = useUserProfile(userId); + + // next-gen auth identity providers may provide profile settings if they want + const profileEditableThroughIDP = + authMetadata !== undefined && + authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile); + + const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => { + const entries = Object.entries({ + ...LEGACY_FIELD_ELEMENTS, + // don't show the MSC4133 elements if the HS doesn't support them + ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}), + }).filter(([key]) => + // don't show fields if the HS blocks them with capabilities + profileEditsAllowed(key, capabilities, extendedProfileSupported) + ); + return [Object.fromEntries(entries), entries.length > 0]; + }, [capabilities, extendedProfileSupported]); + + const [fieldDefaults, setFieldDefaults] = useState({ + displayname: legacyProfile.displayName, + avatar_url: legacyProfile.avatarUrl, + }); + + // this updates the field defaults when the extended profile data is (re)loaded. + // it has to be a layout effect to prevent flickering on saves. + // if MSC4133 isn't supported by the HS this does nothing + useLayoutEffect(() => { + // `extendedProfile` includes the old dn/av fields, so + // we don't have to add those here + if (extendedProfile) { + setFieldDefaults(extendedProfile); + } + }, [setFieldDefaults, extendedProfile]); + + const [saveState, handleSave] = useAsyncCallback( + useCallback( + async (fields: ExtendedProfile) => { + if (extendedProfileSupported) { + await Promise.all( + Object.entries(fields).map(async ([key, value]) => { + if (value === undefined) { + await mx.deleteExtendedProfileProperty(key); + } else { + await mx.setExtendedProfileProperty(key, value); + } + }) + ); + + // calling this will trigger the layout effect to update the defaults + // once the profile request completes + await refreshExtendedProfile(); + + // synthesize a profile update for ourselves to update our name and avatar in the rest + // of the UI. code copied from matrix-js-sdk + const user = mx.getUser(userId); + if (user) { + user.displayName = fields.displayname; + user.avatarUrl = fields.avatar_url; + user.emit(UserEvent.DisplayName, user.events.presence, user); + user.emit(UserEvent.AvatarUrl, user.events.presence, user); + } + } else { + await mx.setDisplayName(fields.displayname ?? ''); + await mx.setAvatarUrl(fields.avatar_url ?? ''); + // layout effect does nothing because `extendedProfile` is undefined + // so we have to update the defaults explicitly here + setFieldDefaults(fields); + } + }, + [mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + const loadingExtendedProfile = extendedProfile === undefined; + const busy = saving || loadingExtendedProfile; return ( Profile - - + + {(reset, hasChanges, fields, fieldElements) => { + const heroAvatarUrl = + (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ?? + undefined; + return ( + <> + + + + + + + {server && } + + {fields['us.cloke.msc4175.tz'] && ( + + )} + + + + + {profileEditableThroughIDP && ( + + )} + {profileEditableThroughClient && ( + <> + + {fieldElements} + + + + + {saving && } + + + )} + {!(profileEditableThroughClient || profileEditableThroughIDP) && ( + + + + + Profile Editing Disabled + + + + Your homeserver does not allow you to edit your profile. + + + + + + )} + + + ); + }} + ); diff --git a/src/app/features/settings/account/fields/ProfileAvatar.tsx b/src/app/features/settings/account/fields/ProfileAvatar.tsx new file mode 100644 index 00000000..5c668c56 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileAvatar.tsx @@ -0,0 +1,118 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds'; +import React, { useState, useMemo, useCallback } from 'react'; +import { ImageEditor } from '../../../../components/image-editor'; +import { SettingTile } from '../../../../components/setting-tile'; +import { CompactUploadCardRenderer } from '../../../../components/upload-card'; +import { useFilePicker } from '../../../../hooks/useFilePicker'; +import { useMatrixClient } from '../../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication'; +import { useObjectURL } from '../../../../hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '../../../../state/upload'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { mxcUrlToHttp } from '../../../../utils/matrix'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; +import { ModalWide } from '../../../../styles/Modal.css'; + +export function ProfileAvatar({ + busy, value, setValue, +}: ProfileFieldElementProps<'avatar_url', FieldContext>) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const avatarUrl = value + ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + const disabled = busy; + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + setValue(mxc); + handleRemoveUpload(); + }, + [setValue, handleRemoveUpload] + ); + + const handleRemoveAvatar = () => { + setValue(''); + }; + + return ( + + Avatar + } + > + {uploadAtom ? ( + + + + ) : ( + + + {avatarUrl && ( + + )} + + )} + + {imageFileURL && ( + }> + + + + + + + + + )} + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx new file mode 100644 index 00000000..18f78627 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -0,0 +1,127 @@ +import React, { + FunctionComponent, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { deepCompare } from 'matrix-js-sdk/lib/utils'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; + +/// These types ensure the element functions are actually able to manipulate +/// the profile fields they're mapped to. The generic parameter represents +/// extra "context" props which are passed to every element. + +// strip the index signature from ExtendedProfile using mapped type magic. +// keeping the index signature causes weird typechecking issues further down the line +// plus there should never be field elements passed with keys which don't exist in ExtendedProfile. +type ExtendedProfileKeys = keyof { + [Property in keyof ExtendedProfile as string extends Property + ? never + : Property]: ExtendedProfile[Property]; +}; + +// these are the props which all field elements must accept. +// this is split into `RawProps` and `Props` so we can type `V` instead of +// spraying `ExtendedProfile[K]` all over the place. +// don't use this directly, use the `ProfileFieldElementProps` type instead +type ProfileFieldElementRawProps = { + defaultValue: V; + value: V; + setValue: (value: V) => void; +} & C; + +export type ProfileFieldElementProps< + K extends ExtendedProfileKeys, + C +> = ProfileFieldElementRawProps; + +// the map of extended profile keys to field element functions +type ProfileFieldElements = { + [Property in ExtendedProfileKeys]?: FunctionComponent>; +}; + +type ProfileFieldContextProps = { + fieldDefaults: ExtendedProfile; + fieldElements: ProfileFieldElements; + children: ( + reset: () => void, + hasChanges: boolean, + fields: ExtendedProfile, + fieldElements: ReactNode + ) => ReactNode; + context: C; +}; + +/// This element manages the pending state of the profile field widgets. +/// It takes the default values of each field, as well as a map associating a profile field key +/// with an element _function_ (not a rendered element!) that will be used to edit that field. +/// It renders the editor elements internally using React.createElement and passes the rendered +/// elements into the child UI. This allows it to handle the pending state entirely by itself, +/// and provides strong typechecking. +export function ProfileFieldContext({ + fieldDefaults, + fieldElements: fieldElementConstructors, + children, + context, +}: ProfileFieldContextProps): ReactNode { + const [fields, setFields] = useState(fieldDefaults); + + // this callback also runs when fieldDefaults changes, + // which happens when the profile is saved and the pending fields become the new defaults + const reset = useCallback(() => { + setFields(fieldDefaults); + }, [fieldDefaults]); + + // set the pending values to the defaults on the first render + useEffect(() => { + reset(); + }, [reset]); + + const setField = useCallback( + (key: string, value: unknown) => { + setFields({ + ...fields, + [key]: value, + }); + }, + [fields] + ); + + const hasChanges = useMemo( + () => + Object.entries(fields).find( + ([key, value]) => + // deep comparison is necessary here because field values can be any JSON type + !deepCompare(fieldDefaults[key as keyof ExtendedProfile], value) + ) !== undefined, + [fields, fieldDefaults] + ); + + const createElement = useCallback( + (key: K, element: ProfileFieldElements[K]) => { + const props: ProfileFieldElementRawProps = { + ...context, + defaultValue: fieldDefaults[key], + value: fields[key], + setValue: (value) => setField(key, value), + key, + }; + // element can be undefined if the field defaults didn't include its key, + // which means the HS doesn't support setting that field + if (element !== undefined) { + return React.createElement(element, props); + } + return undefined; + }, + [context, fieldDefaults, fields, setField] + ); + + const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) => + // @ts-expect-error TypeScript doesn't quite understand the magic going on here + createElement(key, element) + ); + + return children(reset, hasChanges, fields, fieldElements); +} diff --git a/src/app/features/settings/account/fields/ProfilePronouns.tsx b/src/app/features/settings/account/fields/ProfilePronouns.tsx new file mode 100644 index 00000000..963a7a94 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfilePronouns.tsx @@ -0,0 +1,125 @@ +import FocusTrap from 'focus-trap-react'; +import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'; +import { SettingTile } from '../../../../components/setting-tile'; +import { stopPropagation } from '../../../../utils/keyboard'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfilePronouns({ + value, setValue, busy, +}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) { + const disabled = busy; + + const [menuCords, setMenuCords] = useState(); + const [pendingPronoun, setPendingPronoun] = useState(''); + + const handleRemovePronoun = (index: number) => { + const newPronouns = [...(value ?? [])]; + newPronouns.splice(index, 1); + if (newPronouns.length > 0) { + setValue(newPronouns); + } else { + setValue(undefined); + } + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + setMenuCords(undefined); + if (pendingPronoun.length > 0) { + setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setMenuCords(undefined); + } + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setPendingPronoun(''); + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + + Pronouns + } + > + + {value?.map(({ summary }, index) => ( + } + onClick={() => handleRemovePronoun(index)} + disabled={disabled} + > + + {summary} + + + ))} + } + onClick={handleOpenMenu} + > + Add + + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + setPendingPronoun(evt.currentTarget.value)} + onKeyDown={handleKeyDown} /> + + + + } /> + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTextField.tsx b/src/app/features/settings/account/fields/ProfileTextField.tsx new file mode 100644 index 00000000..fc96cb69 --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTextField.tsx @@ -0,0 +1,63 @@ +import { Text, Box, Input, IconButton, Icon, Icons } from 'folds'; +import React, { ChangeEventHandler } from 'react'; +import { FilterByValues } from '../../../../../types/utils'; +import { SettingTile } from '../../../../components/setting-tile'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTextField>({ + label, defaultValue, value, setValue, busy, +}: ProfileFieldElementProps & { label: string; }) { + const disabled = busy; + const hasChanges = defaultValue !== value; + + const handleChange: ChangeEventHandler = (evt) => { + const content = evt.currentTarget.value; + if (content.length > 0) { + setValue(evt.currentTarget.value); + } else { + setValue(undefined); + } + }; + + const handleReset = () => { + setValue(defaultValue); + }; + + return ( + + {label} + } + > + + + + + + + )} /> + + + + + ); +} diff --git a/src/app/features/settings/account/fields/ProfileTimezone.tsx b/src/app/features/settings/account/fields/ProfileTimezone.tsx new file mode 100644 index 00000000..6ebea2ac --- /dev/null +++ b/src/app/features/settings/account/fields/ProfileTimezone.tsx @@ -0,0 +1,160 @@ +import FocusTrap from 'focus-trap-react'; +import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds'; +import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react'; +import { CutoutCard } from '../../../../components/cutout-card'; +import { SettingTile } from '../../../../components/setting-tile'; +import { FieldContext } from '../Profile'; +import { ProfileFieldElementProps } from './ProfileFieldContext'; + +export function ProfileTimezone({ + value, setValue, busy, +}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) { + const disabled = busy; + + const inputRef = useRef(null); + const scrollRef = useRef(null); + const [overlayOpen, setOverlayOpen] = useState(false); + const [query, setQuery] = useState(''); + + // @ts-expect-error Intl.supportedValuesOf isn't in the types yet + const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []); + const filteredTimezones = timezones.filter( + (timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase()) + ); + + const handleSelect = useCallback( + (timezone: string) => { + setOverlayOpen(false); + setValue(timezone); + }, + [setOverlayOpen, setValue] + ); + + useEffect(() => { + if (overlayOpen) { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`); + + if (value && focusedItem && scrollView) { + focusedItem.scrollIntoView({ + block: 'center', + }); + } + } + }, [scrollRef, value, overlayOpen]); + + return ( + + Timezone + } + > + }> + + inputRef.current, + allowOutsideClick: true, + clickOutsideDeactivates: true, + onDeactivate: () => setOverlayOpen(false), + escapeDeactivates: (evt) => { + evt.stopPropagation(); + return true; + }, + }} + > + +
+ + Choose a Timezone + + setOverlayOpen(false)} radii="300"> + + +
+ + } + value={query} + onChange={(evt) => setQuery(evt.currentTarget.value)} /> + + {filteredTimezones.length === 0 && ( + + + No Results + + + )} + {filteredTimezones.map((timezone) => ( + } + onClick={() => handleSelect(timezone)} + > + + + {timezone} + + + + ))} + + +
+
+
+
+ + + {value && ( + + )} + +
+ ); +} diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx deleted file mode 100644 index 8bccb62e..00000000 --- a/src/app/features/settings/developer-tools/AccountData.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; -import { CutoutCard } from '../../../components/cutout-card'; - -type AccountDataProps = { - expand: boolean; - onExpandToggle: (expand: boolean) => void; - onSelect: (type: string | null) => void; -}; -export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) { - const mx = useMatrixClient(); - const [accountDataTypes, setAccountDataKeys] = useState(() => - Array.from(mx.store.accountData.keys()) - ); - - useAccountDataCallback( - mx, - useCallback(() => { - setAccountDataKeys(Array.from(mx.store.accountData.keys())); - }, [mx]) - ); - - return ( - - Account Data - - onExpandToggle(!expand)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {expand ? 'Collapse' : 'Expand'} - - } - /> - {expand && ( - - - Events - Total: {accountDataTypes.length} - - - } - onClick={() => onSelect(null)} - > - - - Add New - - - - {accountDataTypes.sort().map((type) => ( - } - onClick={() => onSelect(type)} - > - - - {type} - - - - ))} - - - )} - - - ); -} diff --git a/src/app/features/settings/developer-tools/AccountDataList.tsx b/src/app/features/settings/developer-tools/AccountDataList.tsx new file mode 100644 index 00000000..9c9cbe6a --- /dev/null +++ b/src/app/features/settings/developer-tools/AccountDataList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, MenuItem } from 'folds'; +import { CutoutCard } from '../../../components/cutout-card'; + +type AccountDataListProps = { + types: string[]; + onSelect: (type: string | null) => void; +}; +export function AccountDataList({ + types, + onSelect, +}: AccountDataListProps) { + return ( + + + Fields + Total: {types.length} + + + } + onClick={() => onSelect(null)} + > + + + Add New + + + + {types.sort().map((type) => ( + } + onClick={() => onSelect(type)} + > + + + {type} + + + + ))} + + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..01e6e6a4 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useState } from 'react'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; +import { AccountDataEvents } from 'matrix-js-sdk'; +import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { + AccountDataDeleteCallback, AccountDataEditor, AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; -import { AccountData } from './AccountData'; +import { AccountDataList } from './AccountDataList'; +import { useExtendedProfile } from '../../../hooks/useExtendedProfile'; +import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; + +type DeveloperToolsPage = + | { name: 'index' } + | { name: 'account-data'; type: string | null } + | { name: 'profile-field'; type: string | null }; type DeveloperToolsProps = { requestClose: () => void; }; export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); + const userId = mx.getUserId() as string; + + const [accountDataTypes, setAccountDataKeys] = useState(() => + Array.from(mx.store.accountData.keys()) + ); + const accountDataDeletionSupported = + (mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !== + ServerSupport.Unsupported; + useAccountDataCallback( + mx, + useCallback(() => { + setAccountDataKeys(Array.from(mx.store.accountData.keys())); + }, [mx]) + ); + + const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId); + const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); - const [expand, setExpend] = useState(false); - const [accountDataType, setAccountDataType] = useState(); + const [page, setPage] = useState({ name: 'index' }); + const [globalExpand, setGlobalExpand] = useState(false); + const [profileExpand, setProfileExpand] = useState(false); const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { - await mx.setAccountData(type, content); + await mx.setAccountData(type as keyof AccountDataEvents, content); }, [mx] ); - if (accountDataType !== undefined) { - return ( - setAccountDataType(undefined)} - /> - ); - } - - return ( - - - - - - Developer Tools - - - - - - - - - - - - - - - Options - - - } - /> - - {developerTools && ( - - - copyToClipboard(mx.getAccessToken() ?? '') - } - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - > - Copy - - } - /> - - )} - - {developerTools && ( - - )} - - - - - + const deleteAccountData: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteAccountData(type as keyof AccountDataEvents); + }, + [mx] ); + + const submitProfileField: AccountDataSubmitCallback = useCallback( + async (type, content) => { + await mx.setExtendedProfileProperty(type, content); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] + ); + + const deleteProfileField: AccountDataDeleteCallback = useCallback( + async (type) => { + await mx.deleteExtendedProfileProperty(type); + await refreshExtendedProfile(); + }, + [mx, refreshExtendedProfile] + ); + + const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]); + + switch (page.name) { + case 'account-data': + return ( + + ); + + case 'profile-field': + return ( + + ); + + default: + return ( + + + + + + Developer Tools + + + + + + + + + + + + + + + Options + + + } + /> + + {developerTools && ( + + + copyToClipboard(mx.getAccessToken() ?? '') + } + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Copy + + } + /> + + )} + + {developerTools && ( + + Account Data + + setPage({ name: 'account-data', type })} + /> + + {extendedProfile && ( + + setPage({ name: 'profile-field', type })} + /> + + )} + + )} + + + + + + ); + } } diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..35baa9cb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys'; import { useAlive } from '../../../hooks/useAlive'; import { useFilePicker } from '../../../hooks/useFilePicker'; +import { CollapsibleCard } from '../../../components/CollapsibleCard'; function ExportKeys() { const mx = useMatrixClient(); @@ -121,37 +122,18 @@ function ExportKeys() { ); } -function ExportKeysTile() { +function ExportKeysCard() { const [expand, setExpand] = useState(false); return ( - <> - - -
- } - /> - {expand && } - + + + ); } @@ -304,14 +286,7 @@ export function LocalBackup() { return ( Local Backup - - - + ; + +export function useExtendedProfileSupported(): boolean { + const { versions, unstable_features: unstableFeatures } = useSpecVersions(); + + return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15'); +} + +/// Returns the user's MSC4133 extended profile, if our homeserver supports it. +/// This will return `undefined` while the request is in flight and `null` if the HS lacks support. +export function useExtendedProfile( + userId: string +): [ExtendedProfile | undefined | null, () => Promise] { + const mx = useMatrixClient(); + const extendedProfileSupported = useExtendedProfileSupported(); + const { data, refetch } = useQuery({ + queryKey: ['extended-profile', userId], + queryFn: useCallback(async () => { + if (extendedProfileSupported) { + return extendedProfile.parse(await mx.getExtendedProfile(userId)); + } + return null; + }, [mx, userId, extendedProfileSupported]), + refetchOnMount: false, + }); + + return [ + data, + async () => { + await refetch(); + }, + ]; +} + +const LEGACY_FIELDS = ['displayname', 'avatar_url']; + +/// Returns whether the given profile field may be edited by the user. +export function profileEditsAllowed( + field: string, + capabilities: Capabilities, + extendedProfileSupported: boolean +): boolean { + if (LEGACY_FIELDS.includes(field)) { + // this field might have a pre-msc4133 capability. check that first + if (capabilities[`m.set_${field}`]?.enabled === false) { + return false; + } + + if (!extendedProfileSupported) { + // the homeserver only supports legacy fields + return true; + } + } + + if (extendedProfileSupported) { + // the homeserver has msc4133 support + const extendedProfileCapability = capabilities[ + 'uk.tcpip.msc4133.profile_fields' + ] as IProfileFieldsCapability; + + if (extendedProfileCapability === undefined) { + // the capability is missing, assume modification is allowed + return true; + } + + if (!extendedProfileCapability.enabled) { + // the capability is set to disable profile modifications + return false; + } + + if ( + extendedProfileCapability.allowed !== undefined && + !extendedProfileCapability.allowed.includes(field) + ) { + // the capability includes an allowlist and `field` isn't in it + return false; + } + + if (extendedProfileCapability.disallowed?.includes(field)) { + // the capability includes an blocklist and `field` is in it + return false; + } + + // the capability is enabled and `field` isn't blocked + return true; + } + + // `field` is an extended profile key and the homeserver lacks msc4133 support + return false; +} diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index bb212184..df0515a2 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; -import { Text } from 'folds'; +import { Icon, Icons } from 'folds'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; -import { nameInitials } from '../../../utils/common'; +import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; @@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500'; export function SettingsTab() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; + const userId = mx.getUserId() as string; const profile = useUserProfile(userId); const [settings, setSettings] = useState(false); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -34,7 +32,7 @@ export function SettingsTab() { {nameInitials(displayName)}} + renderFallback={() => } /> )} diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts index 210c711f..2764d6c3 100644 --- a/src/types/matrix/common.ts +++ b/src/types/matrix/common.ts @@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; -export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; +export const MATRIX_SPOILER_REASON_PROPERTY_NAME = + 'page.codeberg.everypizza.msc4193.spoiler.reason'; export type IImageInfo = { w?: number; @@ -88,3 +89,9 @@ export type ILocationContent = { geo_uri?: string; info?: IThumbnailContent; }; + +export type IProfileFieldsCapability = { + enabled?: boolean; + allowed?: string[]; + disallowed?: string[]; +}; diff --git a/src/types/utils.ts b/src/types/utils.ts index 353ace6e..84632c99 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,3 +1,8 @@ export type WithRequiredProp = Type & { [Property in Key]-?: Type[Property]; }; + +// Represents a subset of T containing only the keys whose values extend V +export type FilterByValues = { + [Property in keyof T as T[Property] extends V ? Property : never]: T[Property]; +};