mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
Move profile field elements into their own files
This commit is contained in:
parent
317cd366c3
commit
8a8443bda4
6 changed files with 475 additions and 495 deletions
|
|
@ -1,63 +1,31 @@
|
||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
|
||||||
FormEventHandler,
|
|
||||||
KeyboardEventHandler,
|
|
||||||
MouseEventHandler,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
IconButton,
|
|
||||||
Icon,
|
|
||||||
Icons,
|
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
Overlay,
|
|
||||||
OverlayBackdrop,
|
|
||||||
OverlayCenter,
|
|
||||||
Modal,
|
|
||||||
config,
|
config,
|
||||||
Spinner,
|
Spinner,
|
||||||
toRem,
|
|
||||||
Dialog,
|
|
||||||
Header,
|
|
||||||
MenuItem,
|
|
||||||
Chip,
|
|
||||||
PopOut,
|
|
||||||
RectCords,
|
|
||||||
Menu,
|
|
||||||
Line,
|
Line,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
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 { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
|
||||||
import {
|
import {
|
||||||
ExtendedProfile,
|
ExtendedProfile,
|
||||||
profileEditsAllowed,
|
profileEditsAllowed,
|
||||||
useExtendedProfile,
|
useExtendedProfile,
|
||||||
} from '../../../hooks/useExtendedProfile';
|
} from '../../../hooks/useExtendedProfile';
|
||||||
import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext';
|
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { FilterByValues } from '../../../../types/utils';
|
|
||||||
import { CutoutCard } from '../../../components/cutout-card';
|
import { CutoutCard } from '../../../components/cutout-card';
|
||||||
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
|
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
|
@ -66,467 +34,12 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
import { withSearchParam } from '../../../pages/pathUtils';
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
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 };
|
export 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<File>();
|
|
||||||
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 (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Avatar
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{uploadAtom ? (
|
|
||||||
<Box gap="200" direction="Column">
|
|
||||||
<CompactUploadCardRenderer
|
|
||||||
uploadAtom={uploadAtom}
|
|
||||||
onRemove={handleRemoveUpload}
|
|
||||||
onComplete={handleUploaded}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box gap="200">
|
|
||||||
<Button
|
|
||||||
onClick={() => pickFile('image/*')}
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
outlined
|
|
||||||
radii="300"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Text size="B300">Upload Avatar</Text>
|
|
||||||
</Button>
|
|
||||||
{avatarUrl && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
radii="300"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={handleRemoveAvatar}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove Avatar</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{imageFileURL && (
|
|
||||||
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: handleRemoveUpload,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal className={ModalWide} variant="Surface" size="500">
|
|
||||||
<ImageEditor
|
|
||||||
name={imageFile?.name ?? 'Unnamed'}
|
|
||||||
url={imageFileURL}
|
|
||||||
requestClose={handleRemoveUpload}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
|
||||||
label,
|
|
||||||
defaultValue,
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
busy,
|
|
||||||
}: ProfileFieldElementProps<K, FieldContext> & { label: string }) {
|
|
||||||
const disabled = busy;
|
|
||||||
const hasChanges = defaultValue !== value;
|
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
|
||||||
const content = evt.currentTarget.value;
|
|
||||||
if (content.length > 0) {
|
|
||||||
setValue(evt.currentTarget.value);
|
|
||||||
} else {
|
|
||||||
setValue(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setValue(defaultValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box direction="Column" grow="Yes" gap="100">
|
|
||||||
<Box gap="200" aria-disabled={disabled}>
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
name="displayNameInput"
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="Secondary"
|
|
||||||
radii="300"
|
|
||||||
disabled={disabled}
|
|
||||||
readOnly={disabled}
|
|
||||||
after={
|
|
||||||
hasChanges &&
|
|
||||||
!busy && (
|
|
||||||
<IconButton
|
|
||||||
type="reset"
|
|
||||||
onClick={handleReset}
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
variant="Secondary"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfilePronouns({
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
busy,
|
|
||||||
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
|
|
||||||
const disabled = busy;
|
|
||||||
|
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
|
||||||
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<HTMLFormElement> = (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setMenuCords(undefined);
|
|
||||||
if (pendingPronoun.length > 0) {
|
|
||||||
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
|
||||||
if (isKeyHotkey('escape', evt)) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
setMenuCords(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
|
|
||||||
setPendingPronoun('');
|
|
||||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Pronouns
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
|
||||||
{value?.map(({ summary }, index) => (
|
|
||||||
<Chip
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
key={index}
|
|
||||||
variant="Secondary"
|
|
||||||
radii="Pill"
|
|
||||||
after={<Icon src={Icons.Cross} size="100" />}
|
|
||||||
onClick={() => handleRemovePronoun(index)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Text size="T200" truncate>
|
|
||||||
{summary}
|
|
||||||
</Text>
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
<Chip
|
|
||||||
variant="Secondary"
|
|
||||||
radii="Pill"
|
|
||||||
disabled={disabled}
|
|
||||||
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
|
|
||||||
onClick={handleOpenMenu}
|
|
||||||
>
|
|
||||||
<Text size="T200">Add</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
<PopOut
|
|
||||||
anchor={menuCords}
|
|
||||||
offset={5}
|
|
||||||
position="Right"
|
|
||||||
align="Center"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
onDeactivate: () => setMenuCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
style={{
|
|
||||||
padding: config.space.S200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
|
|
||||||
<Input
|
|
||||||
variant="Secondary"
|
|
||||||
placeholder="they/them"
|
|
||||||
inputSize={10}
|
|
||||||
radii="300"
|
|
||||||
size="300"
|
|
||||||
outlined
|
|
||||||
value={pendingPronoun}
|
|
||||||
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="300"
|
|
||||||
variant="Success"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="100" src={Icons.Plus} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Add</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileTimezone({
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
busy,
|
|
||||||
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
|
|
||||||
const disabled = busy;
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Timezone
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: () => inputRef.current,
|
|
||||||
allowOutsideClick: true,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
onDeactivate: () => setOverlayOpen(false),
|
|
||||||
escapeDeactivates: (evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog variant="Surface">
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
|
||||||
}}
|
|
||||||
variant="Surface"
|
|
||||||
size="500"
|
|
||||||
>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="H4">Choose a Timezone</Text>
|
|
||||||
</Box>
|
|
||||||
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Header>
|
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
size="500"
|
|
||||||
variant="Background"
|
|
||||||
radii="400"
|
|
||||||
outlined
|
|
||||||
placeholder="Search"
|
|
||||||
before={<Icon size="200" src={Icons.Search} />}
|
|
||||||
value={query}
|
|
||||||
onChange={(evt) => setQuery(evt.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
|
|
||||||
{filteredTimezones.length === 0 && (
|
|
||||||
<Box
|
|
||||||
style={{ paddingTop: config.space.S700 }}
|
|
||||||
grow="Yes"
|
|
||||||
alignItems="Center"
|
|
||||||
justifyContent="Center"
|
|
||||||
direction="Column"
|
|
||||||
gap="100"
|
|
||||||
>
|
|
||||||
<Text size="H6" align="Center">
|
|
||||||
No Results
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{filteredTimezones.map((timezone) => (
|
|
||||||
<MenuItem
|
|
||||||
key={timezone}
|
|
||||||
data-tz={timezone}
|
|
||||||
variant={timezone === value ? 'Success' : 'Surface'}
|
|
||||||
fill={timezone === value ? 'Soft' : 'None'}
|
|
||||||
size="300"
|
|
||||||
radii="0"
|
|
||||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
|
||||||
onClick={() => handleSelect(timezone)}
|
|
||||||
>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="T200" truncate>
|
|
||||||
{timezone}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</CutoutCard>
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
<Box gap="200">
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
outlined
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => setOverlayOpen(true)}
|
|
||||||
after={<Icon size="100" src={Icons.ChevronRight} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">{value ?? 'Set Timezone'}</Text>
|
|
||||||
</Button>
|
|
||||||
{value && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
radii="300"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => setValue(undefined)}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove Timezone</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
|
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
|
||||||
const accountManagementActions = useAccountManagementActions();
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
|
|
||||||
118
src/app/features/settings/account/fields/ProfileAvatar.tsx
Normal file
118
src/app/features/settings/account/fields/ProfileAvatar.tsx
Normal file
|
|
@ -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<File>();
|
||||||
|
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 (
|
||||||
|
<SettingTile
|
||||||
|
title={<Text as="span" size="L400">
|
||||||
|
Avatar
|
||||||
|
</Text>}
|
||||||
|
>
|
||||||
|
{uploadAtom ? (
|
||||||
|
<Box gap="200" direction="Column">
|
||||||
|
<CompactUploadCardRenderer
|
||||||
|
uploadAtom={uploadAtom}
|
||||||
|
onRemove={handleRemoveUpload}
|
||||||
|
onComplete={handleUploaded} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
onClick={() => pickFile('image/*')}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload Avatar</Text>
|
||||||
|
</Button>
|
||||||
|
{avatarUrl && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
>
|
||||||
|
<Text size="B300">Remove Avatar</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageFileURL && (
|
||||||
|
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleRemoveUpload,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal className={ModalWide} variant="Surface" size="500">
|
||||||
|
<ImageEditor
|
||||||
|
name={imageFile?.name ?? 'Unnamed'}
|
||||||
|
url={imageFileURL}
|
||||||
|
requestClose={handleRemoveUpload} />
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
|
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
|
||||||
|
|
||||||
type ExtendedProfileKeys = keyof {
|
type ExtendedProfileKeys = keyof {
|
||||||
[Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]
|
[Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]
|
||||||
|
|
@ -61,6 +61,7 @@ export function ProfileFieldContext<C>({
|
||||||
defaultValue: fieldDefaults[key],
|
defaultValue: fieldDefaults[key],
|
||||||
value: fields[key],
|
value: fields[key],
|
||||||
setValue: (value) => setField(key, value),
|
setValue: (value) => setField(key, value),
|
||||||
|
key,
|
||||||
};
|
};
|
||||||
if (element !== undefined) {
|
if (element !== undefined) {
|
||||||
return React.createElement(element, props);
|
return React.createElement(element, props);
|
||||||
125
src/app/features/settings/account/fields/ProfilePronouns.tsx
Normal file
125
src/app/features/settings/account/fields/ProfilePronouns.tsx
Normal file
|
|
@ -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<RectCords>();
|
||||||
|
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<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setMenuCords(undefined);
|
||||||
|
if (pendingPronoun.length > 0) {
|
||||||
|
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (isKeyHotkey('escape', evt)) {
|
||||||
|
evt.stopPropagation();
|
||||||
|
setMenuCords(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
|
||||||
|
setPendingPronoun('');
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title={<Text as="span" size="L400">
|
||||||
|
Pronouns
|
||||||
|
</Text>}
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||||
|
{value?.map(({ summary }, index) => (
|
||||||
|
<Chip
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={index}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
after={<Icon src={Icons.Cross} size="100" />}
|
||||||
|
onClick={() => handleRemovePronoun(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{summary}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={disabled}
|
||||||
|
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
>
|
||||||
|
<Text size="T200">Add</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
content={<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
|
||||||
|
<Input
|
||||||
|
variant="Secondary"
|
||||||
|
placeholder="they/them"
|
||||||
|
inputSize={10}
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
outlined
|
||||||
|
value={pendingPronoun}
|
||||||
|
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="100" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Add</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>} />
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
||||||
|
label, defaultValue, value, setValue, busy,
|
||||||
|
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
|
||||||
|
const disabled = busy;
|
||||||
|
const hasChanges = defaultValue !== value;
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const content = evt.currentTarget.value;
|
||||||
|
if (content.length > 0) {
|
||||||
|
setValue(evt.currentTarget.value);
|
||||||
|
} else {
|
||||||
|
setValue(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setValue(defaultValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title={<Text as="span" size="L400">
|
||||||
|
{label}
|
||||||
|
</Text>}
|
||||||
|
>
|
||||||
|
<Box direction="Column" grow="Yes" gap="100">
|
||||||
|
<Box gap="200" aria-disabled={disabled}>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
name="displayNameInput"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={disabled}
|
||||||
|
after={hasChanges &&
|
||||||
|
!busy && (
|
||||||
|
<IconButton
|
||||||
|
type="reset"
|
||||||
|
onClick={handleReset}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/features/settings/account/fields/ProfileTimezone.tsx
Normal file
160
src/app/features/settings/account/fields/ProfileTimezone.tsx
Normal file
|
|
@ -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<HTMLInputElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<SettingTile
|
||||||
|
title={<Text as="span" size="L400">
|
||||||
|
Timezone
|
||||||
|
</Text>}
|
||||||
|
>
|
||||||
|
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: () => inputRef.current,
|
||||||
|
allowOutsideClick: true,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: () => setOverlayOpen(false),
|
||||||
|
escapeDeactivates: (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Choose a Timezone</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
size="500"
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
outlined
|
||||||
|
placeholder="Search"
|
||||||
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(evt) => setQuery(evt.currentTarget.value)} />
|
||||||
|
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
|
||||||
|
{filteredTimezones.length === 0 && (
|
||||||
|
<Box
|
||||||
|
style={{ paddingTop: config.space.S700 }}
|
||||||
|
grow="Yes"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Text size="H6" align="Center">
|
||||||
|
No Results
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{filteredTimezones.map((timezone) => (
|
||||||
|
<MenuItem
|
||||||
|
key={timezone}
|
||||||
|
data-tz={timezone}
|
||||||
|
variant={timezone === value ? 'Success' : 'Surface'}
|
||||||
|
fill={timezone === value ? 'Soft' : 'None'}
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||||
|
onClick={() => handleSelect(timezone)}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" truncate>
|
||||||
|
{timezone}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</CutoutCard>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOverlayOpen(true)}
|
||||||
|
after={<Icon size="100" src={Icons.ChevronRight} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{value ?? 'Set Timezone'}</Text>
|
||||||
|
</Button>
|
||||||
|
{value && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setValue(undefined)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Remove Timezone</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue