mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 14:30: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, {
|
||||
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<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>
|
||||
);
|
||||
}
|
||||
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<ExtendedProfile>({
|
||||
displayname: legacyProfile.displayName,
|
||||
avatar_url: legacyProfile.avatarUrl,
|
||||
|
|
|
|||
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 { 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<C>({
|
|||
defaultValue: fieldDefaults[key],
|
||||
value: fields[key],
|
||||
setValue: (value) => setField(key, value),
|
||||
key,
|
||||
};
|
||||
if (element !== undefined) {
|
||||
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