diff --git a/codegen.yml b/codegen.yml index 67fc86b9f8..d3f74b5fcd 100644 --- a/codegen.yml +++ b/codegen.yml @@ -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: diff --git a/package.json b/package.json index 48cf7f01ca..3a3c12930f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/custom/components/AffiliateStatusCheck/index.tsx b/src/custom/components/AffiliateStatusCheck/index.tsx index 070a3e703e..b830f58817 100644 --- a/src/custom/components/AffiliateStatusCheck/index.tsx +++ b/src/custom/components/AffiliateStatusCheck/index.tsx @@ -106,7 +106,6 @@ export default function AffiliateStatusCheck() { return } - setAffiliateState(null) setError('') if (!account) { diff --git a/src/custom/hooks/useParseReferralQueryParam.ts b/src/custom/hooks/useParseReferralQueryParam.ts index f73431b03d..d1b5cf0b4e 100644 --- a/src/custom/hooks/useParseReferralQueryParam.ts +++ b/src/custom/hooks/useParseReferralQueryParam.ts @@ -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 @@ -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 } diff --git a/src/custom/pages/Profile/AddressSelector.tsx b/src/custom/pages/Profile/AddressSelector.tsx new file mode 100644 index 0000000000..4d9443e953 --- /dev/null +++ b/src/custom/pages/Profile/AddressSelector.tsx @@ -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([address]) + const toggle = useCallback(() => setOpen((open) => !open), []) + const node = useRef(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 ? ( + {tryShortenAddress(address)} + ) : ( + + + {tryShortenAddress(selectedAddress)} + + + {open && ( + + {items.map((item) => ( + handleSelectItem(item)}> + {' '} + {tryShortenAddress(item)} + + ))} + + )} + + )} + + ) +} + +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')}; +` diff --git a/src/custom/pages/Profile/ens.ts b/src/custom/pages/Profile/ens.ts new file mode 100644 index 0000000000..3e39bcf261 --- /dev/null +++ b/src/custom/pages/Profile/ens.ts @@ -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 = { + [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(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 + } +} diff --git a/src/custom/pages/Profile/index.tsx b/src/custom/pages/Profile/index.tsx index 0399715bd5..8858e896ec 100644 --- a/src/custom/pages/Profile/index.tsx +++ b/src/custom/pages/Profile/index.tsx @@ -27,7 +27,7 @@ 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' @@ -35,7 +35,9 @@ 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' @@ -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() @@ -302,9 +305,20 @@ export default function Profile() { <> {referralLink.prefix} - {referralLink.address} + {chainId === ChainId.XDAI ? ( + {shortenAddress(referralLink.address)} + ) : ( + + )} + - + diff --git a/src/custom/state/affiliate/actions.ts b/src/custom/state/affiliate/actions.ts index 109f300b2b..3f0216478f 100644 --- a/src/custom/state/affiliate/actions.ts +++ b/src/custom/state/affiliate/actions.ts @@ -4,6 +4,9 @@ export const updateReferralAddress = createAction<{ value: string isValid: boolean } | null>('affiliate/updateReferralAddress') + +export const updateAddress = createAction('affiliate/updateAddress') + export const updateAppDataHash = createAction('affiliate/updateAppDataHash') export const dismissNotification = createAction('affiliate/dismissNotification') diff --git a/src/custom/state/affiliate/hooks.ts b/src/custom/state/affiliate/hooks.ts index 3852f80742..d1c664c74b 100644 --- a/src/custom/state/affiliate/hooks.ts +++ b/src/custom/state/affiliate/hooks.ts @@ -24,6 +24,12 @@ export function useReferralAddress() { }) } +export function useAddress() { + return useSelector((state) => { + return state.affiliate.address + }) +} + export function useResetReferralAddress() { const dispatch = useAppDispatch() diff --git a/src/custom/state/affiliate/reducer.ts b/src/custom/state/affiliate/reducer.ts index d8f2772824..8858a6ed1a 100644 --- a/src/custom/state/affiliate/reducer.ts +++ b/src/custom/state/affiliate/reducer.ts @@ -1,5 +1,5 @@ import { createReducer } from '@reduxjs/toolkit' -import { dismissNotification, updateAppDataHash, updateReferralAddress } from './actions' +import { dismissNotification, updateAddress, updateAppDataHash, updateReferralAddress } from './actions' import { APP_DATA_HASH } from 'constants/index' export interface AffiliateState { @@ -11,6 +11,7 @@ export interface AffiliateState { isNotificationClosed?: { [key: string]: boolean } + address?: string // this can be a ENS name or an address } export const initialState: AffiliateState = { @@ -22,6 +23,9 @@ export default createReducer(initialState, (builder) => .addCase(updateReferralAddress, (state, action) => { state.referralAddress = action.payload ?? undefined }) + .addCase(updateAddress, (state, action) => { + state.address = action.payload + }) .addCase(updateAppDataHash, (state, action) => { state.appDataHash = action.payload }) diff --git a/yarn.lock b/yarn.lock index 324f44576c..a66a6a0a21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12542,6 +12542,15 @@ graphql-request@^3.3.0, graphql-request@^3.4.0: extract-files "^9.0.0" form-data "^3.0.0" +graphql-request@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-4.2.0.tgz#063377bc2dd29cc46aed3fddcc65fe97b805ba81" + integrity sha512-uFeMyhhl8ss4LFgjlfPeAn2pqYw+CJto+cjj71uaBYIMMK2jPIqgHm5KEFxUk0YDD41A8Bq31a2b4G2WJBlp2Q== + dependencies: + cross-fetch "^3.1.5" + extract-files "^9.0.0" + form-data "^3.0.0" + graphql-sse@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/graphql-sse/-/graphql-sse-1.1.0.tgz#05a8ea0528b4bde1c042caa5a7a63ef244bd3c56"