diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index d90b8e4b..396e8e8f 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -1,63 +1,31 @@ import React, { - ChangeEventHandler, - FormEventHandler, - KeyboardEventHandler, - MouseEventHandler, useCallback, - useEffect, useLayoutEffect, useMemo, - useRef, useState, } from 'react'; import { Box, Text, - IconButton, - Icon, - Icons, - Input, Button, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, config, Spinner, - toRem, - Dialog, - Header, - MenuItem, - Chip, - PopOut, - RectCords, - Menu, Line, } from 'folds'; -import FocusTrap from 'focus-trap-react'; import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk'; -import { isKeyHotkey } from 'is-hotkey'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -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 { UserHero, UserHeroName } from '../../../components/user-profile/UserHero'; import { ExtendedProfile, profileEditsAllowed, useExtendedProfile, } from '../../../hooks/useExtendedProfile'; -import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext'; +import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { FilterByValues } from '../../../../types/utils'; import { CutoutCard } from '../../../components/cutout-card'; import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips'; import { SequenceCardStyle } from '../styles.css'; @@ -66,467 +34,12 @@ 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 FieldContext = { busy: boolean }; - -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 && ( - }> - - - - - - - - - )} - - ); -} - -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} - - } - > - - - - - - - ) - } - /> - - - - - ); -} - -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} - /> - - - - - } - /> - - ); -} - -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 && ( - - )} - -
- ); -} +export type FieldContext = { busy: boolean }; function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) { const accountManagementActions = useAccountManagementActions(); @@ -600,7 +113,7 @@ export function Profile() { }).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported)); return [Object.fromEntries(entries), entries.length > 0]; }, [capabilities, extendedProfileSupported]); - + const [fieldDefaults, setFieldDefaults] = useState({ displayname: legacyProfile.displayName, avatar_url: legacyProfile.avatarUrl, 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/ProfileFieldContext.tsx b/src/app/features/settings/account/fields/ProfileFieldContext.tsx similarity index 96% rename from src/app/features/settings/account/ProfileFieldContext.tsx rename to src/app/features/settings/account/fields/ProfileFieldContext.tsx index 31241052..0e5da3e6 100644 --- a/src/app/features/settings/account/ProfileFieldContext.tsx +++ b/src/app/features/settings/account/fields/ProfileFieldContext.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; -import { ExtendedProfile } from '../../../hooks/useExtendedProfile'; +import { ExtendedProfile } from '../../../../hooks/useExtendedProfile'; type ExtendedProfileKeys = keyof { [Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property] @@ -61,6 +61,7 @@ export function ProfileFieldContext({ defaultValue: fieldDefaults[key], value: fields[key], setValue: (value) => setField(key, value), + key, }; if (element !== undefined) { return React.createElement(element, props); 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 && ( + + )} + +
+ ); +}