Files
TheGame/packages/web/components/EditProfileModal.tsx
2024-08-13 19:04:31 -04:00

558 lines
18 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.
import {
Box,
Button,
FormControl,
FormErrorMessage,
FormHelperText,
Grid,
GridItem,
Input,
InputGroup,
InputLeftElement,
InputRightAddon,
ITimezoneOption,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
SelectTimeZone,
StatusedSubmitButton,
Text,
useToast,
Wrap,
WrapItem,
} from '@metafam/ds';
import {
ComposeDBImageMetadata,
getMimeType,
HasuraImageFieldKey,
hasuraImageFields,
isHasuraImageField,
profileMapping,
} from '@metafam/utils';
import {
Maybe,
Player,
useInsertCacheInvalidationMutation,
} from 'graphql/autogen/hasura-sdk';
import React, {
createRef,
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { getPlayer } from '#graphql/getPlayer';
import { PlayerProfile } from '#graphql/types';
import { useWeb3 } from '#lib/hooks';
import { useSaveToComposeDB } from '#lib/hooks/ceramic/useSaveToComposeDB';
import { errorHandler } from '#utils/errorHandler';
import { getImageDimensions } from '#utils/imageHelpers';
import { isEmpty } from '#utils/objectHelpers';
import { hasuraToComposeDBProfile } from '#utils/playerHelpers';
import { ConnectToProgress } from './ConnectToProgress';
import { EditAvatarImage } from './Player/Profile/EditAvatarImage';
import { EditBackgroundImage } from './Player/Profile/EditBackgroundImage';
import { EditDescription } from './Player/Profile/EditDescription';
import { Label } from './Player/Profile/Label';
import MeetWithWalletProfileEdition from './Player/Profile/MeetWithWalletProfileEdition';
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 ?? {};
const { save } = useSaveToComposeDB();
const [, invalidateCache] = useInsertCacheInvalidationMutation();
const { w3storage, chainId } = useWeb3();
const initialFormValues = useMemo(
() => getDefaultFormValues(player),
[player],
);
const formMethods = useForm({
defaultValues: initialFormValues,
mode: 'onTouched',
});
const {
handleSubmit,
register,
watch,
control,
reset,
formState: { errors, dirtyFields, isDirty },
} = formMethods;
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 MAX_NAME_LEN = 150; // characters
const displayName = watch('name');
const nameRemaining = useMemo(
() => MAX_NAME_LEN - (displayName?.length ?? 0),
[displayName],
);
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 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>>;
if (Object.keys(pickedFiles).length > 0) {
setStatus('Uploading images to web3.storage…');
const rootCID = await w3storage?.uploadDirectory(
Object.values(pickedFiles),
);
await Promise.all(
Object.entries(pickedFileDataURLs).map(async ([key, val]) => {
setStatus('Calculating image metadata…');
const file = pickedFiles[key as HasuraImageFieldKey];
if (!file) {
throw new Error(`No \`file\` for "${key}".`);
}
const imageMetadata = {
url: `ipfs://${rootCID}/${file.name}`,
mimeType: getMimeType(val),
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 !== 10) {
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>Edit Profile</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormProvider {...formMethods}>
<Grid templateColumns="1fr" gap={6} p={[0, 8]}>
<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">
<FormControl isInvalid={!!errors.name}>
<Label htmlFor="name" userSelect="none">
Display Name
</Label>
<FormHelperText pb={3} color="white">
Arbitrary letters, spaces, & punctuation.
</FormHelperText>
<Input
w="100%"
placeholder="e.g., Meta Player 10x!"
{...register('name', {
required: 'We have to identify you somehow! 😱',
maxLength: {
value: 150,
message: 'Maximum length is 150 characters.',
},
})}
/>
<FormHelperText color="white">
{nameRemaining} characters left.
</FormHelperText>
<Box>
<FormErrorMessage>
{errors.name?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.username}>
<Label htmlFor="username" userSelect="none">
Profile URL
</Label>
<FormHelperText pb={3} color="white">
Lowercase alpha, digits, dashes, & underscores only.
</FormHelperText>
<InputGroup w="100%">
<InputLeftElement
pointerEvents="none"
children="https://metagame.wtf/players/"
width="auto"
paddingLeft="1em"
color="whiteAlpha.700"
/>
<Input
w="100%"
flex={1}
minW={20}
maxW="100%"
pl={250}
placeholder="e.g., meta_player-10x"
{...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.',
},
})}
/>
</InputGroup>
<Box>
<FormErrorMessage>
{errors.username?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem flex={1}>
<EditDescription />
</GridItem>
<GridItem flex={1} alignItems="center">
<FormControl isInvalid={!!errors.availableHours}>
<Label htmlFor="availableHours">Availability</Label>
<FormHelperText pb={3} color="white">
What is your weekly availability for any kind of freelance
work?
</FormHelperText>
<InputGroup w="5em">
<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>
<FormErrorMessage>
{errors.availableHours?.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>
<FormErrorMessage>
{errors.timeZone?.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 URL</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>
<FormErrorMessage>
{errors.website?.message?.toString()}
</FormErrorMessage>
</Box>
</FormControl>
</GridItem>
<GridItem gridColumn={'1/-1'} alignItems="center">
<FormControl>
<Label>Meeting Calendar</Label>
<MeetWithWalletProfileEdition {...{ player }} />
</FormControl>
</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>
</Grid>
</FormProvider>
</ModalBody>
<ModalFooter mt={6} flex={1} justifyContent="center">
<Wrap justify="center" align="center" flex={1}>
<WrapItem>
<StatusedSubmitButton
isDisabled={isEmpty(dirtyFields)}
label="Save"
{...{ status }}
/>
</WrapItem>
<WrapItem>
<Button
variant="ghost"
onClick={() => {
onClose();
resetData();
}}
color="white"
_hover={{ bg: '#FFFFFF11' }}
_active={{ bg: '#FF000011' }}
disabled={!!status}
>
Cancel
</Button>
</WrapItem>
</Wrap>
</ModalFooter>
</ModalContent>
</Modal>
);
};