mirror of
https://github.com/MetaFam/TheGame.git
synced 2026-01-13 08:37:53 -05:00
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
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:
|
||
'It’s 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>
|
||
);
|
||
};
|