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; backgroundImageURL?: Maybe; description?: Maybe; username?: Maybe; name?: Maybe; timeZone?: Maybe; availableHours?: Maybe; pronouns?: Maybe; website?: Maybe; location?: Maybe; emoji?: Maybe; }; 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 = ({ player, isOpen, onClose, onSave, }) => { const [status, setStatus] = useState>(); 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> >({}); const [pickedFileDataURLs, setPickedFileDataURLs] = useState< Partial> >({}); 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()]), ); 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>; 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 ( Wrong Chain ); } return ( Edit Profile { setPickedFiles({ ...pickedFiles, profileImageURL: file }); setPickedFileDataURLs({ ...pickedFileDataURLs, profileImageURL: dataURL, }); }} /> Arbitrary letters, spaces, & punctuation. {nameRemaining} characters left. {errors.name?.message?.toString()} Lowercase alpha, digits, dashes, & underscores only. { 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.', }, })} /> {errors.username?.message?.toString()} What is your weekly availability for any kind of freelance work? 🕛 hrweek {errors.availableHours?.message?.toString()} ( { onChange(tz.value); }} value={value ?? undefined} {...props} /> )} /> {errors.timeZone?.message?.toString()} {/* {errors.pronouns?.message?.toString()} */} {errors.website?.message?.toString()} { setPickedFiles({ ...pickedFiles, backgroundImageURL: file, }); setPickedFileDataURLs({ ...pickedFileDataURLs, backgroundImageURL: dataURL, }); }} /> ); };