diff --git a/public/locales/en/transactionFlow.json b/public/locales/en/transactionFlow.json index d61128554..425e81d08 100644 --- a/public/locales/en/transactionFlow.json +++ b/public/locales/en/transactionFlow.json @@ -3,9 +3,12 @@ "profileEditor": { "tabs": { "avatar": { + "change": "Change avatar", + "label": "Avatar", "dropdown": { "selectNFT": "Select NFT", - "uploadImage": "Upload Image" + "uploadImage": "Upload Image", + "enterManually": "Enter manually" }, "nft": { "title": "Select an NFT", diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx index 31b270ab0..b9c1fa7ad 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx @@ -2,88 +2,35 @@ import { ComponentProps, Dispatch, SetStateAction, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { Avatar, Dropdown } from '@ensdomains/thorin' +import { Avatar, Button, Dropdown } from '@ensdomains/thorin' import { DropdownItem } from '@ensdomains/thorin/dist/types/components/molecules/Dropdown/Dropdown' -import CameraIcon from '@app/assets/Camera.svg' import { LegacyDropdown } from '@app/components/@molecules/LegacyDropdown/LegacyDropdown' -const Container = styled.button<{ $error?: boolean; $validated?: boolean; $dirty?: boolean }>( - ({ theme, $validated, $dirty, $error }) => css` - position: relative; - width: 90px; - height: 90px; - border-radius: 50%; - background-color: ${theme.colors.backgroundPrimary}; - cursor: pointer; - - ::after { - content: ''; - position: absolute; - background-color: transparent; - width: 16px; - height: 16px; - border: 2px solid transparent; - box-sizing: border-box; - border-radius: 50%; - right: 0; - top: 0; - transform: translate(-20%, 20%) scale(0.2); - transition: all 0.3s ease-out; - } - - ${$dirty && - css` - :after { - background-color: ${theme.colors.blue}; - border-color: ${theme.colors.backgroundPrimary}; - transform: translate(-20%, 20%) scale(1); - } - `} - - ${$validated && - css` - :after { - background-color: ${theme.colors.green}; - border-color: ${theme.colors.backgroundPrimary}; - transform: translate(-20%, 20%) scale(1); - } - `} - - ${$error && - css` - :after { - background-color: ${theme.colors.red}; - border-color: ${theme.colors.backgroundPrimary}; - transform: translate(-20%, 20%) scale(1); - } - `} +const ActionContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space[2]}; `, ) -const IconMask = styled.div( +const Container = styled.div( ({ theme }) => css` - position: absolute; - top: 0; - left: 0; - width: 90px; - height: 90px; - border-radius: 50%; display: flex; align-items: center; - justify-content: center; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)); - border: 4px solid ${theme.colors.grey}; - overflow: hidden; + gap: ${theme.space[4]}; + `, +) - svg { - width: 40px; - display: block; - } +const AvatarWrapper = styled.div( + () => css` + width: 120px; + height: 120px; `, ) -export type AvatarClickType = 'upload' | 'nft' +export type AvatarClickType = 'upload' | 'nft' | 'manual' type PickedDropdownProps = Pick, 'isOpen' | 'setIsOpen'> @@ -100,8 +47,6 @@ type Props = { const AvatarButton = ({ validated, - dirty, - error, src, onSelectOption, onAvatarChange, @@ -129,41 +74,51 @@ const AvatarButton = ({ : ({} as { isOpen: never; setIsOpen: never }) return ( - - + + - {!validated && !error && ( - - - - )} + + + + + + - - + + ) } diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx new file mode 100644 index 000000000..dc4d4856c --- /dev/null +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx @@ -0,0 +1,174 @@ +/* eslint-disable no-multi-assign */ + +import { sha256 } from '@noble/hashes/sha256' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { bytesToHex } from 'viem' +import { useAccount, useSignTypedData } from 'wagmi' + +import { Button, Dialog, Helper, Input } from '@ensdomains/thorin' + +import { useChainName } from '@app/hooks/chain/useChainName' + +type AvatarUploadResult = + | { + message: string + } + | { + error: string + status: number + } + +type AvatarManualProps = { + name: string + handleCancel: () => void + handleSubmit: (type: 'manual', uri: string, display?: string) => void +} + +function isValidHttpUrl(value: string) { + let url + + try { + url = new URL(value) + } catch (_) { + return false + } + + return url.protocol === 'http:' || url.protocol === 'https:' +} + +const dataURLToBytes = (dataURL: string) => { + const base64 = dataURL.split(',')[1] + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + return bytes +} + +export function AvatarManual({ name, handleCancel, handleSubmit }: AvatarManualProps) { + const { t } = useTranslation('transactionFlow') + const queryClient = useQueryClient() + const chainName = useChainName() + + const { address } = useAccount() + const { signTypedDataAsync } = useSignTypedData() + const [value, setValue] = useState('') + + const { + mutate: signAndUpload, + isPending, + error, + } = useMutation({ + mutationFn: async () => { + let baseURL = process.env.NEXT_PUBLIC_AVUP_ENDPOINT || `https://euc.li` + if (chainName !== 'mainnet') { + baseURL = `${baseURL}/${chainName}` + } + const endpoint = `${baseURL}/${name}` + + const dataURL = await fetch(value) + .then((res) => res.blob()) + .then((blob) => { + return new Promise((res) => { + const reader = new FileReader() + + reader.onload = (e) => { + if (e.target) res(e.target.result as string) + } + + reader.readAsDataURL(blob) + }) + }) + + const urlHash = bytesToHex(sha256(dataURLToBytes(dataURL))) + const expiry = `${Date.now() + 1000 * 60 * 60 * 24 * 7}` + + const sig = await signTypedDataAsync({ + primaryType: 'Upload', + domain: { + name: 'Ethereum Name Service', + version: '1', + }, + types: { + Upload: [ + { name: 'upload', type: 'string' }, + { name: 'expiry', type: 'string' }, + { name: 'name', type: 'string' }, + { name: 'hash', type: 'string' }, + ], + }, + message: { + upload: 'avatar', + expiry, + name, + hash: urlHash, + }, + }) + const fetched = (await fetch(endpoint, { + method: 'PUT', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + expiry, + dataURL, + sig, + unverifiedAddress: address, + }), + }).then((res) => res.json())) as AvatarUploadResult + + if ('message' in fetched && fetched.message === 'uploaded') { + queryClient.invalidateQueries({ + predicate: (query) => { + const { + queryKey: [params], + } = query + if (params !== 'ensAvatar') return false + return true + }, + }) + return handleSubmit('manual', endpoint, value) + } + + if ('error' in fetched) { + throw new Error(fetched.error) + } + + throw new Error('Unknown error') + }, + }) + + return ( + <> + + + setValue(e.target.value)} + /> + + {error && ( + + {error.message} + + )} + handleCancel()}> + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarViewManager.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarViewManager.tsx index 486f0307e..b0881b7bf 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarViewManager.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarViewManager.tsx @@ -1,20 +1,27 @@ +import { AvatarManual } from './AvatarManual' import { AvatarNFT } from './AvatarNFT' import { AvatarUpload } from './AvatarUpload' +type AvatarViewManagerType = 'upload' | 'manual' | 'nft' + export const AvatarViewManager = ({ type, avatarFile, ...props }: { handleCancel: () => void - handleSubmit: (type: 'upload' | 'nft', uri: string, display?: string) => void + handleSubmit: (type: AvatarViewManagerType, uri: string, display?: string) => void name: string avatarFile?: File - type: 'upload' | 'nft' + type: AvatarViewManagerType }) => { - return type === 'upload' ? ( - - ) : ( - - ) + if (type === 'upload') { + return + } + + if (type === 'manual') { + return + } + + return } diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx b/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx index bbabfbad7..7c346bea0 100644 --- a/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx @@ -208,7 +208,7 @@ const Profile = ({ name, callback, registrationData, resolverExists }: Props) => avatarFile={avatarFile} handleCancel={() => setModalOpen(false)} type={_modalOption} - handleSubmit={(type: 'nft' | 'upload', uri: string, display?: string) => { + handleSubmit={(type, uri, display) => { setAvatar(uri) setAvatarSrc(display) setModalOpen(false) diff --git a/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx b/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx index 57823fc13..789bd897c 100644 --- a/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx +++ b/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx @@ -33,13 +33,6 @@ import { getResolverWrapperAwareness } from '@app/utils/utils' import ResolverWarningOverlay from './ResolverWarningOverlay' import { WrappedAvatarButton } from './WrappedAvatarButton' -const AvatarWrapper = styled.div( - () => css` - display: flex; - justify-content: center; - `, -) - const ButtonContainer = styled.div( ({ theme }) => css` display: flex; @@ -112,7 +105,9 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr const { t } = useTranslation('register') const formRef = useRef(null) - const [view, setView] = useState<'editor' | 'upload' | 'nft' | 'addRecord' | 'warning'>('editor') + const [view, setView] = useState< + 'editor' | 'upload' | 'manual' | 'nft' | 'addRecord' | 'warning' + >('editor') const { name = '', resumable = false } = data @@ -144,6 +139,7 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr // Update profile records if transaction data exists const [isRecordsUpdated, setIsRecordsUpdated] = useState(false) + useEffect(() => { const updateProfileRecordsWithTransactionData = () => { const transaction = transactions.find( @@ -239,6 +235,13 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr if (isLoading || resolverStatus.isLoading || !isRecordsUpdated) return + const handleConfirm = (uri: string, display?: string) => { + setAvatar(uri) + setAvatarSrc(display) + setView('editor') + trigger() + } + return ( <> {match(view) @@ -254,17 +257,15 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr })} alwaysShowDividers={{ bottom: true }} > - - setView(option)} - onAvatarChange={(avatar) => setAvatar(avatar)} - onAvatarFileChange={(file) => setAvatarFile(file)} - onAvatarSrcChange={(src) => setAvatarSrc(src)} - /> - + setView(option)} + onAvatarChange={(avatar) => setAvatar(avatar)} + onAvatarFileChange={(file) => setAvatarFile(file)} + onAvatarSrcChange={(src) => setAvatarSrc(src)} + /> {profileRecords.map((field, index) => field.group === 'custom' ? ( setView('editor')} type="upload" - handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { - setAvatar(uri) - setAvatarSrc(display) - setView('editor') - trigger() + handleSubmit={(type, uri, display) => { + handleConfirm(uri, display) + }} + /> + )) + .with('manual', () => ( + setView('editor')} + type="manual" + handleSubmit={(type, uri, display) => { + handleConfirm(uri, display) }} /> )) @@ -398,11 +407,8 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr avatarFile={avatarFile} handleCancel={() => setView('editor')} type="nft" - handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { - setAvatar(uri) - setAvatarSrc(display) - setView('editor') - trigger() + handleSubmit={(type, uri, display) => { + handleConfirm(uri, display) }} /> )) diff --git a/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx b/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx index 69f17ff0f..7f382b96a 100644 --- a/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx +++ b/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx @@ -1,7 +1,10 @@ import { ComponentProps } from 'react' import { Control, useFormState } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { useEnsAvatar } from 'wagmi' +import { Field } from '@ensdomains/thorin' + import AvatarButton from '@app/components/@molecules/ProfileEditor/Avatar/AvatarButton' import { ProfileEditorForm } from '@app/hooks/useProfileEditorForm' import { ensAvatarConfig } from '@app/utils/query/ipfsGateway' @@ -12,6 +15,8 @@ type Props = { } & Omit, 'validated'> export const WrappedAvatarButton = ({ control, name, src, ...props }: Props) => { + const { t } = useTranslation('transactionFlow') + const { data: avatar } = useEnsAvatar({ ...ensAvatarConfig, name }) const formState = useFormState({ control, @@ -20,7 +25,10 @@ export const WrappedAvatarButton = ({ control, name, src, ...props }: Props) => const isValidated = !!src || !!avatar const isDirty = !!formState.dirtyFields.avatar const currentOrUpdatedSrc = isDirty ? src : (avatar as string | undefined) + return ( - + + + ) }