Skip to content

Commit

Permalink
FET-987: new avatar uploader
Browse files Browse the repository at this point in the history
  • Loading branch information
Stanislav Lysak committed Aug 2, 2024
1 parent feeabdc commit 2fa4cf9
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 145 deletions.
5 changes: 4 additions & 1 deletion public/locales/en/transactionFlow.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
167 changes: 61 additions & 106 deletions src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentProps<typeof Dropdown>, 'isOpen' | 'setIsOpen'>

Expand All @@ -100,8 +47,6 @@ type Props = {

const AvatarButton = ({
validated,
dirty,
error,
src,
onSelectOption,
onAvatarChange,
Expand Down Expand Up @@ -129,41 +74,51 @@ const AvatarButton = ({
: ({} as { isOpen: never; setIsOpen: never })

return (
<LegacyDropdown
items={
[
{
label: t('input.profileEditor.tabs.avatar.dropdown.selectNFT'),
color: 'black',
onClick: handleSelectOption('nft'),
},
{
label: t('input.profileEditor.tabs.avatar.dropdown.uploadImage'),
color: 'black',
onClick: handleSelectOption('upload'),
},
...(validated
? [
{
label: t('action.remove', { ns: 'common' }),
color: 'red',
onClick: handleSelectOption('remove'),
},
]
: []),
] as DropdownItem[]
}
keepMenuOnTop
shortThrow
{...dropdownProps}
>
<Container $validated={validated && dirty} $error={error} $dirty={dirty} type="button">
<Container>
<AvatarWrapper>
<Avatar label="profile-button-avatar" src={src} noBorder />
{!validated && !error && (
<IconMask>
<CameraIcon />
</IconMask>
)}
</AvatarWrapper>
<LegacyDropdown
items={
[
{
label: t('input.profileEditor.tabs.avatar.dropdown.selectNFT'),
color: 'black',
onClick: handleSelectOption('nft'),
},
{
label: t('input.profileEditor.tabs.avatar.dropdown.uploadImage'),
color: 'black',
onClick: handleSelectOption('upload'),
},
{
label: t('input.profileEditor.tabs.avatar.dropdown.enterManually'),
color: 'black',
onClick: handleSelectOption('manual'),
},
...(validated
? [
{
label: t('action.remove', { ns: 'common' }),
color: 'red',
onClick: handleSelectOption('remove'),
},
]
: []),
] as DropdownItem[]
}
keepMenuOnTop
shortThrow
{...dropdownProps}
>
<ActionContainer>
<Button disabled colorStyle="accentSecondary">
{src}
</Button>
<Button colorStyle="accentSecondary">
{t('input.profileEditor.tabs.avatar.change')}
</Button>
</ActionContainer>
<input
type="file"
style={{ display: 'none' }}
Expand All @@ -176,8 +131,8 @@ const AvatarButton = ({
}
}}
/>
</Container>
</LegacyDropdown>
</LegacyDropdown>
</Container>
)
}

Expand Down
174 changes: 174 additions & 0 deletions src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('')

const {
mutate: signAndUpload,
isPending,
error,
} = useMutation<void, Error>({
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<string>((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 (
<>
<Dialog.Heading title={t('input.profileEditor.tabs.avatar.dropdown.enterManually')} />
<Dialog.Content>
<Input
label={t('input.profileEditor.tabs.avatar.label')}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Dialog.Content>
{error && (
<Helper data-testid="avatar-upload-error" type="error">
{error.message}
</Helper>
)}
<Dialog.Footer
leading={
<Button colorStyle="accentSecondary" onClick={() => handleCancel()}>
{t('action.back', { ns: 'common' })}
</Button>
}
trailing={
<Button
disabled={isPending || !isValidHttpUrl(value)}
colorStyle={error ? 'redSecondary' : undefined}
onClick={() => signAndUpload()}
>
{error ? t('action.tryAgain', { ns: 'common' }) : t('action.confirm', { ns: 'common' })}
</Button>
}
/>
</>
)
}
Loading

0 comments on commit 2fa4cf9

Please sign in to comment.