Skip to content

Commit

Permalink
Adds a ENS address selector to the Profile page (#367)
Browse files Browse the repository at this point in the history
* add base ens name selector in Profile

* fix styles

* fix codegen schema error

* handle error for xdai

* shorten address in dropdown

* only show the address selector on mainnet

* Fix code style issues with Prettier

* handle account change

* parse ens name from qs

* bump graphql-request

* show full address when there are no ens names

* disable dropdown if there are no ens names

* add rinkeby subgraph

* remove dropdown when there's no ens names

* shorten always referral address

* prevent returning an invalid state due to a loading variable

* Update src/custom/hooks/useParseReferralQueryParam.ts

Co-authored-by: Leandro Boscariol <[email protected]>

* fix wrong utils path

* merge useEffects hooks

Co-authored-by: Agustín Longoni <[email protected]>
Co-authored-by: Lint Action <[email protected]>
Co-authored-by: Leandro Boscariol <[email protected]>
  • Loading branch information
4 people authored Apr 18, 2022
1 parent 08e7b07 commit d983da0
Show file tree
Hide file tree
Showing 11 changed files with 332 additions and 17 deletions.
6 changes: 5 additions & 1 deletion codegen.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
overrideExisting: true
schema: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3'
schema:
[
'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3',
'https://api.thegraph.com/subgraphs/name/ensdomains/ens',
]
documents: 'src/**/!(*.d).{ts,tsx}'
generates:
./src/state/data/generated.ts:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"graphql": "^15.5.0",
"graphql-request": "^3.4.0",
"graphql-request": "^4.2.0",
"inter-ui": "^3.13.1",
"jest-styled-components": "^7.0.5",
"microbundle": "^0.13.3",
Expand Down
1 change: 0 additions & 1 deletion src/custom/components/AffiliateStatusCheck/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ export default function AffiliateStatusCheck() {
return
}

setAffiliateState(null)
setError('')

if (!account) {
Expand Down
26 changes: 16 additions & 10 deletions src/custom/hooks/useParseReferralQueryParam.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo } from 'react'
import { isAddress } from '@ethersproject/address'
import { useMemo, useState } from 'react'
import useParsedQueryString from 'hooks/useParsedQueryString'
import { REFERRAL_QUERY_PARAM } from 'hooks/useReferralLink'
import useENS from 'hooks/useENS'
import { isAddress } from 'utils'

type ReferralQueryValue = {
value: string
Expand All @@ -13,20 +14,25 @@ type ReferralQueryValue = {
*/
export default function useParseReferralQueryParam(): ReferralQueryValue {
const parsedQs = useParsedQueryString()
const referralAddress = parsedQs[REFERRAL_QUERY_PARAM] as string
const result = useENS(referralAddress)
const [loading, setLoading] = useState(isAddress(referralAddress) === false) // this is a hack to force a initial loading state to true in case of referralAddress is a ens name because the useENS hook returns loading as false when initialized

const referral = useMemo(() => {
const referralAddress = parsedQs[REFERRAL_QUERY_PARAM]
if (typeof referralAddress === 'string' && isAddress(referralAddress)) {
return { value: referralAddress, isValid: true }
if (loading || result.loading || !referralAddress) {
if (result.loading) {
setLoading(false)
}
return null
}

if (referralAddress) {
console.warn('Invalid referral address')
return { value: '', isValid: false }
if (result.address) {
return { value: result.address, isValid: true }
}

return null
}, [parsedQs])
console.warn('Invalid referral address')
return { value: '', isValid: false }
}, [result.loading, result.address, referralAddress, loading])

return referral
}
216 changes: 216 additions & 0 deletions src/custom/pages/Profile/AddressSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import styled, { css } from 'styled-components/macro'
import { Check, ChevronDown } from 'react-feather'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useActiveWeb3React } from 'hooks/web3'
import { ensNames } from './ens'
import { useAddress } from 'state/affiliate/hooks'
import { updateAddress } from 'state/affiliate/actions'
import { useAppDispatch } from 'state/hooks'
import { isAddress, shortenAddress } from 'utils'

type AddressSelectorProps = {
address: string
}

export default function AddressSelector(props: AddressSelectorProps) {
const { address } = props
const dispatch = useAppDispatch()
const selectedAddress = useAddress()
const { chainId, library } = useActiveWeb3React()
const [open, setOpen] = useState(false)
const [items, setItems] = useState<string[]>([address])
const toggle = useCallback(() => setOpen((open) => !open), [])
const node = useRef<HTMLDivElement>(null)
useOnClickOutside(node, open ? toggle : undefined)

const tryShortenAddress = useCallback((item?: string) => {
if (!item) {
return item
}

try {
return shortenAddress(item)
} catch (error) {
return item
}
}, [])

const handleSelectItem = useCallback(
(item: string) => {
dispatch(updateAddress(item))
toggle()
},
[dispatch, toggle]
)

useEffect(() => {
if (!chainId) {
return
}

ensNames(chainId, address).then((response) => {
if ('error' in response) {
console.info(response.error)
setItems([address])
return
}
setItems([...response, address])
})
}, [address, chainId])

useEffect(() => {
// if the user switches accounts, reset the selected address
const switchedAccounts = isAddress(selectedAddress) && selectedAddress !== address
if (switchedAccounts || !selectedAddress) {
dispatch(updateAddress(address))
return
}

// the selected address is a ens name, verify that resolves to the correct address
const verify = async () => {
const resolvedAddress = await library?.resolveName(selectedAddress)
if (resolvedAddress !== address) {
dispatch(updateAddress(address))
}
}

verify()
}, [selectedAddress, address, dispatch, library])

return (
<>
{items.length === 1 ? (
<strong>{tryShortenAddress(address)}</strong>
) : (
<Wrapper ref={node}>
<AddressInfo onClick={toggle}>
<span style={{ marginRight: '2px' }}>{tryShortenAddress(selectedAddress)}</span>
<ChevronDown size={16} style={{ marginTop: '2px' }} strokeWidth={2.5} />
</AddressInfo>
{open && (
<MenuFlyout>
{items.map((item) => (
<ButtonMenuItem key={item} $selected={item === ''} onClick={() => handleSelectItem(item)}>
<GreenCheck size={16} strokeWidth={2.5} $visible={item === selectedAddress} />{' '}
{tryShortenAddress(item)}
</ButtonMenuItem>
))}
</MenuFlyout>
)}
</Wrapper>
)}
</>
)
}

const Wrapper = styled.div`
position: relative;
display: inline;
margin-right: 0.4rem;
${({ theme }) => theme.mediaWidth.upToMedium`
justify-self: end;
`};
${({ theme }) => theme.mediaWidth.upToSmall`
margin: 0 0.5rem 0 0;
width: initial;
text-overflow: ellipsis;
flex-shrink: 1;
`};
`

const MenuFlyout = styled.span`
background-color: ${({ theme }) => theme.bg4};
border: 1px solid ${({ theme }) => theme.bg0};
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
0px 24px 32px rgba(0, 0, 0, 0.01);
border-radius: 12px;
padding: 0.3rem;
display: flex;
flex-direction: column;
font-size: 1rem;
position: absolute;
left: 0;
top: 1.75rem;
z-index: 100;
min-width: 350px;
${({ theme }) => theme.mediaWidth.upToMedium`;
min-width: 145px
`};
> {
padding: 12px;
}
`
const MenuItem = css`
align-items: center;
background-color: transparent;
border-radius: 12px;
color: ${({ theme }) => theme.text2};
cursor: pointer;
display: flex;
flex: 1;
flex-direction: row;
font-size: 16px;
font-weight: 400;
justify-content: start;
:hover {
text-decoration: none;
}
`

export const AddressInfo = styled.button`
align-items: center;
background-color: ${({ theme }) => theme.bg4};
border-radius: 12px;
border: 1px solid ${({ theme }) => theme.bg0};
color: ${({ theme }) => theme.text1};
display: inline-flex;
flex-direction: row;
font-weight: 700;
font-size: 12px;
height: 100%;
margin: 0 0.4rem;
padding: 0.2rem 0.4rem;
:hover,
:focus {
cursor: pointer;
outline: none;
border: 1px solid ${({ theme }) => theme.bg3};
}
`
const ButtonMenuItem = styled.button<{ $selected?: boolean }>`
${MenuItem}
cursor: ${({ $selected }) => ($selected ? 'initial' : 'pointer')};
border: none;
box-shadow: none;
color: ${({ theme, $selected }) => ($selected ? theme.text2 : theme.text1)};
background-color: ${({ theme, $selected }) => $selected && theme.primary1};
outline: none;
font-weight: ${({ $selected }) => ($selected ? '700' : '500')};
font-size: 12px;
text-transform: lowercase;
padding: 6px 10px 6px 5px;
${({ $selected }) => $selected && `margin: 3px 0;`}
> ${AddressInfo} {
margin: 0 auto 0 8px;
}
&:hover {
color: ${({ theme, $selected }) => !$selected && theme.text1};
background: ${({ theme, $selected }) => !$selected && theme.bg4};
}
transition: background 0.13s ease-in-out;
`

const GreenCheck = styled(Check)<{ $visible: boolean }>`
margin-right: 5px;
color: ${({ theme }) => theme.success};
visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')};
`
54 changes: 54 additions & 0 deletions src/custom/pages/Profile/ens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { SupportedChainId } from 'constants/chains'
import { ClientError, gql, GraphQLClient } from 'graphql-request'
import { EnsNamesQuery } from 'state/data/generated'

const CHAIN_SUBGRAPH_URL: Record<number, string> = {
[SupportedChainId.MAINNET]: 'https://api.thegraph.com/subgraphs/name/ensdomains/ens',
[SupportedChainId.RINKEBY]: 'https://api.thegraph.com/subgraphs/name/ensdomains/ensrinkeby',
}

const DOMAINS_BY_ADDRESS_QUERY = gql`
query ensNames($resolvedAddress: String!) {
domains(where: { resolvedAddress_contains: $resolvedAddress }, orderBy: name) {
name
}
}
`

export async function ensNames(
chainId: SupportedChainId,
address: string
): Promise<
| {
error: { name: string; message: string; stack: string | undefined }
}
| string[]
> {
try {
const subgraphUrl = chainId ? CHAIN_SUBGRAPH_URL[chainId] : undefined

if (!subgraphUrl) {
return {
error: {
name: 'UnsupportedChainId',
message: `Subgraph queries against ChainId ${chainId} are not supported.`,
stack: '',
},
}
}

const data = await new GraphQLClient(subgraphUrl).request<EnsNamesQuery>(DOMAINS_BY_ADDRESS_QUERY, {
resolvedAddress: address.toLocaleLowerCase(),
})

return data.domains
.map((domain) => domain.name)
.filter((domainName): domainName is string => domainName !== null && domainName !== undefined)
} catch (error) {
if (error instanceof ClientError) {
const { name, message, stack } = error
return { error: { name, message, stack } }
}
throw error
}
}
20 changes: 17 additions & 3 deletions src/custom/pages/Profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ import { RefreshCcw } from 'react-feather'
import Web3Status from 'components/Web3Status'
import useReferralLink from 'hooks/useReferralLink'
import useFetchProfile from 'hooks/useFetchProfile'
import { getBlockExplorerUrl } from 'utils'
import { getBlockExplorerUrl, shortenAddress } from 'utils'
import { formatMax, formatSmartLocaleAware, numberFormatter } from 'utils/format'
import { getExplorerAddressLink } from 'utils/explorer'
import useTimeAgo from 'hooks/useTimeAgo'
import { MouseoverTooltipContent } from 'components/Tooltip'
import NotificationBanner from 'components/NotificationBanner'
import { SupportedChainId, SupportedChainId as ChainId } from 'constants/chains'
import AffiliateStatusCheck from 'components/AffiliateStatusCheck'
import AddressSelector from './AddressSelector'
import { useHasOrders } from 'api/gnosisProtocol/hooks'
import { useAddress } from 'state/affiliate/hooks'
import { Title, SectionTitle, HelpCircle } from 'components/Page'
import { ButtonPrimary } from 'custom/components/Button'
import vCOWImage from 'assets/cow-swap/vCOW.png'
Expand Down Expand Up @@ -64,6 +66,7 @@ export default function Profile() {
const lastUpdated = useTimeAgo(profileData?.lastUpdated)
const isTradesTooltipVisible = account && chainId === SupportedChainId.MAINNET && !!profileData?.totalTrades
const hasOrders = useHasOrders(account)
const selectedAddress = useAddress()

const setSwapVCowStatus = useSetSwapVCowStatus()
const swapVCowStatus = useSwapVCowStatus()
Expand Down Expand Up @@ -302,9 +305,20 @@ export default function Profile() {
<>
<span style={{ wordBreak: 'break-all', display: 'inline-block' }}>
{referralLink.prefix}
<strong>{referralLink.address}</strong>
{chainId === ChainId.XDAI ? (
<strong>{shortenAddress(referralLink.address)}</strong>
) : (
<AddressSelector address={referralLink.address} />
)}

<span style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: 8 }}>
<Copy toCopy={referralLink.link} />
<Copy
toCopy={
selectedAddress && chainId !== ChainId.XDAI
? `${referralLink.prefix}${selectedAddress}`
: referralLink.link
}
/>
</span>
</span>
</>
Expand Down
Loading

0 comments on commit d983da0

Please sign in to comment.