Files
TheGame/packages/web/components/EditProfileModal.tsx
2023-04-20 10:09:34 -04:00

605 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-console */
import {
Box,
Button,
FormControl,
FormErrorMessage,
Grid,
GridItem,
InfoIcon,
Input,
InputGroup,
InputLeftElement,
InputRightAddon,
ITimezoneOption,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
SelectTimeZone,
StatusedSubmitButton,
Text,
Tooltip,
useToast,
Wrap,
WrapItem,
} from '@metafam/ds';
import {
ComposeDBImageMetadata,
getMimeType,
HasuraImageFieldKey,
hasuraImageFields,
isHasuraImageField,
profileMapping,
} from '@metafam/utils';
import {
Maybe,
Player,
useInsertCacheInvalidationMutation,
} from 'graphql/autogen/types';
import { getPlayer } from 'graphql/getPlayer';
import { PlayerProfile } from 'graphql/types';
import { useWeb3 } from 'lib/hooks';
import { useSaveToComposeDB } from 'lib/hooks/ceramic/useSaveToComposeDB';
import React, {
createRef,
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { errorHandler } from 'utils/errorHandler';
import { getImageDimensions } from 'utils/imageHelpers';
import { isEmpty } from 'utils/objectHelpers';
import { hasuraToComposeDBProfile } from 'utils/playerHelpers';
import { uploadFiles } from 'utils/uploadHelpers';
import { ConnectToProgress } from './ConnectToProgress';
import MeetWithWalletProfileEdition from './Player/MeetWithWalletProfileEdition';
import { EditAvatarImage } from './Player/Profile/EditAvatarImage';
import { EditBackgroundImage } from './Player/Profile/EditBackgroundImage';
import { EditDescription } from './Player/Profile/EditDescription';
import { Label } from './Player/Profile/Label';
// Start work here
type EditProfileFields = {
profileImageURL?: Maybe<string>;
backgroundImageURL?: Maybe<string>;
description?: Maybe<string>;
username?: Maybe<string>;
name?: Maybe<string>;
timeZone?: Maybe<string>;
availableHours?: Maybe<number>;
pronouns?: Maybe<string>;
website?: Maybe<string>;
location?: Maybe<string>;
emoji?: Maybe<string>;
};
const getDefaultFormValues = (player: Player): EditProfileFields => {
if (!player.profile) {
return {} as EditProfileFields;
}
return Object.fromEntries(
Object.entries(player.profile).filter(([key]) =>
Object.keys(profileMapping).includes(key),
),
);
};
export type EditProfileModalProps = {
player: Player;
isOpen: boolean;
onClose: () => void;
onSave: (ceramicStreamID: string) => void;
};
export const EditProfileModal: React.FC<EditProfileModalProps> = ({
player,
isOpen,
onClose,
onSave,
}) => {
const [status, setStatus] = useState<Maybe<ReactElement | string>>();
const username = player.profile?.username;
const { save } = useSaveToComposeDB();
const [, invalidateCache] = useInsertCacheInvalidationMutation();
const initialFormValues = useMemo(
() => getDefaultFormValues(player),
[player],
);
const formMethods = useForm({
defaultValues: initialFormValues,
});
const {
handleSubmit,
register,
setValue,
control,
reset,
formState: { errors, dirtyFields, isDirty },
} = formMethods;
const { chainId } = useWeb3();
const toast = useToast();
const [pickedFiles, setPickedFiles] = useState<
Partial<Record<HasuraImageFieldKey, File>>
>({});
const [pickedFileDataURLs, setPickedFileDataURLs] = useState<
Partial<Record<HasuraImageFieldKey, string>>
>({});
const resetData = useCallback(() => {
reset(initialFormValues);
setPickedFiles({});
setPickedFileDataURLs({});
}, [initialFormValues, reset]);
useEffect(resetData, [resetData]);
const imageFieldRefs = Object.fromEntries(
hasuraImageFields.map((key) => [key, createRef<HTMLImageElement>()]),
);
if (!save) {
toast({
title: 'Ceramic Connection Error',
description: 'Unable to connect to the Ceramic API to save changes.',
status: 'error',
isClosable: true,
duration: 8000,
});
onClose();
return null;
}
const onSubmit = async (inputs: EditProfileFields) => {
try {
if (!isDirty) {
setStatus('No changes detected. Skipping save…');
setTimeout(() => onClose, 500);
return null;
}
const formData = new FormData();
const changedInputs = Object.fromEntries(
Object.entries(inputs).filter(([key]) => !isHasuraImageField(key)),
);
const profile: PlayerProfile = { ...changedInputs };
const profileImages = Object.fromEntries(
hasuraImageFields.map((field) => [field, null]),
) as Record<HasuraImageFieldKey, Maybe<ComposeDBImageMetadata>>;
const toType = (key: string) => {
const match = key.match(/^(.+?)(Image)?(URL)$/i);
const [name] = match?.slice(1) ?? ['unknown'];
return name;
};
if (Object.keys(pickedFiles).length > 0) {
setStatus('Uploading images to web3.storage…');
// Upload all the files to /api/storage
Object.entries(pickedFiles).forEach(([key, file]) => {
formData.append(toType(key), file);
});
const response = await uploadFiles(formData);
await Promise.all(
Object.entries(pickedFileDataURLs).map(async ([key, val]) => {
const tKey = toType(key);
if (!response[tKey]) {
toast({
title: 'Error Saving Image',
description: `Uploaded "${tKey}" & didn't get a response back.`,
status: 'warning',
isClosable: true,
duration: 8000,
});
} else {
setStatus('Calculating image metadata…');
const mime = getMimeType(val);
const file = pickedFiles[key as HasuraImageFieldKey];
const imageMetadata = {
url: `ipfs://${response[tKey]}`,
mimeType: mime,
size: file?.size,
} as ComposeDBImageMetadata;
const { width, height } = await getImageDimensions(val);
if (width && height) {
imageMetadata.width = width;
imageMetadata.height = height;
}
profileImages[key as HasuraImageFieldKey] = imageMetadata;
}
}),
);
}
setStatus('Saving to Ceramic…');
const payload = hasuraToComposeDBProfile(profile, profileImages);
const ceramicStreamID = await save(payload);
if (player) {
setStatus('Invalidating Cache…');
await invalidateCache({ playerId: player.id });
}
// if they changed their username, the page will 404 on reload
if (player && inputs.username !== username) {
window.history.replaceState(
null,
`${inputs.name ?? inputs.username}s MetaGame Profile`,
`/player/${player.ethereumAddress}`,
);
}
onSave(ceramicStreamID);
return onClose();
} catch (err) {
toast({
title: 'Ceramic Error',
description: `Error saving profile: ${(err as Error).message}`,
status: 'error',
isClosable: true,
duration: 15000,
});
errorHandler(err as Error);
return null;
} finally {
setStatus(null);
}
};
if (chainId !== '0x1') {
return (
<Modal {...{ isOpen, onClose }}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Wrong Chain</ModalHeader>
<ModalCloseButton />
<ModalBody>
<ConnectToProgress header="" />
</ModalBody>
</ModalContent>
</Modal>
);
}
return (
<Modal {...{ isOpen, onClose }}>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader>Profile</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormProvider {...formMethods}>
<Grid
templateColumns={['auto', 'auto', '1fr 1fr', '1fr 1fr 1fr']}
gap={6}
>
<GridItem flex={1} alignItems="center" h="10em">
<EditAvatarImage
ref={imageFieldRefs.profileImageURL}
initialURL={initialFormValues.profileImageURL}
onFilePicked={({ file, dataURL }) => {
setPickedFiles({ ...pickedFiles, profileImageURL: file });
setPickedFileDataURLs({
...pickedFileDataURLs,
profileImageURL: dataURL,
});
}}
/>
</GridItem>
<GridItem flex={1} alignItems="center" h="10em">
<EditBackgroundImage
player={player}
ref={imageFieldRefs.profileBackgroundURL}
initialURL={initialFormValues.backgroundImageURL}
onFilePicked={({ file, dataURL }) => {
setPickedFiles({
...pickedFiles,
backgroundImageURL: file,
});
setPickedFileDataURLs({
...pickedFileDataURLs,
backgroundImageURL: dataURL,
});
}}
/>
</GridItem>
<GridItem flex={1}>
<EditDescription />
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.name}>
<Tooltip label="Arbitrary letters, spaces, & punctuation. Max 150 characters.">
<Label htmlFor="name" userSelect="none">
Display Name
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Input
w="100%"
placeholder="Imma User"
{...register('name', {
maxLength: {
value: 150,
message: 'Maximum length is 150 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.name?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.username}>
<Tooltip label="Lowercase alpha, digits, dashes, & underscores only.">
<Label htmlFor="username" userSelect="none">
Name
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Input
w="100%"
placeholder="i-am-a-user"
{...register('username', {
validate: async (value) => {
if (value && /0x[0-9a-z]{40}/i.test(value)) {
return `Name "${value}" has the same format as an Ethereum address.`;
}
if (
value &&
value !== username &&
(await getPlayer(value))
) {
return `Name "${value}" is already in use.`;
}
return true;
},
pattern: {
value: /^[a-z0-9-_]+$/,
message:
'Only lowercase letters, digits, dashes, & underscores allowed.',
},
minLength: {
value: 3,
message: 'Must have at least three characters.',
},
maxLength: {
value: 150,
message: 'Maximum length is 150 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.username?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.timeZone}>
<Label htmlFor="name">Time Zone</Label>
<Controller
{...{ control }}
name="timeZone"
defaultValue={
Intl.DateTimeFormat().resolvedOptions().timeZone
}
render={({ field: { onChange, value, ref, ...props } }) => (
<SelectTimeZone
labelStyle="abbrev"
onChange={(tz: ITimezoneOption) => {
onChange(tz.value);
}}
value={value ?? undefined}
{...props}
/>
)}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.timeZone?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.availableHours}>
<Label htmlFor="availableHours">Availability</Label>
<InputGroup w="100%">
<InputLeftElement>
<Text as="span" role="img" aria-label="clock">
🕛
</Text>
</InputLeftElement>
<Input
flex={1}
w="100%"
type="number"
placeholder="23"
pl={10}
minW={20}
maxW="100%"
borderTopEndRadius={0}
borderBottomEndRadius={0}
borderRight={0}
{...register('availableHours', {
valueAsNumber: true,
min: {
value: 0,
message:
'Its not possible to be available for negative time!',
},
max: {
value: 24 * 7,
message: `There are only ${24 * 7} hours in a week!`,
},
})}
/>
<InputRightAddon background="purpleBoxDark" color="white">
<Text as="sup">hr</Text> <Text as="sub">week</Text>
</InputRightAddon>
</InputGroup>
<Box minH="3em">
<FormErrorMessage>
{errors.availableHours?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.pronouns}>
<Label htmlFor="pronouns">Pronouns</Label>
<Input
w="100%"
id="pronouns"
placeholder="He, she, it, they, them, etc."
{...register('pronouns', {
maxLength: {
value: 150,
message: 'Maximum length is 150 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.pronouns?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.website}>
<Label htmlFor="name">Website</Label>
<Input
w="100%"
id="website"
placeholder="https://github.com/jane-user"
{...register('website', {
pattern: {
value: /^(ipfs|https?):(\/\/)?.+$/i,
message: 'URL must be IPFS, HTTP or HTTPS.',
},
maxLength: {
value: 240,
message: 'Maximum length is 240 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.website?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.location}>
<Label htmlFor="location">Location</Label>
<Input
id="location"
w="100%"
placeholder="Laniakea Supercluster"
{...register('location', {
maxLength: {
value: 140,
message: 'Maximum length is 140 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.location?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.emoji}>
<Label htmlFor="emoji">Spirit Emoji</Label>
<Input
id="emoji"
placeholder="🗽"
_placeholder={{ opacity: 0.75 }}
minW="inherit"
w="100%"
{...register('emoji', {
maxLength: {
value: 2,
message: 'Maximum length is 2 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>
{errors.emoji?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem gridColumn={'1/-1'} alignItems="center">
<FormControl>
<Label>Meeting calendar</Label>
<MeetWithWalletProfileEdition
setValue={setValue}
player={player}
/>
</FormControl>
</GridItem>
</Grid>
</FormProvider>
</ModalBody>
<ModalFooter mt={6} flex={1} justifyContent="center">
<Wrap justify="center" align="center" flex={1}>
<WrapItem>
<StatusedSubmitButton
isDisabled={isEmpty(dirtyFields)}
label="Save Changes"
{...{ status }}
/>
</WrapItem>
<WrapItem>
<Button
variant="ghost"
onClick={() => {
onClose();
resetData();
}}
color="white"
_hover={{ bg: '#FFFFFF11' }}
_active={{ bg: '#FF000011' }}
disabled={!!status}
>
Close
</Button>
</WrapItem>
</Wrap>
</ModalFooter>
</ModalContent>
</Modal>
);
};