Files
TheGame/packages/web/components/EditProfileForm.tsx
dan13ram 7bc99d4b45 Fix Onboarding + A Bunch of Other Issues (#1181)
* feat: metamask switch network support + fixed dependancy cycle

* feat: moved landing to index

* feat: updated favicon

* fix: fixed landing page issues + scrollSnap

* feat: join button

* fix: fixed seed script with new prod schema

* feat: join button redirects based on user creation date

* fix: minor ui bug fixes

* feat: connect to mainnet to continue with switch network on metamask

* fix: uniform setup screens

* fix: fixed XP on dashboard

* feat: added start page

* fix: fixed issues on landing page

* fix: fixed minor issues on dashboard

* fix: update idx profile in auth webhook for new players

* fix: minor fixes in seed page

* fix: player avatar & type

* fix: incorporated review comments from @dysbulic & @vidvidvid

* fix: more review comments
2022-03-07 10:20:26 -05:00

878 lines
27 KiB
TypeScript
Raw 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 { Caip10Link } from '@ceramicnetwork/stream-caip10-link';
import { ImageSources } from '@datamodels/identity-profile-basic';
import {
Box,
BoxProps,
Button,
Center,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
FormLabelProps,
Image,
InfoIcon,
Input as ChakraInput,
InputGroup,
InputLeftElement,
InputProps,
InputRightAddon,
Link,
MetaButton,
MetaHeading,
ModalFooter,
motion,
SelectTimeZone,
Spinner,
Stack,
Text,
Textarea,
Tooltip,
useToast,
Wrap,
WrapItem,
} from '@metafam/ds';
import {
AllProfileFields,
HasuraProfileProps,
Images,
Optional,
} from '@metafam/utils';
import FileOpenIcon from 'assets/file-open-icon.svg';
import PlayerProfileIcon from 'assets/player-profile-icon.svg';
import {
Maybe,
Player,
useInsertCacheInvalidationMutation,
} from 'graphql/autogen/types';
import { getPlayer } from 'graphql/getPlayer';
import { useProfileField, useSaveCeramicProfile, useWeb3 } from 'lib/hooks';
import { useRouter } from 'next/router';
import React, {
ReactElement,
RefObject,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Controller, useForm } from 'react-hook-form';
import { optimizedImage } from 'utils/imageHelpers';
import { isEmpty } from 'utils/objectHelpers';
import { ConnectToProgress } from './ConnectToProgress';
const MAX_DESC_LEN = 420; // characters
export type ProfileEditorProps = {
player?: Maybe<Player>;
onClose: () => void;
};
const Label: React.FC<FormLabelProps> = React.forwardRef(
({ children, ...props }, container) => {
const ref = container as RefObject<HTMLLabelElement>;
return (
<FormLabel color="cyan" {...{ ref }} {...props}>
{children}
</FormLabel>
);
},
);
const Input = React.forwardRef<typeof ChakraInput, InputProps>(
({ children, ...props }, fwdRef) => {
const [width, setWidth] = useState('9em');
const ref = fwdRef as RefObject<HTMLInputElement>;
const textRef = useRef<HTMLParagraphElement>(null);
const isText = !props.type || props.type === 'text';
const calcWidth = useCallback((text?: string) => {
const layout = textRef.current;
const modal = layout?.closest('form');
if (layout && modal && text) {
layout.textContent = text;
const widths = [
`calc(${modal.clientWidth}px - 2rem)`,
`calc(${layout.scrollWidth}px + 2.25em)`,
];
setWidth(`min(${widths.join(',')})`);
}
}, []);
const recalcText = (event: SyntheticEvent<HTMLInputElement>) => {
if (isText) {
const {
currentTarget: { value },
} = event;
calcWidth(value);
}
};
return (
<Box>
<Text
position="absolute"
visibility="hidden"
whiteSpace="pre"
ref={textRef}
></Text>
<ChakraInput
color="white"
bg="dark"
minW="9rem"
_autofill={{
'&, &:hover, &:focus, &:active': {
WebkitBoxShadow:
'0 0 0 2em var(--chakra-colors-dark) inset !important',
WebkitTextFillColor: 'white !important',
caretColor: 'white',
},
}}
onInput={recalcText}
onFocus={recalcText}
{...{ width, ref }}
{...props}
>
{children}
</ChakraInput>
</Box>
);
},
);
export type Merge<P, T> = Omit<P, keyof T> & T;
export const MotionBox = motion<BoxProps>(Box);
export const PulseHoverBox: React.FC<{ duration?: number }> = ({
// duration = 2,
children,
}) => (
<MotionBox
whileHover={{
scale: 1.2,
// transition: { duration },
}}
whileTap={{ scale: 0.9 }}
>
{children}
</MotionBox>
);
export const EditProfileForm: React.FC<ProfileEditorProps> = ({
player,
onClose,
}) => {
const [status, setStatus] = useState<Maybe<ReactElement | string>>();
// eslint-disable-next-line react-hooks/exhaustive-deps
const username = useMemo(() => player?.profile?.username, []);
const params = useRouter();
const debug = !!params.query.debug;
const saveToCeramic = useSaveCeramicProfile({ debug, setStatus });
const [, invalidateCache] = useInsertCacheInvalidationMutation();
const {
handleSubmit,
register,
setValue,
control,
watch,
formState: { errors, dirtyFields },
} = useForm();
const { ceramic, address, chainId } = useWeb3();
const toast = useToast();
const description = watch('description');
const remaining = useMemo(() => MAX_DESC_LEN - (description?.length ?? 0), [
description,
]);
const fields = Object.fromEntries(
Object.keys(AllProfileFields).map((key) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { value } = useProfileField({
field: key,
player,
});
return [key, value];
}),
);
const endpoints = Object.fromEntries(
Object.keys(Images).map((hasuraId) => {
const key = hasuraId as keyof typeof Images;
// eslint-disable-next-line react-hooks/rules-of-hooks
const [active, setActive] = useState(false);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loading, setLoading] = useState(true);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [url, setURL] = useState<Optional<string>>(
optimizedImage(key, fields[key]),
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [file, setFile] = useState<Maybe<File>>(null);
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = useRef<HTMLImageElement>(null);
// key ends in “URL”
return [
key,
{
loading,
active,
val: url,
file,
ref,
setLoading,
setActive,
setURL,
setFile,
},
];
}),
);
if (debug) {
console.debug({ fields, endpoints });
}
useEffect(() => {
if (!endpoints.profileImageURL.ref.current) {
console.warn('Unable to initially focus the profile image.');
} else {
endpoints.profileImageURL.ref.current.focus();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
Object.entries(fields).forEach(([key, value]) => {
if (!key.startsWith('_')) {
setValue(key, value ?? undefined);
}
});
}, [setValue]); // eslint-disable-line react-hooks/exhaustive-deps
const onFileChange = useCallback(
({ target: input }: { target: HTMLInputElement }) => {
const file = input.files?.[0];
if (!file) return;
const key = input.name as keyof typeof endpoints;
endpoints[key].setLoading(true);
endpoints[key].setFile(file);
const reader = new FileReader();
reader.addEventListener('load', () => {
endpoints[key].setURL(reader.result as string);
});
reader.readAsDataURL(file);
},
[endpoints],
);
if (!ceramic || !saveToCeramic) {
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: HasuraProfileProps) => {
try {
if (isEmpty(dirtyFields)) {
return onClose();
}
if (!ceramic.did?.authenticated) {
setStatus(<Text>Authenticating DID</Text>);
await ceramic.did?.authenticate();
}
if (debug) {
console.debug(`For ETH Address: ${address}`);
console.debug(`Connected DID: ${ceramic.did?.id}`);
const caip10 = await Caip10Link.fromAccount(
ceramic,
`${address}@eip155:1`,
);
console.debug(`CAIP-10 DID: ${caip10.did}`);
}
setStatus(
<Text>
Uploading images to
<Link href="//web3.storage" ml={1}>
web3.storage
</Link>
</Text>,
);
const formData = new FormData();
const files: Record<string, File> = {};
const images: Record<string, ImageSources> = {};
const values = { ...inputs };
Object.keys(Images).forEach((hasuraId) => {
const key = hasuraId as keyof typeof Images;
if (endpoints[key].file) {
files[key] = endpoints[key].file as File;
}
delete values[key];
});
if (debug) {
console.debug({ inputs, values, files, endpoints });
}
const toType = (key: string) => {
const match = key.match(/^(.+?)(Image)?(URL)$/i);
const [name] = match?.slice(1) ?? ['unknown'];
return name;
};
if (Object.keys(files).length > 0) {
Object.entries(files).forEach(([key, file]) => {
formData.append(toType(key), file);
});
const result = await fetch(`/api/storage`, {
method: 'POST',
body: formData,
credentials: 'include',
});
const response = await result.json();
const { error } = response;
if (result.status >= 400 || error) {
throw new Error(
`web3.storage ${result.status} response: "${
error ?? result.statusText
}"`,
);
}
Object.keys(files).forEach((key: string) => {
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 {
const { val, ref } = endpoints[key];
let [, mime] = val?.match(/^data:([^;]+);/) ?? [];
mime ??= 'image/*';
const elem = ref.current as HTMLImageElement | null;
const props: { width?: number; height?: number } = {};
['width', 'height'].forEach((prop) => {
props[prop as 'width' | 'height'] = Math.max(
elem?.[
`natural${prop[0].toUpperCase()}${prop.slice(1)}` as
| 'naturalWidth'
| 'naturalHeight'
] ?? 0,
elem?.[prop as 'width' | 'height'] ?? 0,
1,
);
});
images[key as keyof typeof Images] = {
original: {
src: `ipfs://${response[tKey]}`,
mimeType: mime,
...props,
},
} as ImageSources;
}
});
}
if (debug) {
console.debug({ files, values, inputs, dirtyFields });
}
Object.keys(values).forEach((hasuraId) => {
const key = hasuraId as keyof HasuraProfileProps;
if (!dirtyFields[key]) {
if (debug) {
console.info(`Removing Unchanged Value [${key}]: “${values[key]}`);
}
delete values[key];
}
});
await saveToCeramic({ values, images });
if (player) {
setStatus(<Text>Invalidating Cache</Text>);
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}`,
);
}
return onClose();
} catch (err) {
toast({
title: 'Ceramic Error',
description: `Error saving profile: ${(err as Error).message}`,
status: 'error',
isClosable: true,
duration: 15000,
});
return null;
} finally {
setStatus(null);
}
};
if (chainId !== '0x1') {
return <ConnectToProgress />;
}
return (
<Stack as="form" onSubmit={handleSubmit(onSubmit)} maxW="full">
<MetaHeading mb={8} textAlign="center" color="white">
Profile
</MetaHeading>
<Wrap>
<WrapItem flex={1} px={5}>
<FormControl isInvalid={errors.profileImageURL} align="center">
<Tooltip label="An image representing the user generally cropped to a circle for display. 1MiB maximum size.">
<Label htmlFor="profileImageURL" userSelect="none">
Profile Image
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Center position="relative">
<Box w="10em" h="10em" borderRadius="full" display="inline-flex">
<PulseHoverBox>
<Image
ref={endpoints.profileImageURL.ref ?? null}
onLoad={() => {
endpoints.profileImageURL.setLoading(false);
}}
display={
endpoints.profileImageURL.loading ? 'none' : 'inherit'
}
src={endpoints.profileImageURL.val}
borderRadius="full"
objectFit="cover"
h="full"
w="full"
border="2px solid"
borderColor={
endpoints.profileImageURL.active
? 'blue.400'
: 'transparent'
}
/>
</PulseHoverBox>
<Center>
{endpoints.profileImageURL.loading &&
(endpoints.profileImageURL.val == null ? (
<Image maxW="50%" src={PlayerProfileIcon} opacity={0.5} />
) : (
<Spinner size="xl" color="purple.500" thickness="4px" />
))}
</Center>
</Box>
<Controller
{...{ control }}
name="profileImageURL"
defaultValue={[]}
render={({ field: { onChange, value, ...props } }) => (
<Input
{...props}
type="file"
value={value?.filename ?? ''}
onChange={async (evt) => {
onChange(evt.target.files);
onFileChange(evt);
}}
minW="100% !important"
minH="100%"
position="absolute"
top={0}
bottom={0}
left={0}
right={0}
opacity={0}
onFocus={() => endpoints.profileImageURL.setActive(true)}
onBlur={() => endpoints.profileImageURL.setActive(false)}
/>
)}
/>
</Center>
<FormErrorMessage>
{errors.profileImageURL?.message}
</FormErrorMessage>
</FormControl>
</WrapItem>
{[
{
key: 'bannerImageURL',
title: 'Header Banner',
description:
'An image with an ~3:1 aspect ratio to be displayed as a page or profile banner. 1MiB maximum size.',
},
{
key: 'backgroundImageURL',
title: 'Page Background',
description:
'An image with an ~1:1 aspect ratio to be the page background. 1MiB maximum size.',
},
].map(({ key, title, description: spec }) => (
<WrapItem flex={1} px={5} {...{ key }}>
<FormControl isInvalid={errors[key]} align="center">
<Tooltip label={spec}>
<Label htmlFor={key} userSelect="none" whiteSpace="nowrap">
{title}
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Center
position="relative"
maxW="12em"
h="10em"
border="2px solid"
borderColor={endpoints[key].active ? 'blue.400' : 'transparent'}
>
<Image
ref={endpoints[key].ref ?? null}
onLoad={() => {
endpoints[key].setLoading(false);
}}
display={endpoints[key].loading ? 'none' : 'inherit'}
src={endpoints[key].val}
h="full"
w="full"
/>
{endpoints[key].loading &&
(endpoints[key].val == null ? (
<Image maxW="50%" src={FileOpenIcon} opacity={0.5} />
) : (
<Spinner size="xl" color="purple.500" thickness="4px" />
))}
<Controller
control={control}
name={key}
defaultValue={[]}
render={({ field: { onChange, value, ...props } }) => (
<Input
type="file"
{...props}
value={value?.filename}
onChange={async (evt) => {
onChange(evt.target.files);
onFileChange(evt);
}}
maxW="100%"
minH="100%"
position="absolute"
top={0}
bottom={0}
left={0}
right={0}
opacity={0}
onFocus={() => endpoints[key].setActive(true)}
onBlur={() => endpoints[key].setActive(false)}
/>
)}
/>
</Center>
<FormErrorMessage>{errors[key]?.message}</FormErrorMessage>
</FormControl>
</WrapItem>
))}
<WrapItem flex={1} px={5}>
<FormControl isInvalid={errors.description}>
<Tooltip label={`${MAX_DESC_LEN} characters max.`}>
<Label htmlFor="description" userSelect="none">
Description
<Text as="sup" ml={2}>
{remaining}
</Text>
<Text as="sub">{MAX_DESC_LEN}</Text>
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Textarea
placeholder="Describe yourself."
minW="min(18em, calc(100vw - 2rem))"
h="10em"
color="white"
bg="dark"
{...register('description', {
maxLength: {
value: 420,
message: 'Maximum length is 420 characters.',
},
})}
/>
<FormErrorMessage>{errors.description?.message}</FormErrorMessage>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<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
placeholder="Imma User"
{...register('name', {
maxLength: {
value: 150,
message: 'Maximum length is 150 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.username}>
<Tooltip label="Lowercase alpha, digits, dashes, & underscores only.">
<Label htmlFor="username" userSelect="none">
Username
<InfoIcon ml={2} />
</Label>
</Tooltip>
<Input
placeholder="i-am-a-user"
{...register('username', {
validate: async (value) => {
if (/0x[0-9a-z]{40}/i.test(value)) {
return `Username "${value}" has the same format as an Ethereum address.`;
}
if (value !== username && (await getPlayer(value))) {
return `Username "${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}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.pronouns}>
<Label htmlFor="pronouns">Pronouns</Label>
<Input
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}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
{/*
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={HALF}>
<CountrySelectDropdown country={COUNTRIES_OPTIONS[0]} />
</GridItem>
*/}
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.availableHours}>
<Label htmlFor="availableHours">Availability</Label>
<InputGroup borderColor="white">
<InputLeftElement>
<Text as="span" role="img" aria-label="clock">
🕛
</Text>
</InputLeftElement>
<Input
type="number"
placeholder="23"
pl={9}
minW={20}
maxW={22}
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: `Theres 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}
</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5} minW="20rem">
<FormControl isInvalid={errors.timeZone}>
<Label htmlFor="name">Time Zone</Label>
<Controller
{...{ control }}
name="timeZone"
defaultValue={Intl.DateTimeFormat().resolvedOptions().timeZone}
render={({ field: { onChange, ref, ...props } }) => (
<SelectTimeZone
labelStyle="abbrev"
onChange={(tz) => {
onChange(tz.value);
}}
{...props}
/>
)}
/>
<Box minH="3em">
<FormErrorMessage>{errors.timeZone?.message}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.website}>
<Label htmlFor="name">Website</Label>
<Input
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}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.location}>
<Label htmlFor="location">Location</Label>
<Input
id="location"
placeholder="Laniakea Supercluster"
{...register('location', {
maxLength: {
value: 140,
message: 'Maximum length is 140 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>{errors.location?.message}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
<WrapItem flex={1} alignItems="center" px={5}>
<FormControl isInvalid={errors.emoji}>
<Label htmlFor="emoji">Spirit Emoji</Label>
<Input
id="emoji"
placeholder="🗽"
minW="inherit"
maxW="4em"
{...register('emoji', {
maxLength: {
value: 2,
message: 'Maximum length is 2 characters.',
},
})}
/>
<Box minH="3em">
<FormErrorMessage>{errors.emoji?.message}</FormErrorMessage>
</Box>
</FormControl>
</WrapItem>
</Wrap>
{/*
<ProfileField title="working hours" placeholder="9am - 10pm" />
*/}
{onClose && (
<ModalFooter mt={6} flex={1} justifyContent="center">
<Wrap justify="center" align="center" flex={1}>
<WrapItem>
<MetaButton isDisabled={!!status} type="submit">
{!status ? (
'Save Changes'
) : (
<Flex align="center">
<Spinner mr={3} />
{typeof status === 'string' ? (
<Text>{status}</Text>
) : (
status
)}
</Flex>
)}
</MetaButton>
</WrapItem>
<WrapItem>
<Button
variant="ghost"
onClick={onClose}
color="white"
_hover={{ bg: '#FFFFFF11' }}
_active={{ bg: '#FF000011' }}
disabled={!!status}
>
Close
</Button>
</WrapItem>
</Wrap>
</ModalFooter>
)}
</Stack>
);
};