diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8a06701905..630d44ade2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -29,6 +29,8 @@ jobs: - run: yarn install --frozen-lockfile - run: yarn cypress install - run: yarn build + env: + REACT_APP_NETWORK_URL: "https://mainnet.infura.io/v3/4bf032f2d38a4ed6bb975b80d6340847" - run: yarn integration-test unit-tests: diff --git a/cypress.json b/cypress.json index cb2f5cd868..c35d015d9f 100644 --- a/cypress.json +++ b/cypress.json @@ -3,5 +3,6 @@ "pluginsFile": false, "fixturesFolder": false, "supportFile": "cypress/support/index.js", - "video": false + "video": false, + "defaultCommandTimeout": 10000 } diff --git a/cypress/integration/lists.test.ts b/cypress/integration/lists.test.ts new file mode 100644 index 0000000000..1ed4a11cd9 --- /dev/null +++ b/cypress/integration/lists.test.ts @@ -0,0 +1,11 @@ +describe('Swap', () => { + beforeEach(() => cy.visit('/swap')) + + it('list selection persists', () => { + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.get('#select-default-uniswap-list .select-button').click() + cy.reload() + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.get('#select-default-uniswap-list').should('not.exist') + }) +}) diff --git a/cypress/integration/swap.test.ts b/cypress/integration/swap.test.ts index e27b148e9b..958be46ab0 100644 --- a/cypress/integration/swap.test.ts +++ b/cypress/integration/swap.test.ts @@ -1,5 +1,8 @@ describe('Swap', () => { - beforeEach(() => cy.visit('/swap')) + beforeEach(() => { + cy.clearLocalStorage() + cy.visit('/swap') + }) it('can enter an amount into input', () => { cy.get('#swap-currency-input .token-amount-input') .type('0.001', { delay: 200 }) @@ -32,6 +35,7 @@ describe('Swap', () => { it('can swap ETH for DAI', () => { cy.get('#swap-currency-output .open-currency-select-button').click() + cy.get('#select-default-uniswap-list .select-button').click() cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').should('be.visible') cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click({ force: true }) cy.get('#swap-currency-input .token-amount-input').should('be.visible') diff --git a/cypress/integration/token-warning.ts b/cypress/integration/token-warning.ts index c6f027501d..0d22c94c1b 100644 --- a/cypress/integration/token-warning.ts +++ b/cypress/integration/token-warning.ts @@ -6,14 +6,11 @@ describe('Warning', () => { it('Check that warning is displayed', () => { cy.get('.token-warning-container').should('be.visible') }) - it('Check that warning hides after button dismissal.', () => { + it('Check that warning hides after button dismissal', () => { + cy.get('.token-dismiss-button').should('be.disabled') + cy.get('.understand-checkbox').click() + cy.get('.token-dismiss-button').should('not.be.disabled') cy.get('.token-dismiss-button').click() cy.get('.token-warning-container').should('not.be.visible') }) - it('Check supression persists across sessions.', () => { - cy.get('.token-warning-container').should('be.visible') - cy.get('.token-dismiss-button').click() - cy.reload() - cy.get('.token-warning-container').should('not.be.visible') - }) }) diff --git a/package.json b/package.json index c09a9a8606..8c8dd9fe26 100644 --- a/package.json +++ b/package.json @@ -11,20 +11,23 @@ "@reduxjs/toolkit": "^1.3.5", "@types/jest": "^25.2.1", "@types/lodash.flatmap": "^4.5.6", + "@types/multicodec": "^1.0.0", "@types/node": "^13.13.5", "@types/qs": "^6.9.2", "@types/react": "^16.9.34", "@types/react-dom": "^16.9.7", "@types/react-redux": "^7.1.8", "@types/react-router-dom": "^5.0.0", + "@types/react-virtualized-auto-sizer": "^1.0.0", "@types/react-window": "^1.8.2", "@types/rebass": "^4.0.5", "@types/styled-components": "^5.1.0", "@types/testing-library__cypress": "^5.0.5", "@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/parser": "^2.31.0", + "@uniswap/default-token-list": "^1.3.0", "@uniswap/sdk": "3.0.3-beta.1", - "@uniswap/token-lists": "^1.0.0-beta.11", + "@uniswap/token-lists": "^1.0.0-beta.14", "@uniswap/v2-core": "1.0.0", "@uniswap/v2-periphery": "^1.1.0-beta.0", "@web3-react/core": "^6.0.9", @@ -34,6 +37,7 @@ "@web3-react/walletconnect-connector": "^6.1.1", "@web3-react/walletlink-connector": "^6.0.9", "ajv": "^6.12.3", + "cids": "^1.0.0", "copy-to-clipboard": "^3.2.0", "cross-env": "^7.0.2", "cypress": "^4.11.0", @@ -49,6 +53,8 @@ "inter-ui": "^3.13.1", "jazzicon": "^1.5.0", "lodash.flatmap": "^4.5.0", + "multicodec": "^2.0.0", + "multihashes": "^3.0.1", "polished": "^3.3.2", "prettier": "^1.17.0", "qs": "^6.9.4", @@ -64,6 +70,7 @@ "react-scripts": "^3.4.1", "react-spring": "^8.0.27", "react-use-gesture": "^6.0.14", + "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.5", "rebass": "^4.0.7", "redux-localstorage-simple": "^2.2.0", diff --git a/src/components/Card/index.tsx b/src/components/Card/index.tsx index f5dff0cf71..f7cd474507 100644 --- a/src/components/Card/index.tsx +++ b/src/components/Card/index.tsx @@ -19,11 +19,11 @@ export const LightCard = styled(Card)` ` export const GreyCard = styled(Card)` - background-color: ${({ theme }) => theme.advancedBG}; + background-color: ${({ theme }) => theme.bg3}; ` export const OutlineCard = styled(Card)` - border: 1px solid ${({ theme }) => theme.advancedBG}; + border: 1px solid ${({ theme }) => theme.bg3}; ` export const YellowCard = styled(Card)` diff --git a/src/components/CurrencyInputPanel/index.tsx b/src/components/CurrencyInputPanel/index.tsx index a7f2a7fdfc..50e0b87cba 100644 --- a/src/components/CurrencyInputPanel/index.tsx +++ b/src/components/CurrencyInputPanel/index.tsx @@ -126,7 +126,6 @@ interface CurrencyInputPanelProps { hideBalance?: boolean pair?: Pair | null hideInput?: boolean - showSendWithSwap?: boolean otherCurrency?: Currency | null id: string showCommonBases?: boolean @@ -144,7 +143,6 @@ export default function CurrencyInputPanel({ hideBalance = false, pair = null, // used for double token logo hideInput = false, - showSendWithSwap = false, otherCurrency = null, id, showCommonBases @@ -238,8 +236,7 @@ export default function CurrencyInputPanel({ isOpen={modalOpen} onDismiss={handleDismissSearch} onCurrencySelect={onCurrencySelect} - showSendWithSwap={showSendWithSwap} - hiddenCurrency={currency} + selectedCurrency={currency} otherSelectedCurrency={otherCurrency} showCommonBases={showCommonBases} /> diff --git a/src/components/CurrencyLogo/index.tsx b/src/components/CurrencyLogo/index.tsx index e645ea934c..dde56ff2db 100644 --- a/src/components/CurrencyLogo/index.tsx +++ b/src/components/CurrencyLogo/index.tsx @@ -1,32 +1,14 @@ import { Currency, ETHER, Token } from '@uniswap/sdk' -import React, { useState } from 'react' +import React, { useMemo } from 'react' import styled from 'styled-components' import EthereumLogo from '../../assets/images/ethereum-logo.png' +import useHttpLocations from '../../hooks/useHttpLocations' import { WrappedTokenInfo } from '../../state/lists/hooks' -import uriToHttp from '../../utils/uriToHttp' +import Logo from '../Logo' -const getTokenLogoURL = address => +const getTokenLogoURL = (address: string) => `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/${address}/logo.png` -const BAD_URIS: { [tokenAddress: string]: true } = {} - -const Image = styled.img<{ size: string }>` - width: ${({ size }) => size}; - height: ${({ size }) => size}; - background-color: white; - border-radius: 1rem; - box-shadow: 0px 6px 10px rgba(0, 0, 0, 0.075); -` - -const Emoji = styled.span<{ size?: string }>` - display: flex; - align-items: center; - justify-content: center; - font-size: ${({ size }) => size}; - width: ${({ size }) => size}; - height: ${({ size }) => size}; - margin-bottom: -4px; -` const StyledEthereumLogo = styled.img<{ size: string }>` width: ${({ size }) => size}; @@ -35,60 +17,38 @@ const StyledEthereumLogo = styled.img<{ size: string }>` border-radius: 24px; ` +const StyledLogo = styled(Logo)<{ size: string }>` + width: ${({ size }) => size}; + height: ${({ size }) => size}; +` + export default function CurrencyLogo({ currency, size = '24px', - ...rest + style }: { currency?: Currency size?: string style?: React.CSSProperties }) { - const [, refresh] = useState(0) + const uriLocations = useHttpLocations(currency instanceof WrappedTokenInfo ? currency.logoURI : undefined) + + const srcs: string[] = useMemo(() => { + if (currency === ETHER) return [] + + if (currency instanceof Token) { + if (currency instanceof WrappedTokenInfo) { + return [...uriLocations, getTokenLogoURL(currency.address)] + } + + return [getTokenLogoURL(currency.address)] + } + return [] + }, [currency, uriLocations]) if (currency === ETHER) { - return + return } - if (currency instanceof Token) { - let uri: string | undefined - - if (currency instanceof WrappedTokenInfo) { - if (currency.logoURI && !BAD_URIS[currency.logoURI]) { - uri = uriToHttp(currency.logoURI).filter(s => !BAD_URIS[s])[0] - } - } - - if (!uri) { - const defaultUri = getTokenLogoURL(currency.address) - if (!BAD_URIS[defaultUri]) { - uri = defaultUri - } - } - - if (uri) { - return ( - {`${currency.name} { - if (currency instanceof Token) { - BAD_URIS[uri] = true - } - refresh(i => i + 1) - }} - /> - ) - } - } - - return ( - - - 🤔 - - - ) + return } diff --git a/src/components/ListLogo/index.tsx b/src/components/ListLogo/index.tsx new file mode 100644 index 0000000000..f1b7ec3607 --- /dev/null +++ b/src/components/ListLogo/index.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import styled from 'styled-components' +import useHttpLocations from '../../hooks/useHttpLocations' + +import Logo from '../Logo' + +const StyledListLogo = styled(Logo)<{ size: string }>` + width: ${({ size }) => size}; + height: ${({ size }) => size}; +` + +export default function ListLogo({ + logoURI, + style, + size = '24px', + alt +}: { + logoURI: string + size?: string + style?: React.CSSProperties + alt?: string +}) { + const srcs: string[] = useHttpLocations(logoURI) + + return +} diff --git a/src/components/Logo/index.tsx b/src/components/Logo/index.tsx new file mode 100644 index 0000000000..6b8ab19170 --- /dev/null +++ b/src/components/Logo/index.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react' +import { AlertTriangle } from 'react-feather' +import { ImageProps } from 'rebass' + +const BAD_SRCS: { [tokenAddress: string]: true } = {} + +export interface LogoProps extends Pick { + srcs: string[] +} + +/** + * Renders an image by sequentially trying a list of URIs, and then eventually a fallback triangle alert + */ +export default function Logo({ srcs, alt, ...rest }: LogoProps) { + const [, refresh] = useState(0) + + const src: string | undefined = srcs.find(src => !BAD_SRCS[src]) + + if (src) { + return ( + {alt} { + if (src) BAD_SRCS[src] = true + refresh(i => i + 1) + }} + /> + ) + } + + return +} diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 5791f25dd8..3d41cf72db 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,7 +1,8 @@ -import React, { useRef, useEffect } from 'react' +import React, { useRef } from 'react' import { Info, BookOpen, Code, PieChart, MessageCircle } from 'react-feather' import styled from 'styled-components' import { ReactComponent as MenuIcon } from '../../assets/images/menu.svg' +import { useOnClickOutside } from '../../hooks/useOnClickOutside' import useToggle from '../../hooks/useToggle' import { ExternalLink } from '../../theme' @@ -83,24 +84,7 @@ export default function Menu() { const node = useRef() const [open, toggle] = useToggle(false) - useEffect(() => { - const handleClickOutside = e => { - if (node.current?.contains(e.target) ?? false) { - return - } - toggle() - } - - if (open) { - document.addEventListener('mousedown', handleClickOutside) - } else { - document.removeEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [open, toggle]) + useOnClickOutside(node, open ? toggle : undefined) return ( diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index bf21475660..af6eb2ea3e 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -38,6 +38,7 @@ const StyledDialogContent = styled(({ minHeight, maxHeight, mobile, isOpen, ...r box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.95, theme.shadow1)}; padding: 0px; width: 50vw; + overflow: hidden; align-self: ${({ mobile }) => (mobile ? 'flex-end' : 'center')}; diff --git a/src/components/Popups/ListUpdatePopup.tsx b/src/components/Popups/ListUpdatePopup.tsx index b0058b3f71..09f484570e 100644 --- a/src/components/Popups/ListUpdatePopup.tsx +++ b/src/components/Popups/ListUpdatePopup.tsx @@ -1,20 +1,17 @@ -import { TokenList, Version } from '@uniswap/token-lists' -import React, { useCallback, useContext } from 'react' -import { AlertCircle, Info } from 'react-feather' +import { diffTokenLists, TokenList } from '@uniswap/token-lists' +import React, { useCallback, useMemo } from 'react' +import ReactGA from 'react-ga' import { useDispatch } from 'react-redux' -import { ThemeContext } from 'styled-components' +import { Text } from 'rebass' import { AppDispatch } from '../../state' import { useRemovePopup } from '../../state/application/hooks' import { acceptListUpdate } from '../../state/lists/actions' import { TYPE } from '../../theme' -import { ButtonPrimary, ButtonSecondary } from '../Button' +import listVersionLabel from '../../utils/listVersionLabel' +import { ButtonSecondary } from '../Button' import { AutoColumn } from '../Column' import { AutoRow } from '../Row' -function versionLabel(version: Version): string { - return `v${version.major}.${version.minor}.${version.patch}` -} - export default function ListUpdatePopup({ popKey, listUrl, @@ -31,34 +28,68 @@ export default function ListUpdatePopup({ const removePopup = useRemovePopup() const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup]) const dispatch = useDispatch() - const theme = useContext(ThemeContext) - const updateList = useCallback(() => { + const handleAcceptUpdate = useCallback(() => { if (auto) return + ReactGA.event({ + category: 'Lists', + action: 'Update List from Popup', + label: listUrl + }) dispatch(acceptListUpdate(listUrl)) removeThisPopup() }, [auto, dispatch, listUrl, removeThisPopup]) + const { added: tokensAdded, changed: tokensChanged, removed: tokensRemoved } = useMemo(() => { + return diffTokenLists(oldList.tokens, newList.tokens) + }, [newList.tokens, oldList.tokens]) + const numTokensChanged = useMemo( + () => Object.keys(tokensChanged).reduce((memo, chainId) => memo + Object.keys(tokensChanged[chainId]).length, 0), + [tokensChanged] + ) + return ( -
- {auto ? : }{' '} -
{auto ? ( The token list "{oldList.name}" has been updated to{' '} - {versionLabel(newList.version)}. + {listVersionLabel(newList.version)}. ) : ( <>
- A token list update is available for the list "{oldList.name}" ({versionLabel(oldList.version)}{' '} - to {versionLabel(newList.version)}). + + An update is available for the token list "{oldList.name}" ( + {listVersionLabel(oldList.version)} to {listVersionLabel(newList.version)}). + +
    + {tokensAdded.length > 0 ? ( +
  • + {tokensAdded.map(token => ( + + {token.symbol} + + ))}{' '} + added +
  • + ) : null} + {tokensRemoved.length > 0 ? ( +
  • + {tokensRemoved.map(token => ( + + {token.symbol} + + ))}{' '} + removed +
  • + ) : null} + {numTokensChanged > 0 ?
  • {numTokensChanged} tokens updated
  • : null} +
-
- Update list +
+ Accept update
Dismiss diff --git a/src/components/Popups/PopupItem.tsx b/src/components/Popups/PopupItem.tsx index f7f0436052..6e529ab594 100644 --- a/src/components/Popups/PopupItem.tsx +++ b/src/components/Popups/PopupItem.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useContext, useState } from 'react' +import React, { useCallback, useContext, useEffect } from 'react' import { X } from 'react-feather' +import { useSpring } from 'react-spring/web' import styled, { ThemeContext } from 'styled-components' -import useInterval from '../../hooks/useInterval' +import { animated } from 'react-spring' import { PopupContent } from '../../state/application/actions' import { useRemovePopup } from '../../state/application/hooks' import ListUpdatePopup from './ListUpdatePopup' @@ -25,44 +26,48 @@ export const Popup = styled.div` border-radius: 10px; padding: 20px; padding-right: 35px; - z-index: 2; overflow: hidden; ${({ theme }) => theme.mediaWidth.upToSmall` min-width: 290px; `} ` -const DELAY = 100 -const Fader = styled.div<{ count: number }>` +const Fader = styled.div` position: absolute; bottom: 0px; left: 0px; - width: ${({ count }) => `calc(100% - (100% / ${150 / count}))`}; + width: 100%; height: 2px; background-color: ${({ theme }) => theme.bg3}; - transition: width 100ms linear; ` -export default function PopupItem({ content, popKey }: { content: PopupContent; popKey: string }) { - const [count, setCount] = useState(1) +const AnimatedFader = animated(Fader) - const [isRunning, setIsRunning] = useState(true) +export default function PopupItem({ + removeAfterMs, + content, + popKey +}: { + removeAfterMs: number | null + content: PopupContent + popKey: string +}) { const removePopup = useRemovePopup() - const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup]) + useEffect(() => { + if (removeAfterMs === null) return - useInterval( - () => { - count > 150 ? removeThisPopup() : setCount(count + 1) - }, - isRunning ? DELAY : null - ) + const timeout = setTimeout(() => { + removeThisPopup() + }, removeAfterMs) + + return () => { + clearTimeout(timeout) + } + }, [removeAfterMs, removeThisPopup]) const theme = useContext(ThemeContext) - const handleMouseEnter = useCallback(() => setIsRunning(false), []) - const handleMouseLeave = useCallback(() => setIsRunning(true), []) - let popupContent if ('txn' in content) { const { @@ -76,11 +81,13 @@ export default function PopupItem({ content, popKey }: { content: PopupContent; popupContent = } + const faderStyle = useSpring({ from: { width: '100%' }, to: { width: '0%' }, config: { duration: removeAfterMs } }) + return ( - - removePopup(popKey)} /> + + {popupContent} - + {removeAfterMs !== null ? : null} ) } diff --git a/src/components/Popups/index.tsx b/src/components/Popups/index.tsx index 25528ccfde..8099be51d5 100644 --- a/src/components/Popups/index.tsx +++ b/src/components/Popups/index.tsx @@ -35,6 +35,7 @@ const FixedPopupColumn = styled(AutoColumn)` right: 1rem; max-width: 355px !important; width: 100%; + z-index: 2; ${({ theme }) => theme.mediaWidth.upToSmall` display: none; @@ -49,7 +50,7 @@ export default function Popups() { <> {activePopups.map(item => ( - + ))} 0 ? 'fit-content' : 0}> @@ -58,7 +59,7 @@ export default function Popups() { .slice(0) .reverse() .map(item => ( - + ))} diff --git a/src/components/QuestionHelper/index.tsx b/src/components/QuestionHelper/index.tsx index 614714ed36..d2a270bcdc 100644 --- a/src/components/QuestionHelper/index.tsx +++ b/src/components/QuestionHelper/index.tsx @@ -22,7 +22,7 @@ const QuestionWrapper = styled.div` } ` -export default function QuestionHelper({ text, disabled }: { text: string; disabled?: boolean }) { +export default function QuestionHelper({ text }: { text: string }) { const [show, setShow] = useState(false) const open = useCallback(() => setShow(true), [setShow]) @@ -30,7 +30,7 @@ export default function QuestionHelper({ text, disabled }: { text: string; disab return ( - + diff --git a/src/components/SearchModal/CommonBases.tsx b/src/components/SearchModal/CommonBases.tsx index 90c97ba1a2..8bb2f643a3 100644 --- a/src/components/SearchModal/CommonBases.tsx +++ b/src/components/SearchModal/CommonBases.tsx @@ -44,7 +44,11 @@ export default function CommonBases({ !currencyEquals(selectedCurrency, ETHER) && onSelect(ETHER)} + onClick={() => { + if (!selectedCurrency || !currencyEquals(selectedCurrency, ETHER)) { + onSelect(ETHER) + } + }} disable={selectedCurrency === ETHER} > diff --git a/src/components/SearchModal/CurrencyList.tsx b/src/components/SearchModal/CurrencyList.tsx index 87e2ef3f9c..975c4ac7c2 100644 --- a/src/components/SearchModal/CurrencyList.tsx +++ b/src/components/SearchModal/CurrencyList.tsx @@ -1,155 +1,208 @@ -import { Currency, CurrencyAmount, currencyEquals, ETHER, JSBI, Token } from '@uniswap/sdk' -import React, { CSSProperties, memo, useContext, useMemo } from 'react' +import { Currency, CurrencyAmount, currencyEquals, ETHER, Token } from '@uniswap/sdk' +import React, { CSSProperties, MutableRefObject, useCallback, useMemo } from 'react' import { FixedSizeList } from 'react-window' import { Text } from 'rebass' -import { ThemeContext } from 'styled-components' +import styled from 'styled-components' import { useActiveWeb3React } from '../../hooks' -import { useAllTokens } from '../../hooks/Tokens' -import { useDefaultTokenList } from '../../state/lists/hooks' +import { useSelectedTokenList, WrappedTokenInfo } from '../../state/lists/hooks' import { useAddUserToken, useRemoveUserAddedToken } from '../../state/user/hooks' -import { useETHBalances } from '../../state/wallet/hooks' +import { useCurrencyBalance } from '../../state/wallet/hooks' import { LinkStyledButton, TYPE } from '../../theme' -import { ButtonSecondary } from '../Button' -import Column, { AutoColumn } from '../Column' +import Column from '../Column' import { RowFixed } from '../Row' import CurrencyLogo from '../CurrencyLogo' +import { MouseoverTooltip } from '../Tooltip' import { FadedSpan, MenuItem } from './styleds' import Loader from '../Loader' -import { isDefaultToken } from '../../utils' +import { isTokenOnList } from '../../utils' function currencyKey(currency: Currency): string { return currency instanceof Token ? currency.address : currency === ETHER ? 'ETHER' : '' } +const StyledBalanceText = styled(Text)` + white-space: nowrap; + overflow: hidden; + max-width: 5rem; + text-overflow: ellipsis; +` + +const Tag = styled.div` + background-color: ${({ theme }) => theme.bg3}; + color: ${({ theme }) => theme.text2}; + font-size: 14px; + border-radius: 4px; + padding: 0.25rem 0.3rem 0.25rem 0.3rem; + max-width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-self: flex-end; + margin-right: 4px; +` + +function Balance({ balance }: { balance: CurrencyAmount }) { + return {balance.toSignificant(4)} +} + +const TagContainer = styled.div` + display: flex; + justify-content: flex-end; +` + +function TokenTags({ currency }: { currency: Currency }) { + if (!(currency instanceof WrappedTokenInfo)) { + return + } + + const tags = currency.tags + if (!tags || tags.length === 0) return + + const tag = tags[0] + + return ( + + + {tag.name} + + {tags.length > 1 ? ( + `${name}: ${description}`) + .join('; \n')} + > + ... + + ) : null} + + ) +} + +function CurrencyRow({ + currency, + onSelect, + isSelected, + otherSelected, + style +}: { + currency: Currency + onSelect: () => void + isSelected: boolean + otherSelected: boolean + style: CSSProperties +}) { + const { account, chainId } = useActiveWeb3React() + const key = currencyKey(currency) + const selectedTokenList = useSelectedTokenList() + const isOnSelectedList = isTokenOnList(selectedTokenList, currency) + const customAdded = Boolean(!isOnSelectedList && currency instanceof Token) + const balance = useCurrencyBalance(account ?? undefined, currency) + + const removeToken = useRemoveUserAddedToken() + const addToken = useAddUserToken() + + return ( + (isSelected ? null : onSelect())} + disabled={isSelected} + selected={otherSelected} + > + + + + {currency.symbol} + + + {customAdded ? ( + + Added by user + { + event.stopPropagation() + if (chainId && currency instanceof Token) removeToken(chainId, currency.address) + }} + > + (Remove) + + + ) : null} + {!isOnSelectedList && !customAdded ? ( + + Found by address + { + event.stopPropagation() + if (currency instanceof Token) addToken(currency) + }} + > + (Add) + + + ) : null} + + + + + {balance ? : account ? : null} + + + ) +} + export default function CurrencyList({ + height, currencies, - allBalances, selectedCurrency, onCurrencySelect, otherCurrency, - showSendWithSwap + fixedListRef, + showETH }: { + height: number currencies: Currency[] - selectedCurrency: Currency - allBalances: { [tokenAddress: string]: CurrencyAmount } + selectedCurrency: Currency | undefined onCurrencySelect: (currency: Currency) => void - otherCurrency: Currency - showSendWithSwap?: boolean + otherCurrency: Currency | undefined + fixedListRef?: MutableRefObject + showETH: boolean }) { - const { account, chainId } = useActiveWeb3React() - const theme = useContext(ThemeContext) - const allTokens = useAllTokens() - const defaultTokens = useDefaultTokenList() - const addToken = useAddUserToken() - const removeToken = useRemoveUserAddedToken() - const ETHBalance = useETHBalances([account])[account] + const itemData = useMemo(() => (showETH ? [Currency.ETHER, ...currencies] : currencies), [currencies, showETH]) - const CurrencyRow = useMemo(() => { - return memo(function CurrencyRow({ index, style }: { index: number; style: CSSProperties }) { - const currency = index === 0 ? Currency.ETHER : currencies[index - 1] - const key = currencyKey(currency) - const isDefault = isDefaultToken(defaultTokens, currency) - const customAdded = Boolean(!isDefault && currency instanceof Token && allTokens[currency.address]) - const balance = currency === ETHER ? ETHBalance : allBalances[key] - - const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw) - - const isSelected = Boolean(selectedCurrency && currencyEquals(currency, selectedCurrency)) + const Row = useCallback( + ({ data, index, style }) => { + const currency: Currency = data[index] + const isSelected = Boolean(selectedCurrency && currencyEquals(selectedCurrency, currency)) const otherSelected = Boolean(otherCurrency && currencyEquals(otherCurrency, currency)) - + const handleSelect = () => onCurrencySelect(currency) return ( - (isSelected ? null : onCurrencySelect(currency))} - disabled={isSelected} - selected={otherSelected} - > - - - - {currency.symbol} - - {customAdded ? ( - - Added by user - { - event.stopPropagation() - if (currency instanceof Token) removeToken(chainId, currency.address) - }} - > - (Remove) - - - ) : null} - {!isDefault && !customAdded ? ( - - Found by address - { - event.stopPropagation() - if (currency instanceof Token) addToken(currency) - }} - > - (Add) - - - ) : null} - - - - - {balance ? ( - - {zeroBalance && showSendWithSwap ? ( - - - Send With Swap - - - ) : balance ? ( - balance.toSignificant(6) - ) : ( - '-' - )} - - ) : account ? ( - - ) : ( - '-' - )} - - + currency={currency} + isSelected={isSelected} + onSelect={handleSelect} + otherSelected={otherSelected} + /> ) - }) - }, [ - ETHBalance, - account, - addToken, - allBalances, - allTokens, - chainId, - currencies, - defaultTokens, - onCurrencySelect, - otherCurrency, - removeToken, - selectedCurrency, - showSendWithSwap, - theme.primary1 - ]) + }, + [onCurrencySelect, otherCurrency, selectedCurrency] + ) + + const itemKey = useCallback((index: number, data: any) => currencyKey(data[index]), []) return ( currencyKey(currencies[index])} + itemKey={itemKey} > - {CurrencyRow} + {Row} ) } diff --git a/src/components/SearchModal/CurrencySearch.tsx b/src/components/SearchModal/CurrencySearch.tsx new file mode 100644 index 0000000000..5783465de7 --- /dev/null +++ b/src/components/SearchModal/CurrencySearch.tsx @@ -0,0 +1,210 @@ +import { Currency, ETHER, Token } from '@uniswap/sdk' +import React, { KeyboardEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import ReactGA from 'react-ga' +import { useTranslation } from 'react-i18next' +import { FixedSizeList } from 'react-window' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { useActiveWeb3React } from '../../hooks' +import { useAllTokens, useToken } from '../../hooks/Tokens' +import { useSelectedListInfo } from '../../state/lists/hooks' +import { CloseIcon, LinkStyledButton, TYPE } from '../../theme' +import { isAddress } from '../../utils' +import Card from '../Card' +import Column from '../Column' +import ListLogo from '../ListLogo' +import QuestionHelper from '../QuestionHelper' +import Row, { RowBetween } from '../Row' +import CommonBases from './CommonBases' +import CurrencyList from './CurrencyList' +import { filterTokens } from './filtering' +import SortButton from './SortButton' +import { useTokenComparator } from './sorting' +import { PaddedColumn, SearchInput, Separator } from './styleds' +import AutoSizer from 'react-virtualized-auto-sizer' + +interface CurrencySearchProps { + isOpen: boolean + onDismiss: () => void + selectedCurrency?: Currency + onCurrencySelect: (currency: Currency) => void + otherSelectedCurrency?: Currency + showCommonBases?: boolean + onChangeList: () => void +} + +export function CurrencySearch({ + selectedCurrency, + onCurrencySelect, + otherSelectedCurrency, + showCommonBases, + onDismiss, + isOpen, + onChangeList +}: CurrencySearchProps) { + const { t } = useTranslation() + const { chainId } = useActiveWeb3React() + const theme = useContext(ThemeContext) + + const fixedList = useRef() + const [searchQuery, setSearchQuery] = useState('') + const [invertSearchOrder, setInvertSearchOrder] = useState(false) + const allTokens = useAllTokens() + + // if they input an address, use it + const isAddressSearch = isAddress(searchQuery) + const searchToken = useToken(searchQuery) + + useEffect(() => { + if (isAddressSearch) { + ReactGA.event({ + category: 'Currency Select', + action: 'Search by address', + label: isAddressSearch + }) + } + }, [isAddressSearch]) + + const showETH: boolean = useMemo(() => { + const s = searchQuery.toLowerCase().trim() + return s === '' || s === 'e' || s === 'et' || s === 'eth' + }, [searchQuery]) + + const tokenComparator = useTokenComparator(invertSearchOrder) + + const filteredTokens: Token[] = useMemo(() => { + if (isAddressSearch) return searchToken ? [searchToken] : [] + return filterTokens(Object.values(allTokens), searchQuery) + }, [isAddressSearch, searchToken, allTokens, searchQuery]) + + const filteredSortedTokens: Token[] = useMemo(() => { + if (searchToken) return [searchToken] + const sorted = filteredTokens.sort(tokenComparator) + const symbolMatch = searchQuery + .toLowerCase() + .split(/\s+/) + .filter(s => s.length > 0) + if (symbolMatch.length > 1) return sorted + + return [ + ...(searchToken ? [searchToken] : []), + // sort any exact symbol matches first + ...sorted.filter(token => token.symbol?.toLowerCase() === symbolMatch[0]), + ...sorted.filter(token => token.symbol?.toLowerCase() !== symbolMatch[0]) + ] + }, [filteredTokens, searchQuery, searchToken, tokenComparator]) + + const handleCurrencySelect = useCallback( + (currency: Currency) => { + onCurrencySelect(currency) + onDismiss() + }, + [onDismiss, onCurrencySelect] + ) + + // clear the input on open + useEffect(() => { + if (isOpen) setSearchQuery('') + }, [isOpen]) + + // manage focus on modal show + const inputRef = useRef() + const handleInput = useCallback(event => { + const input = event.target.value + const checksummedInput = isAddress(input) + setSearchQuery(checksummedInput || input) + fixedList.current?.scrollTo(0) + }, []) + + const handleEnter = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const s = searchQuery.toLowerCase().trim() + if (s === 'eth') { + handleCurrencySelect(ETHER) + } else if (filteredSortedTokens.length > 0) { + if ( + filteredSortedTokens[0].symbol?.toLowerCase() === searchQuery.trim().toLowerCase() || + filteredSortedTokens.length === 1 + ) { + handleCurrencySelect(filteredSortedTokens[0]) + } + } + } + }, + [filteredSortedTokens, handleCurrencySelect, searchQuery] + ) + + const selectedListInfo = useSelectedListInfo() + + return ( + + + + + Select a token + + + + + } + onChange={handleInput} + onKeyDown={handleEnter} + /> + {showCommonBases && ( + + )} + + + Token Name + + setInvertSearchOrder(iso => !iso)} /> + + + + + +
+ + {({ height }) => ( + + )} + +
+ + + + + {selectedListInfo.current ? ( + + {selectedListInfo.current.logoURI ? ( + + ) : null} + {selectedListInfo.current.name} + + ) : null} + + {selectedListInfo.current ? 'Change' : 'Select a list'} + + + +
+ ) +} diff --git a/src/components/SearchModal/CurrencySearchModal.tsx b/src/components/SearchModal/CurrencySearchModal.tsx index 3b1bc3bfb4..8200029601 100644 --- a/src/components/SearchModal/CurrencySearchModal.tsx +++ b/src/components/SearchModal/CurrencySearchModal.tsx @@ -1,34 +1,18 @@ -import { Currency, Token } from '@uniswap/sdk' -import React, { KeyboardEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { isMobile } from 'react-device-detect' -import { useTranslation } from 'react-i18next' -import { Text } from 'rebass' -import { ThemeContext } from 'styled-components' -import Card from '../../components/Card' -import { useActiveWeb3React } from '../../hooks' -import { useAllTokens, useToken } from '../../hooks/Tokens' -import useInterval from '../../hooks/useInterval' -import { useAllTokenBalances, useTokenBalance } from '../../state/wallet/hooks' -import { CloseIcon, LinkStyledButton } from '../../theme' -import { isAddress } from '../../utils' -import Column from '../Column' +import { Currency } from '@uniswap/sdk' +import React, { useCallback, useEffect, useState } from 'react' +import ReactGA from 'react-ga' +import useLast from '../../hooks/useLast' +import { useSelectedListUrl } from '../../state/lists/hooks' import Modal from '../Modal' -import QuestionHelper from '../QuestionHelper' -import { AutoRow, RowBetween } from '../Row' -import Tooltip from '../Tooltip' -import CommonBases from './CommonBases' -import { filterTokens } from './filtering' -import { useTokenComparator } from './sorting' -import { PaddedColumn, SearchInput } from './styleds' -import CurrencyList from './CurrencyList' -import SortButton from './SortButton' +import { CurrencySearch } from './CurrencySearch' +import ListIntroduction from './ListIntroduction' +import { ListSelect } from './ListSelect' interface CurrencySearchModalProps { - isOpen?: boolean - onDismiss?: () => void - hiddenCurrency?: Currency - showSendWithSwap?: boolean - onCurrencySelect?: (currency: Currency) => void + isOpen: boolean + onDismiss: () => void + selectedCurrency?: Currency + onCurrencySelect: (currency: Currency) => void otherSelectedCurrency?: Currency showCommonBases?: boolean } @@ -37,53 +21,18 @@ export default function CurrencySearchModal({ isOpen, onDismiss, onCurrencySelect, - hiddenCurrency, - showSendWithSwap, + selectedCurrency, otherSelectedCurrency, showCommonBases = false }: CurrencySearchModalProps) { - const { t } = useTranslation() - const { account, chainId } = useActiveWeb3React() - const theme = useContext(ThemeContext) + const [listView, setListView] = useState(false) + const lastOpen = useLast(isOpen) - const [searchQuery, setSearchQuery] = useState('') - const [tooltipOpen, setTooltipOpen] = useState(false) - const [invertSearchOrder, setInvertSearchOrder] = useState(false) - const allTokens = useAllTokens() - - // if the current input is an address, and we don't have the token in context, try to fetch it and import - const searchToken = useToken(searchQuery) - const searchTokenBalance = useTokenBalance(account, searchToken) - const allTokenBalances_ = useAllTokenBalances() - const allTokenBalances = searchToken - ? { - [searchToken.address]: searchTokenBalance - } - : allTokenBalances_ ?? {} - - const tokenComparator = useTokenComparator(invertSearchOrder) - - const filteredTokens: Token[] = useMemo(() => { - if (searchToken) return [searchToken] - return filterTokens(Object.values(allTokens), searchQuery) - }, [searchToken, allTokens, searchQuery]) - - const filteredSortedTokens: Token[] = useMemo(() => { - if (searchToken) return [searchToken] - const sorted = filteredTokens.sort(tokenComparator) - const symbolMatch = searchQuery - .toLowerCase() - .split(/\s+/) - .filter(s => s.length > 0) - if (symbolMatch.length > 1) return sorted - - return [ - ...(searchToken ? [searchToken] : []), - // sort any exact symbol matches first - ...sorted.filter(token => token.symbol.toLowerCase() === symbolMatch[0]), - ...sorted.filter(token => token.symbol.toLowerCase() !== symbolMatch[0]) - ] - }, [filteredTokens, searchQuery, searchToken, tokenComparator]) + useEffect(() => { + if (isOpen && !lastOpen) { + setListView(false) + } + }, [isOpen, lastOpen]) const handleCurrencySelect = useCallback( (currency: Currency) => { @@ -93,114 +42,41 @@ export default function CurrencySearchModal({ [onDismiss, onCurrencySelect] ) - // clear the input on open - useEffect(() => { - if (isOpen) setSearchQuery('') - }, [isOpen, setSearchQuery]) - - // manage focus on modal show - const inputRef = useRef() - const handleInput = useCallback(event => { - const input = event.target.value - const checksummedInput = isAddress(input) - setSearchQuery(checksummedInput || input) - setTooltipOpen(false) + const handleClickChangeList = useCallback(() => { + ReactGA.event({ + category: 'Lists', + action: 'Change Lists' + }) + setListView(true) + }, []) + const handleClickBack = useCallback(() => { + ReactGA.event({ + category: 'Lists', + action: 'Back' + }) + setListView(false) }, []) - const openTooltip = useCallback(() => { - setTooltipOpen(true) - }, [setTooltipOpen]) - const closeTooltip = useCallback(() => setTooltipOpen(false), [setTooltipOpen]) - - useInterval( - () => { - setTooltipOpen(false) - }, - tooltipOpen ? 4000 : null, - false - ) - - const handleEnter = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter' && filteredSortedTokens.length > 0) { - if ( - filteredSortedTokens[0].symbol.toLowerCase() === searchQuery.trim().toLowerCase() || - filteredSortedTokens.length === 1 - ) { - handleCurrencySelect(filteredSortedTokens[0]) - } - } - }, - [filteredSortedTokens, handleCurrencySelect, searchQuery] - ) + const selectedListUrl = useSelectedListUrl() + const noListSelected = !selectedListUrl return ( - - - - - - Select a token - - - - - - - - {showCommonBases && ( - - )} - - - Token Name - - setInvertSearchOrder(iso => !iso)} /> - - -
- + {noListSelected ? ( + + ) : listView ? ( + + ) : ( + -
- - -
- - Having trouble finding a token? - -
-
-
- + )} ) } diff --git a/src/components/SearchModal/ListIntroduction.tsx b/src/components/SearchModal/ListIntroduction.tsx new file mode 100644 index 0000000000..9c42e9a460 --- /dev/null +++ b/src/components/SearchModal/ListIntroduction.tsx @@ -0,0 +1,80 @@ +import React, { memo, useCallback, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { Text } from 'rebass' +import { AppDispatch, AppState } from '../../state' +import { addList, selectList } from '../../state/lists/actions' +import { ExternalLink } from '../../theme' +import { ButtonPrimary } from '../Button' +import { OutlineCard, GreyCard } from '../Card' +import Column, { AutoColumn } from '../Column' +import ListLogo from '../ListLogo' +import Row from '../Row' +import { PaddedColumn } from './styleds' + +const ListCard = memo(function ListCard({ id, listUrl }: { id: string; listUrl: string }) { + const dispatch = useDispatch() + + const listsByUrl = useSelector(state => state.lists.byUrl) + const list = listsByUrl[listUrl]?.current + + useEffect(() => { + if (!listsByUrl[listUrl]) dispatch(addList(listUrl)) + }, [dispatch, listUrl, listsByUrl]) + + const handleSelect = useCallback(() => { + dispatch(selectList(listUrl)) + }, [dispatch, listUrl]) + + if (!list) return null + + return ( + + + {list.logoURI ? ( + + ) : null} + + {list.name} + + + Select + + + + ) +}) + +export default function ListIntroduction() { + return ( + + + + + Select a list + + + Get started by selecting a token list below. You can switch between token lists and add your own custom + lists via IPFS, HTTPS and ENS. + + + + + + + Token lists are an{' '} + open specification. Check out{' '} + tokenlists.org to find more lists. + + + + + + ) +} diff --git a/src/components/SearchModal/ListSelect.tsx b/src/components/SearchModal/ListSelect.tsx new file mode 100644 index 0000000000..880eba33e6 --- /dev/null +++ b/src/components/SearchModal/ListSelect.tsx @@ -0,0 +1,372 @@ +import React, { memo, useCallback, useMemo, useRef, useState } from 'react' +import { ArrowLeft } from 'react-feather' +import ReactGA from 'react-ga' +import { usePopper } from 'react-popper' +import { useDispatch, useSelector } from 'react-redux' +import { Text } from 'rebass' +import styled from 'styled-components' +import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg' +import { useFetchListCallback } from '../../hooks/useFetchListCallback' +import { useOnClickOutside } from '../../hooks/useOnClickOutside' + +import useToggle from '../../hooks/useToggle' +import { AppDispatch, AppState } from '../../state' +import { acceptListUpdate, removeList, selectList } from '../../state/lists/actions' +import { useSelectedListUrl } from '../../state/lists/hooks' +import { CloseIcon, ExternalLink, LinkStyledButton, TYPE } from '../../theme' +import listVersionLabel from '../../utils/listVersionLabel' +import { parseENSAddress } from '../../utils/parseENSAddress' +import uriToHttp from '../../utils/uriToHttp' +import { ButtonOutlined, ButtonPrimary, ButtonSecondary } from '../Button' + +import Column from '../Column' +import ListLogo from '../ListLogo' +import QuestionHelper from '../QuestionHelper' +import Row, { RowBetween } from '../Row' +import { PaddedColumn, SearchInput, Separator, SeparatorDark } from './styleds' + +const UnpaddedLinkStyledButton = styled(LinkStyledButton)` + padding: 0; + font-size: 1rem; + opacity: ${({ disabled }) => (disabled ? '0.4' : '1')}; +` + +const PopoverContainer = styled.div<{ show: boolean }>` + z-index: 100; + visibility: ${props => (props.show ? 'visible' : 'hidden')}; + opacity: ${props => (props.show ? 1 : 0)}; + transition: visibility 150ms linear, opacity 150ms linear; + background: ${({ theme }) => theme.bg2}; + border: 1px solid ${({ theme }) => theme.bg3}; + 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); + color: ${({ theme }) => theme.text2}; + border-radius: 0.5rem; + padding: 1rem; + display: grid; + grid-template-rows: 1fr; + grid-gap: 8px; + font-size: 1rem; + text-align: left; +` + +const StyledMenu = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: relative; + border: none; +` + +const StyledListUrlText = styled.div` + max-width: 160px; + opacity: 0.6; + margin-right: 0.5rem; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; +` + +function ListOrigin({ listUrl }: { listUrl: string }) { + const ensName = useMemo(() => parseENSAddress(listUrl)?.ensName, [listUrl]) + const host = useMemo(() => { + if (ensName) return undefined + try { + const url = new URL(listUrl) + return url.host + } catch (error) { + return undefined + } + }, [listUrl, ensName]) + return <>{ensName ?? host} +} + +const ListRow = memo(function ListRow({ listUrl, onBack }: { listUrl: string; onBack: () => void }) { + const listsByUrl = useSelector(state => state.lists.byUrl) + const selectedListUrl = useSelectedListUrl() + const dispatch = useDispatch() + const { current: list, pendingUpdate: pending } = listsByUrl[listUrl] + + const isSelected = listUrl === selectedListUrl + + const [open, toggle] = useToggle(false) + const node = useRef() + const [referenceElement, setReferenceElement] = useState() + const [popperElement, setPopperElement] = useState() + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'auto', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [8, 8] } }] + }) + + useOnClickOutside(node, open ? toggle : undefined) + + const selectThisList = useCallback(() => { + if (isSelected) return + ReactGA.event({ + category: 'Lists', + action: 'Select List', + label: listUrl + }) + + dispatch(selectList(listUrl)) + onBack() + }, [dispatch, isSelected, listUrl, onBack]) + + const handleAcceptListUpdate = useCallback(() => { + if (!pending) return + ReactGA.event({ + category: 'Lists', + action: 'Update List from List Select', + label: listUrl + }) + dispatch(acceptListUpdate(listUrl)) + }, [dispatch, listUrl, pending]) + + const handleRemoveList = useCallback(() => { + ReactGA.event({ + category: 'Lists', + action: 'Start Remove List', + label: listUrl + }) + if (window.prompt(`Please confirm you would like to remove this list by typing REMOVE`) === `REMOVE`) { + ReactGA.event({ + category: 'Lists', + action: 'Confirm Remove List', + label: listUrl + }) + dispatch(removeList(listUrl)) + } + }, [dispatch, listUrl]) + + if (!list) return null + + return ( + + {list.logoURI ? ( + + ) : ( +
+ )} + + + + {list.name} + + + + + + + + + + + + + + {open && ( + +
{list && listVersionLabel(list.version)}
+ + View list + + Remove list + + {pending && ( + Update list + )} +
+ )} +
+ {isSelected ? ( + + Selected + + ) : ( + <> + + Select + + + )} + + ) +}) + +const AddListButton = styled(ButtonSecondary)` + /* height: 1.8rem; */ + max-width: 4rem; + margin-left: 1rem; + border-radius: 12px; + padding: 10px 18px; +` + +const ListContainer = styled.div` + flex: 1; + overflow: auto; +` + +export function ListSelect({ onDismiss, onBack }: { onDismiss: () => void; onBack: () => void }) { + const [listUrlInput, setListUrlInput] = useState('') + + const dispatch = useDispatch() + const lists = useSelector(state => state.lists.byUrl) + const adding = Boolean(lists[listUrlInput]?.loadingRequestId) + const [addError, setAddError] = useState(null) + + const handleInput = useCallback(e => { + setListUrlInput(e.target.value) + setAddError(null) + }, []) + const fetchList = useFetchListCallback() + + const handleAddList = useCallback(() => { + if (adding) return + setAddError(null) + fetchList(listUrlInput) + .then(() => { + setListUrlInput('') + ReactGA.event({ + category: 'Lists', + action: 'Add List', + label: listUrlInput + }) + }) + .catch(error => { + ReactGA.event({ + category: 'Lists', + action: 'Add List Failed', + label: listUrlInput + }) + setAddError(error.message) + dispatch(removeList(listUrlInput)) + }) + }, [adding, dispatch, fetchList, listUrlInput]) + + const validUrl: boolean = useMemo(() => { + return uriToHttp(listUrlInput).length > 0 || Boolean(parseENSAddress(listUrlInput)) + }, [listUrlInput]) + + const handleEnterKey = useCallback( + e => { + if (validUrl && e.key === 'Enter') { + handleAddList() + } + }, + [handleAddList, validUrl] + ) + + const sortedLists = useMemo(() => { + const listUrls = Object.keys(lists) + return listUrls + .filter(listUrl => { + return Boolean(lists[listUrl].current) + }) + .sort((u1, u2) => { + const { current: l1 } = lists[u1] + const { current: l2 } = lists[u2] + if (l1 && l2) { + return l1.name.toLowerCase() < l2.name.toLowerCase() + ? -1 + : l1.name.toLowerCase() === l2.name.toLowerCase() + ? 0 + : 1 + } + if (l1) return -1 + if (l2) return 1 + return 0 + }) + }, [lists]) + + return ( + + + +
+ +
+ + Manage Lists + + +
+
+ + + + + + Add a list{' '} + + + + + + Add + + + {addError ? ( + + {addError} + + ) : null} + + + + + + {sortedLists.map(listUrl => ( + + ))} + + + + + Browse lists + +
+ ) +} diff --git a/src/components/SearchModal/sorting.ts b/src/components/SearchModal/sorting.ts index 631b2650db..4713cb5e65 100644 --- a/src/components/SearchModal/sorting.ts +++ b/src/components/SearchModal/sorting.ts @@ -1,6 +1,5 @@ -import { Token, TokenAmount, WETH } from '@uniswap/sdk' +import { Token, TokenAmount } from '@uniswap/sdk' import { useMemo } from 'react' -import { useActiveWeb3React } from '../../hooks' import { useAllTokenBalances } from '../../state/wallet/hooks' // compare two token amounts with highest one coming first @@ -15,20 +14,13 @@ function balanceComparator(balanceA?: TokenAmount, balanceB?: TokenAmount) { return 0 } -function getTokenComparator( - weth: Token | undefined, - balances: { [tokenAddress: string]: TokenAmount } -): (tokenA: Token, tokenB: Token) => number { +function getTokenComparator(balances: { + [tokenAddress: string]: TokenAmount | undefined +}): (tokenA: Token, tokenB: Token) => number { return function sortTokens(tokenA: Token, tokenB: Token): number { // -1 = a is first // 1 = b is first - // sort ETH first - if (weth) { - if (tokenA.equals(weth)) return -1 - if (tokenB.equals(weth)) return 1 - } - // sort by balances const balanceA = balances[tokenA.address] const balanceB = balances[tokenB.address] @@ -36,16 +28,18 @@ function getTokenComparator( const balanceComp = balanceComparator(balanceA, balanceB) if (balanceComp !== 0) return balanceComp - // sort by symbol - return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1 + if (tokenA.symbol && tokenB.symbol) { + // sort by symbol + return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1 + } else { + return tokenA.symbol ? -1 : tokenB.symbol ? -1 : 0 + } } } export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number { - const { chainId } = useActiveWeb3React() - const weth = WETH[chainId] const balances = useAllTokenBalances() - const comparator = useMemo(() => getTokenComparator(weth, balances ?? {}), [balances, weth]) + const comparator = useMemo(() => getTokenComparator(balances ?? {}), [balances]) return useMemo(() => { if (inverted) { return (tokenA: Token, tokenB: Token) => comparator(tokenA, tokenB) * -1 diff --git a/src/components/SearchModal/styleds.tsx b/src/components/SearchModal/styleds.tsx index bed8ef6549..54d29a1585 100644 --- a/src/components/SearchModal/styleds.tsx +++ b/src/components/SearchModal/styleds.tsx @@ -17,12 +17,26 @@ export const FadedSpan = styled(RowFixed)` font-size: 14px; ` -export const GreySpan = styled.span` - color: ${({ theme }) => theme.text3}; - font-weight: 400; +export const PaddedColumn = styled(AutoColumn)` + padding: 20px; + padding-bottom: 12px; ` -export const Input = styled.input` +export const MenuItem = styled(RowBetween)` + padding: 4px 20px; + height: 56px; + display: grid; + grid-template-columns: auto minmax(auto, 1fr) auto minmax(0, 72px); + grid-gap: 16px; + cursor: ${({ disabled }) => !disabled && 'pointer'}; + pointer-events: ${({ disabled }) => disabled && 'none'}; + :hover { + background-color: ${({ theme, disabled }) => !disabled && theme.bg2}; + } + opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)}; +` + +export const SearchInput = styled.input` position: relative; display: flex; padding: 16px; @@ -43,28 +57,20 @@ export const Input = styled.input` ::placeholder { color: ${({ theme }) => theme.text3}; } -` - -export const PaddedColumn = styled(AutoColumn)` - padding: 20px; - padding-bottom: 12px; -` - -export const MenuItem = styled(RowBetween)` - padding: 4px 20px; - height: 56px; - cursor: ${({ disabled }) => !disabled && 'pointer'}; - pointer-events: ${({ disabled }) => disabled && 'none'}; - :hover { - background-color: ${({ theme, disabled }) => !disabled && theme.bg2}; - } - opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)}; -` - -export const SearchInput = styled(Input)` transition: border 100ms; :focus { border: 1px solid ${({ theme }) => theme.primary1}; outline: none; } ` +export const Separator = styled.div` + width: 100%; + height: 1px; + background-color: ${({ theme }) => theme.bg2}; +` + +export const SeparatorDark = styled.div` + width: 100%; + height: 1px; + background-color: ${({ theme }) => theme.bg3}; +` diff --git a/src/components/SearchModal/tsconfig.json b/src/components/SearchModal/tsconfig.json new file mode 100644 index 0000000000..638227fff6 --- /dev/null +++ b/src/components/SearchModal/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.strict.json", + "include": ["**/*"] +} \ No newline at end of file diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index 5dfdc4c987..da6ecfe513 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -1,14 +1,14 @@ -import React, { useRef, useEffect, useContext, useState } from 'react' +import React, { useRef, useContext, useState } from 'react' import { Settings, X } from 'react-feather' import styled from 'styled-components' - +import { useOnClickOutside } from '../../hooks/useOnClickOutside' import { useUserSlippageTolerance, useExpertModeManager, useUserDeadline, useDarkModeManager } from '../../state/user/hooks' -import SlippageTabs from '../SlippageTabs' +import TransactionSettings from '../TransactionSettings' import { RowFixed, RowBetween } from '../Row' import { TYPE } from '../../theme' import QuestionHelper from '../QuestionHelper' @@ -138,24 +138,7 @@ export default function SettingsTab() { // show confirmation view before turning on const [showConfirmation, setShowConfirmation] = useState(false) - useEffect(() => { - const handleClickOutside = e => { - if (node.current?.contains(e.target) ?? false) { - return - } - toggle() - } - - if (open) { - document.addEventListener('mousedown', handleClickOutside) - } else { - document.removeEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [open, toggle]) + useOnClickOutside(node, open ? toggle : undefined) return ( @@ -212,7 +195,7 @@ export default function SettingsTab() { Transaction Settings - ` +const StyledRangeInput = styled.input<{ size: number }>` -webkit-appearance: none; /* Hides the slider so that custom slider can be made */ width: 100%; /* Specific width is required for Firefox. */ background: transparent; /* Otherwise white in Chrome */ @@ -17,8 +17,8 @@ const StyledRangeInput = styled.input<{ value: number }>` &::-webkit-slider-thumb { -webkit-appearance: none; - height: 28px; - width: 28px; + height: ${({ size }) => size}px; + width: ${({ size }) => size}px; background-color: #565a69; border-radius: 100%; border: none; @@ -33,8 +33,8 @@ const StyledRangeInput = styled.input<{ value: number }>` } &::-moz-range-thumb { - height: 28px; - width: 28px; + height: ${({ size }) => size}px; + width: ${({ size }) => size}px; background-color: #565a69; border-radius: 100%; border: none; @@ -48,8 +48,8 @@ const StyledRangeInput = styled.input<{ value: number }>` } &::-ms-thumb { - height: 28px; - width: 28px; + height: ${({ size }) => size}px; + width: ${({ size }) => size}px; background-color: #565a69; border-radius: 100%; color: ${({ theme }) => theme.bg1}; @@ -62,24 +62,12 @@ const StyledRangeInput = styled.input<{ value: number }>` } &::-webkit-slider-runnable-track { - background: linear-gradient( - 90deg, - ${({ theme }) => theme.bg5}, - ${({ theme }) => theme.bg5} ${({ value }) => value}%, - ${({ theme }) => theme.bg3} ${({ value }) => value}%, - ${({ theme }) => theme.bg3} - ); + background: linear-gradient(90deg, ${({ theme }) => theme.bg5}, ${({ theme }) => theme.bg3}); height: 2px; } &::-moz-range-track { - background: linear-gradient( - 90deg, - ${({ theme }) => theme.bg5}, - ${({ theme }) => theme.bg5} ${({ value }) => value}%, - ${({ theme }) => theme.bg3} ${({ value }) => value}%, - ${({ theme }) => theme.bg3} - ); + background: linear-gradient(90deg, ${({ theme }) => theme.bg5}, ${({ theme }) => theme.bg3}); height: 2px; } @@ -102,26 +90,31 @@ const StyledRangeInput = styled.input<{ value: number }>` interface InputSliderProps { value: number onChange: (value: number) => void + step?: number + min?: number + max?: number + size?: number } -export default function InputSlider({ value, onChange }: InputSliderProps) { +export default function Slider({ value, onChange, min = 0, step = 1, max = 100, size = 28 }: InputSliderProps) { const changeCallback = useCallback( e => { - onChange(e.target.value) + onChange(parseInt(e.target.value)) }, [onChange] ) return ( ) } diff --git a/src/components/TokenWarningCard/index.tsx b/src/components/TokenWarningCard/index.tsx deleted file mode 100644 index 7432251f65..0000000000 --- a/src/components/TokenWarningCard/index.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Currency, Token } from '@uniswap/sdk' -import { transparentize } from 'polished' -import React, { useMemo } from 'react' -import styled from 'styled-components' -import { useActiveWeb3React } from '../../hooks' -import { useAllTokens } from '../../hooks/Tokens' -import { useDefaultTokenList } from '../../state/lists/hooks' -import { Field } from '../../state/swap/actions' -import { ExternalLink, TYPE } from '../../theme' -import { getEtherscanLink, isDefaultToken } from '../../utils' -import PropsOfExcluding from '../../utils/props-of-excluding' -import CurrencyLogo from '../CurrencyLogo' -import { AutoRow, RowBetween } from '../Row' -import { AutoColumn } from '../Column' -import { AlertTriangle } from 'react-feather' -import { ButtonError } from '../Button' -import { useTokenWarningDismissal } from '../../state/user/hooks' - -const Wrapper = styled.div<{ error: boolean }>` - background: ${({ theme }) => transparentize(0.6, theme.white)}; - padding: 0.75rem; - border-radius: 20px; -` - -const WarningContainer = styled.div` - max-width: 420px; - width: 100%; - padding: 1rem; - background: rgba(242, 150, 2, 0.05); - border: 1px solid #f3841e; - box-sizing: border-box; - border-radius: 20px; - margin-bottom: 2rem; -` - -const StyledWarningIcon = styled(AlertTriangle)` - stroke: ${({ theme }) => theme.red2}; -` - -interface TokenWarningCardProps extends PropsOfExcluding { - token?: Token -} - -export default function TokenWarningCard({ token, ...rest }: TokenWarningCardProps) { - const { chainId } = useActiveWeb3React() - const defaultTokens = useDefaultTokenList() - const isDefault = isDefaultToken(defaultTokens, token) - - const tokenSymbol = token?.symbol?.toLowerCase() ?? '' - const tokenName = token?.name?.toLowerCase() ?? '' - - const allTokens = useAllTokens() - - const duplicateNameOrSymbol = useMemo(() => { - if (isDefault || !token || !chainId) return false - - return Object.keys(allTokens).some(tokenAddress => { - const userToken = allTokens[tokenAddress] - if (userToken.equals(token)) { - return false - } - return userToken.symbol.toLowerCase() === tokenSymbol || userToken.name.toLowerCase() === tokenName - }) - }, [isDefault, token, chainId, allTokens, tokenSymbol, tokenName]) - - if (isDefault || !token) return null - - return ( - - - - -
-
- - - {token && token.name && token.symbol && token.name !== token.symbol - ? `${token.name} (${token.symbol})` - : token.name || token.symbol} - - - (View on Etherscan) - - -
-
- ) -} - -export function TokenWarningCards({ currencies }: { currencies: { [field in Field]?: Currency } }) { - const { chainId } = useActiveWeb3React() - const [dismissedToken0, dismissToken0] = useTokenWarningDismissal(chainId, currencies[Field.INPUT]) - const [dismissedToken1, dismissToken1] = useTokenWarningDismissal(chainId, currencies[Field.OUTPUT]) - - return ( - - - - - Token imported - - - Anyone can create and name any ERC20 token on Ethereum, including creating fake versions of existing tokens - and tokens that claim to represent projects that do not have a token. - - - Similar to Etherscan, this site can load arbitrary tokens via token addresses. Please do your own research - before interacting with any ERC20 token. - - {Object.keys(currencies).map(field => { - const dismissed = field === Field.INPUT ? dismissedToken0 : dismissedToken1 - return currencies[field] instanceof Token && !dismissed ? ( - - ) : null - })} - -
- { - dismissToken0 && dismissToken0() - dismissToken1 && dismissToken1() - }} - > - - I understand - - -
- - - - ) -} diff --git a/src/components/TokenWarningModal/index.tsx b/src/components/TokenWarningModal/index.tsx new file mode 100644 index 0000000000..53baf3850c --- /dev/null +++ b/src/components/TokenWarningModal/index.tsx @@ -0,0 +1,151 @@ +import { Token } from '@uniswap/sdk' +import { transparentize } from 'polished' +import React, { useCallback, useMemo, useState } from 'react' +import styled from 'styled-components' +import { useActiveWeb3React } from '../../hooks' +import { useAllTokens } from '../../hooks/Tokens' +import { ExternalLink, TYPE } from '../../theme' +import { getEtherscanLink, shortenAddress } from '../../utils' +import CurrencyLogo from '../CurrencyLogo' +import Modal from '../Modal' +import { AutoRow, RowBetween } from '../Row' +import { AutoColumn } from '../Column' +import { AlertTriangle } from 'react-feather' +import { ButtonError } from '../Button' + +const Wrapper = styled.div<{ error: boolean }>` + background: ${({ theme }) => transparentize(0.6, theme.bg3)}; + padding: 0.75rem; + border-radius: 20px; +` + +const WarningContainer = styled.div` + max-width: 420px; + width: 100%; + padding: 1rem; + background: rgba(242, 150, 2, 0.05); + border: 1px solid #f3841e; + border-radius: 20px; + overflow: auto; +` + +const StyledWarningIcon = styled(AlertTriangle)` + stroke: ${({ theme }) => theme.red2}; +` + +interface TokenWarningCardProps { + token?: Token +} + +function TokenWarningCard({ token }: TokenWarningCardProps) { + const { chainId } = useActiveWeb3React() + + const tokenSymbol = token?.symbol?.toLowerCase() ?? '' + const tokenName = token?.name?.toLowerCase() ?? '' + + const allTokens = useAllTokens() + + const duplicateNameOrSymbol = useMemo(() => { + if (!token || !chainId) return false + + return Object.keys(allTokens).some(tokenAddress => { + const userToken = allTokens[tokenAddress] + if (userToken.equals(token)) { + return false + } + return userToken.symbol.toLowerCase() === tokenSymbol || userToken.name.toLowerCase() === tokenName + }) + }, [token, chainId, allTokens, tokenSymbol, tokenName]) + + if (!token) return null + + return ( + + + + +
+
+ + + {token && token.name && token.symbol && token.name !== token.symbol + ? `${token.name} (${token.symbol})` + : token.name || token.symbol}{' '} + + + {shortenAddress(token.address)} (View on Etherscan) + + +
+
+ ) +} + +export default function TokenWarningModal({ + isOpen, + tokens, + onConfirm +}: { + isOpen: boolean + tokens: Token[] + onConfirm: () => void +}) { + const [understandChecked, setUnderstandChecked] = useState(false) + const toggleUnderstand = useCallback(() => setUnderstandChecked(uc => !uc), []) + + const handleDismiss = useCallback(() => null, []) + return ( + + + + + + Token imported + + + Anyone can create an ERC20 token on Ethereum with any name, including creating fake versions of + existing tokens and tokens that claim to represent projects that do not have a token. + + + This interface can load arbitrary tokens by token addresses. Please take extra caution and do your research + when interacting with arbitrary ERC20 tokens. + + + If you purchase an arbitrary token, you may be unable to sell it back. + + {tokens.map(token => { + return + })} + +
+ +
+ { + onConfirm() + }} + > + Continue + +
+
+
+
+ ) +} diff --git a/src/components/SlippageTabs/index.tsx b/src/components/TransactionSettings/index.tsx similarity index 100% rename from src/components/SlippageTabs/index.tsx rename to src/components/TransactionSettings/index.tsx diff --git a/src/components/swap/styleds.tsx b/src/components/swap/styleds.tsx index bfc98be689..66dcc55c6d 100644 --- a/src/components/swap/styleds.tsx +++ b/src/components/swap/styleds.tsx @@ -30,7 +30,7 @@ export const SectionBreak = styled.div` ` export const BottomGrouping = styled.div` - margin-top: 12px; + margin-top: 1rem; ` export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>` diff --git a/src/connectors/NetworkConnector.ts b/src/connectors/NetworkConnector.ts index 8e27145996..7d0da69bfc 100644 --- a/src/connectors/NetworkConnector.ts +++ b/src/connectors/NetworkConnector.ts @@ -152,6 +152,10 @@ export class NetworkConnector extends AbstractConnector { }, {}) } + public get provider(): MiniRpcProvider { + return this.providers[this.currentChainId] + } + public async activate(): Promise { return { provider: this.providers[this.currentChainId], chainId: this.currentChainId, account: null } } diff --git a/src/connectors/index.ts b/src/connectors/index.ts index fe62211a56..5216b83a02 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -1,3 +1,4 @@ +import { Web3Provider } from '@ethersproject/providers' import { InjectedConnector } from '@web3-react/injected-connector' import { WalletConnectConnector } from '@web3-react/walletconnect-connector' import { WalletLinkConnector } from '@web3-react/walletlink-connector' @@ -10,14 +11,21 @@ const NETWORK_URL = process.env.REACT_APP_NETWORK_URL const FORMATIC_KEY = process.env.REACT_APP_FORTMATIC_KEY const PORTIS_ID = process.env.REACT_APP_PORTIS_ID +export const NETWORK_CHAIN_ID: number = parseInt(process.env.REACT_APP_CHAIN_ID ?? '1') + if (typeof NETWORK_URL === 'undefined') { throw new Error(`REACT_APP_NETWORK_URL must be a defined environment variable`) } export const network = new NetworkConnector({ - urls: { [Number(process.env.REACT_APP_CHAIN_ID)]: NETWORK_URL } + urls: { [NETWORK_CHAIN_ID]: NETWORK_URL } }) +let networkLibrary: Web3Provider | undefined +export function getNetworkLibrary(): Web3Provider { + return (networkLibrary = networkLibrary ?? new Web3Provider(network.provider as any)) +} + export const injected = new InjectedConnector({ supportedChainIds: [1, 3, 4, 5, 42] }) diff --git a/src/constants/abis/ens-public-resolver.json b/src/constants/abis/ens-public-resolver.json new file mode 100644 index 0000000000..dccd55d9c0 --- /dev/null +++ b/src/constants/abis/ens-public-resolver.json @@ -0,0 +1,816 @@ +[ + { + "inputs": [ + { + "internalType": "contract ENS", + "name": "_ens", + "type": "address" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "contentType", + "type": "uint256" + } + ], + "name": "ABIChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "a", + "type": "address" + } + ], + "name": "AddrChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "coinType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "newAddress", + "type": "bytes" + } + ], + "name": "AddressChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isAuthorised", + "type": "bool" + } + ], + "name": "AuthorisationChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "hash", + "type": "bytes" + } + ], + "name": "ContenthashChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "name", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "resource", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "record", + "type": "bytes" + } + ], + "name": "DNSRecordChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "name", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "resource", + "type": "uint16" + } + ], + "name": "DNSRecordDeleted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "DNSZoneCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + }, + { + "indexed": false, + "internalType": "address", + "name": "implementer", + "type": "address" + } + ], + "name": "InterfaceChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "NameChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "name": "PubkeyChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "string", + "name": "indexedKey", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "key", + "type": "string" + } + ], + "name": "TextChanged", + "type": "event" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "contentTypes", + "type": "uint256" + } + ], + "name": "ABI", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "addr", + "outputs": [ + { + "internalType": "address payable", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorisations", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "clearDNSZone", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "contenthash", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "name", + "type": "bytes32" + }, + { + "internalType": "uint16", + "name": "resource", + "type": "uint16" + } + ], + "name": "dnsRecord", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "name", + "type": "bytes32" + } + ], + "name": "hasDNSRecords", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + } + ], + "name": "interfaceImplementer", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "pubkey", + "outputs": [ + { + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "contentType", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "setABI", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "coinType", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "a", + "type": "bytes" + } + ], + "name": "setAddr", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "a", + "type": "address" + } + ], + "name": "setAddr", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bool", + "name": "isAuthorised", + "type": "bool" + } + ], + "name": "setAuthorisation", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "hash", + "type": "bytes" + } + ], + "name": "setContenthash", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "setDNSRecords", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + }, + { + "internalType": "address", + "name": "implementer", + "type": "address" + } + ], + "name": "setInterface", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "setName", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "name": "setPubkey", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "key", + "type": "string" + }, + { + "internalType": "string", + "name": "value", + "type": "string" + } + ], + "name": "setText", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "pure", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "key", + "type": "string" + } + ], + "name": "text", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/src/constants/abis/ens-registrar.json b/src/constants/abis/ens-registrar.json new file mode 100644 index 0000000000..ad902ecc86 --- /dev/null +++ b/src/constants/abis/ens-registrar.json @@ -0,0 +1,422 @@ +[ + { + "inputs": [ + { + "internalType": "contract ENS", + "name": "_old", + "type": "address" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "label", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "NewOwner", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "resolver", + "type": "address" + } + ], + "name": "NewResolver", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "ttl", + "type": "uint64" + } + ], + "name": "NewTTL", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "old", + "outputs": [ + { + "internalType": "contract ENS", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "recordExists", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "resolver", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "setOwner", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "resolver", + "type": "address" + }, + { + "internalType": "uint64", + "name": "ttl", + "type": "uint64" + } + ], + "name": "setRecord", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "resolver", + "type": "address" + } + ], + "name": "setResolver", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "label", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "setSubnodeOwner", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "label", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "resolver", + "type": "address" + }, + { + "internalType": "uint64", + "name": "ttl", + "type": "uint64" + } + ], + "name": "setSubnodeRecord", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint64", + "name": "ttl", + "type": "uint64" + } + ], + "name": "setTTL", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "ttl", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/src/constants/index.ts b/src/constants/index.ts index 9fea728cf9..e99a6ed8cd 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,5 +1,5 @@ -import { AbstractConnector } from '@web3-react/abstract-connector' import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk' +import { AbstractConnector } from '@web3-react/abstract-connector' import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors' @@ -162,6 +162,3 @@ export const BLOCKED_PRICE_IMPACT_NON_EXPERT: Percent = new Percent(JSBI.BigInt( // used to ensure the user doesn't send so much ETH so they end up with <.01 export const MIN_ETH: JSBI = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(16)) // .01 ETH export const BETTER_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000)) - -// the Uniswap Default token list lives here -export const DEFAULT_TOKEN_LIST_URL = 'https://unpkg.com/@uniswap/default-token-list@latest' diff --git a/src/constants/lists.ts b/src/constants/lists.ts new file mode 100644 index 0000000000..c9fb736e60 --- /dev/null +++ b/src/constants/lists.ts @@ -0,0 +1,14 @@ +// the Uniswap Default token list lives here +export const DEFAULT_TOKEN_LIST_URL = 'tokens.uniswap.eth' + +export const DEFAULT_LIST_OF_LISTS: string[] = [ + DEFAULT_TOKEN_LIST_URL, + 't2crtokens.eth', // kleros + 'tokens.1inch.eth', // 1inch + 'synths.snx.eth', + 'tokenlist.dharma.eth', + 'defi.cmc.eth', + 'erc20.cmc.eth', + 'https://defiprime.com/defiprime.tokenlist.json', + 'https://umaproject.org/uma.tokenlist.json' +] diff --git a/src/hooks/Tokens.ts b/src/hooks/Tokens.ts index f58eecef0e..57fa3a7c3e 100644 --- a/src/hooks/Tokens.ts +++ b/src/hooks/Tokens.ts @@ -1,7 +1,7 @@ import { parseBytes32String } from '@ethersproject/strings' import { Currency, ETHER, Token } from '@uniswap/sdk' import { useMemo } from 'react' -import { useDefaultTokenList } from '../state/lists/hooks' +import { useSelectedTokenList } from '../state/lists/hooks' import { NEVER_RELOAD, useSingleCallResult } from '../state/multicall/hooks' import { useUserAddedTokens } from '../state/user/hooks' import { isAddress } from '../utils' @@ -12,7 +12,7 @@ import { useBytes32TokenContract, useTokenContract } from './useContract' export function useAllTokens(): { [address: string]: Token } { const { chainId } = useActiveWeb3React() const userAddedTokens = useUserAddedTokens() - const allTokens = useDefaultTokenList() + const allTokens = useSelectedTokenList() return useMemo(() => { if (!chainId) return {} diff --git a/src/hooks/Trades.ts b/src/hooks/Trades.ts index fad891be36..0b2c55c00a 100644 --- a/src/hooks/Trades.ts +++ b/src/hooks/Trades.ts @@ -17,35 +17,46 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] { ? [wrappedCurrency(currencyA, chainId), wrappedCurrency(currencyB, chainId)] : [undefined, undefined] + const basePairs: [Token, Token][] = useMemo( + () => + flatMap(bases, (base): [Token, Token][] => bases.map(otherBase => [base, otherBase])).filter( + ([t0, t1]) => t0.address !== t1.address + ), + [bases] + ) + const allPairCombinations: [Token, Token][] = useMemo( () => - [ - // the direct pair - [tokenA, tokenB], - // token A against all bases - ...bases.map((base): [Token | undefined, Token | undefined] => [tokenA, base]), - // token B against all bases - ...bases.map((base): [Token | undefined, Token | undefined] => [tokenB, base]), - // each base against all bases - ...flatMap(bases, (base): [Token, Token][] => bases.map(otherBase => [base, otherBase])) - ] - .filter((tokens): tokens is [Token, Token] => Boolean(tokens[0] && tokens[1])) - .filter(([tokenA, tokenB]) => { - if (!chainId) return true - const customBases = CUSTOM_BASES[chainId] - if (!customBases) return true + tokenA && tokenB + ? [ + // the direct pair + [tokenA, tokenB], + // token A against all bases + ...bases.map((base): [Token, Token] => [tokenA, base]), + // token B against all bases + ...bases.map((base): [Token, Token] => [tokenB, base]), + // each base against all bases + ...basePairs + ] + .filter((tokens): tokens is [Token, Token] => Boolean(tokens[0] && tokens[1])) + .filter(([t0, t1]) => t0.address !== t1.address) + .filter(([tokenA, tokenB]) => { + if (!chainId) return true + const customBases = CUSTOM_BASES[chainId] + if (!customBases) return true - const customBasesA: Token[] | undefined = customBases[tokenA.address] - const customBasesB: Token[] | undefined = customBases[tokenB.address] + const customBasesA: Token[] | undefined = customBases[tokenA.address] + const customBasesB: Token[] | undefined = customBases[tokenB.address] - if (!customBasesA && !customBasesB) return true + if (!customBasesA && !customBasesB) return true - if (customBasesA && customBasesA.findIndex(base => tokenB.equals(base)) === -1) return false - if (customBasesB && customBasesB.findIndex(base => tokenA.equals(base)) === -1) return false + if (customBasesA && !customBasesA.find(base => tokenB.equals(base))) return false + if (customBasesB && !customBasesB.find(base => tokenA.equals(base))) return false - return true - }), - [tokenA, tokenB, bases, chainId] + return true + }) + : [], + [tokenA, tokenB, bases, basePairs, chainId] ) const allPairs = usePairs(allPairCombinations) @@ -72,7 +83,6 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] { */ export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: Currency): Trade | null { const allowedPairs = useAllCommonPairs(currencyAmountIn?.currency, currencyOut) - return useMemo(() => { if (currencyAmountIn && currencyOut && allowedPairs.length > 0) { return ( diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index a04476ad61..a806d1efc9 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -2,18 +2,20 @@ import { Contract } from '@ethersproject/contracts' import { ChainId, WETH } from '@uniswap/sdk' import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json' import { useMemo } from 'react' +import ENS_ABI from '../constants/abis/ens-registrar.json' +import ENS_PUBLIC_RESOLVER_ABI from '../constants/abis/ens-public-resolver.json' import { ERC20_BYTES32_ABI } from '../constants/abis/erc20' -import UNISOCKS_ABI from '../constants/abis/unisocks.json' import ERC20_ABI from '../constants/abis/erc20.json' -import WETH_ABI from '../constants/abis/weth.json' import { MIGRATOR_ABI, MIGRATOR_ADDRESS } from '../constants/abis/migrator' +import UNISOCKS_ABI from '../constants/abis/unisocks.json' +import WETH_ABI from '../constants/abis/weth.json' import { MULTICALL_ABI, MULTICALL_NETWORKS } from '../constants/multicall' import { V1_EXCHANGE_ABI, V1_FACTORY_ABI, V1_FACTORY_ADDRESSES } from '../constants/v1' import { getContract } from '../utils' import { useActiveWeb3React } from './index' // returns null on errors -function useContract(address?: string, ABI?: any, withSignerIfPossible = true): Contract | null { +function useContract(address: string | undefined, ABI: any, withSignerIfPossible = true): Contract | null { const { library, account } = useActiveWeb3React() return useMemo(() => { @@ -49,6 +51,26 @@ export function useWETHContract(withSignerIfPossible?: boolean): Contract | null return useContract(chainId ? WETH[chainId].address : undefined, WETH_ABI, withSignerIfPossible) } +export function useENSRegistrarContract(withSignerIfPossible?: boolean): Contract | null { + const { chainId } = useActiveWeb3React() + let address: string | undefined + if (chainId) { + switch (chainId) { + case ChainId.MAINNET: + case ChainId.GÖRLI: + case ChainId.ROPSTEN: + case ChainId.RINKEBY: + address = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' + break + } + } + return useContract(address, ENS_ABI, withSignerIfPossible) +} + +export function useENSResolverContract(address: string | undefined, withSignerIfPossible?: boolean): Contract | null { + return useContract(address, ENS_PUBLIC_RESOLVER_ABI, withSignerIfPossible) +} + export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible?: boolean): Contract | null { return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible) } diff --git a/src/hooks/useENSAddress.ts b/src/hooks/useENSAddress.ts index 05d6a13df4..08b4b41a98 100644 --- a/src/hooks/useENSAddress.ts +++ b/src/hooks/useENSAddress.ts @@ -1,46 +1,30 @@ -import { useEffect, useState } from 'react' -import { useActiveWeb3React } from './index' +import { namehash } from 'ethers/lib/utils' +import { useMemo } from 'react' +import { useSingleCallResult } from '../state/multicall/hooks' +import { useENSRegistrarContract, useENSResolverContract } from './useContract' +import useDebounce from './useDebounce' /** * Does a lookup for an ENS name to find its address. */ export default function useENSAddress(ensName?: string | null): { loading: boolean; address: string | null } { - const { library } = useActiveWeb3React() - - const [address, setAddress] = useState<{ loading: boolean; address: string | null }>({ - loading: false, - address: null - }) - - useEffect(() => { - if (!library || typeof ensName !== 'string') { - setAddress({ loading: false, address: null }) - return - } else { - let stale = false - setAddress({ loading: true, address: null }) - library - .resolveName(ensName) - .then(address => { - if (!stale) { - if (address) { - setAddress({ loading: false, address }) - } else { - setAddress({ loading: false, address: null }) - } - } - }) - .catch(() => { - if (!stale) { - setAddress({ loading: false, address: null }) - } - }) - - return () => { - stale = true - } + const debouncedName = useDebounce(ensName, 200) + const ensNodeArgument = useMemo(() => { + if (!debouncedName) return [undefined] + try { + return debouncedName ? [namehash(debouncedName)] : [undefined] + } catch (error) { + return [undefined] } - }, [library, ensName]) + }, [debouncedName]) + const registrarContract = useENSRegistrarContract(false) + const resolverAddress = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument) + const resolverContract = useENSResolverContract(resolverAddress.result?.[0], false) + const addr = useSingleCallResult(resolverContract, 'addr', ensNodeArgument) - return address + const changed = debouncedName !== ensName + return { + address: changed ? null : addr.result?.[0] ?? null, + loading: changed || resolverAddress.loading || addr.loading + } } diff --git a/src/hooks/useENSContentHash.ts b/src/hooks/useENSContentHash.ts new file mode 100644 index 0000000000..2ed78158a9 --- /dev/null +++ b/src/hooks/useENSContentHash.ts @@ -0,0 +1,32 @@ +import { namehash } from 'ethers/lib/utils' +import { useMemo } from 'react' +import { useSingleCallResult } from '../state/multicall/hooks' +import isZero from '../utils/isZero' +import { useENSRegistrarContract, useENSResolverContract } from './useContract' + +/** + * Does a lookup for an ENS name to find its contenthash. + */ +export default function useENSContentHash(ensName?: string | null): { loading: boolean; contenthash: string | null } { + const ensNodeArgument = useMemo(() => { + if (!ensName) return [undefined] + try { + return ensName ? [namehash(ensName)] : [undefined] + } catch (error) { + return [undefined] + } + }, [ensName]) + const registrarContract = useENSRegistrarContract(false) + const resolverAddressResult = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument) + const resolverAddress = resolverAddressResult.result?.[0] + const resolverContract = useENSResolverContract( + resolverAddress && isZero(resolverAddress) ? undefined : resolverAddress, + false + ) + const contenthash = useSingleCallResult(resolverContract, 'contenthash', ensNodeArgument) + + return { + contenthash: contenthash.result?.[0] ?? null, + loading: resolverAddressResult.loading || contenthash.loading + } +} diff --git a/src/hooks/useENSName.ts b/src/hooks/useENSName.ts index 249c520d1a..ee15377149 100644 --- a/src/hooks/useENSName.ts +++ b/src/hooks/useENSName.ts @@ -1,49 +1,37 @@ -import { useEffect, useState } from 'react' +import { namehash } from 'ethers/lib/utils' +import { useMemo } from 'react' +import { useSingleCallResult } from '../state/multicall/hooks' import { isAddress } from '../utils' -import { useActiveWeb3React } from './index' +import isZero from '../utils/isZero' +import { useENSRegistrarContract, useENSResolverContract } from './useContract' +import useDebounce from './useDebounce' /** * Does a reverse lookup for an address to find its ENS name. * Note this is not the same as looking up an ENS name to find an address. */ export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } { - const { library } = useActiveWeb3React() - - const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({ - loading: false, - ENSName: null - }) - - useEffect(() => { - const validated = isAddress(address) - if (!library || !validated) { - setENSName({ loading: false, ENSName: null }) - return - } else { - let stale = false - setENSName({ loading: true, ENSName: null }) - library - .lookupAddress(validated) - .then(name => { - if (!stale) { - if (name) { - setENSName({ loading: false, ENSName: name }) - } else { - setENSName({ loading: false, ENSName: null }) - } - } - }) - .catch(() => { - if (!stale) { - setENSName({ loading: false, ENSName: null }) - } - }) - - return () => { - stale = true - } + const debouncedAddress = useDebounce(address, 200) + const ensNodeArgument = useMemo(() => { + if (!debouncedAddress || !isAddress(debouncedAddress)) return [undefined] + try { + return debouncedAddress ? [namehash(`${debouncedAddress.toLowerCase().substr(2)}.addr.reverse`)] : [undefined] + } catch (error) { + return [undefined] } - }, [library, address]) + }, [debouncedAddress]) + const registrarContract = useENSRegistrarContract(false) + const resolverAddress = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument) + const resolverAddressResult = resolverAddress.result?.[0] + const resolverContract = useENSResolverContract( + resolverAddressResult && !isZero(resolverAddressResult) ? resolverAddressResult : undefined, + false + ) + const name = useSingleCallResult(resolverContract, 'name', ensNodeArgument) - return ENSName + const changed = debouncedAddress !== address + return { + ENSName: changed ? null : name.result?.[0] ?? null, + loading: changed || resolverAddress.loading || name.loading + } } diff --git a/src/hooks/useFetchListCallback.ts b/src/hooks/useFetchListCallback.ts new file mode 100644 index 0000000000..095529d236 --- /dev/null +++ b/src/hooks/useFetchListCallback.ts @@ -0,0 +1,50 @@ +import { nanoid } from '@reduxjs/toolkit' +import { ChainId } from '@uniswap/sdk' +import { TokenList } from '@uniswap/token-lists' +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { getNetworkLibrary, NETWORK_CHAIN_ID } from '../connectors' +import { AppDispatch } from '../state' +import { fetchTokenList } from '../state/lists/actions' +import getTokenList from '../utils/getTokenList' +import resolveENSContentHash from '../utils/resolveENSContentHash' +import { useActiveWeb3React } from './index' + +export function useFetchListCallback(): (listUrl: string) => Promise { + const { chainId, library } = useActiveWeb3React() + const dispatch = useDispatch() + + const ensResolver = useCallback( + (ensName: string) => { + if (!library || chainId !== ChainId.MAINNET) { + if (NETWORK_CHAIN_ID === ChainId.MAINNET) { + const networkLibrary = getNetworkLibrary() + if (networkLibrary) { + return resolveENSContentHash(ensName, networkLibrary) + } + } + throw new Error('Could not construct mainnet ENS resolver') + } + return resolveENSContentHash(ensName, library) + }, + [chainId, library] + ) + + return useCallback( + async (listUrl: string) => { + const requestId = nanoid() + dispatch(fetchTokenList.pending({ requestId, url: listUrl })) + return getTokenList(listUrl, ensResolver) + .then(tokenList => { + dispatch(fetchTokenList.fulfilled({ url: listUrl, tokenList, requestId })) + return tokenList + }) + .catch(error => { + console.debug(`Failed to get list at url ${listUrl}`, error) + dispatch(fetchTokenList.rejected({ url: listUrl, requestId, errorMessage: error.message })) + throw error + }) + }, + [dispatch, ensResolver] + ) +} diff --git a/src/hooks/useHttpLocations.ts b/src/hooks/useHttpLocations.ts new file mode 100644 index 0000000000..c00aa087e0 --- /dev/null +++ b/src/hooks/useHttpLocations.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import contenthashToUri from '../utils/contenthashToUri' +import { parseENSAddress } from '../utils/parseENSAddress' +import uriToHttp from '../utils/uriToHttp' +import useENSContentHash from './useENSContentHash' + +export default function useHttpLocations(uri: string | undefined): string[] { + const ens = useMemo(() => (uri ? parseENSAddress(uri) : undefined), [uri]) + const resolvedContentHash = useENSContentHash(ens?.ensName) + return useMemo(() => { + if (ens) { + return resolvedContentHash.contenthash ? uriToHttp(contenthashToUri(resolvedContentHash.contenthash)) : [] + } else { + return uri ? uriToHttp(uri) : [] + } + }, [ens, resolvedContentHash.contenthash, uri]) +} diff --git a/src/hooks/useOnClickOutside.tsx b/src/hooks/useOnClickOutside.tsx new file mode 100644 index 0000000000..0307fe9d02 --- /dev/null +++ b/src/hooks/useOnClickOutside.tsx @@ -0,0 +1,26 @@ +import { RefObject, useEffect, useRef } from 'react' + +export function useOnClickOutside( + node: RefObject, + handler: undefined | (() => void) +) { + const handlerRef = useRef void)>(handler) + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (node.current?.contains(e.target as Node) ?? false) { + return + } + if (handlerRef.current) handlerRef.current() + } + + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [node]) +} diff --git a/src/index.tsx b/src/index.tsx index e73c637108..87f7a441d8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,21 +1,21 @@ -import { Web3Provider } from '@ethersproject/providers' import { createWeb3ReactRoot, Web3ReactProvider } from '@web3-react/core' -import React from 'react' +import 'inter-ui' +import React, { StrictMode } from 'react' import { isMobile } from 'react-device-detect' import ReactDOM from 'react-dom' import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { NetworkContextName } from './constants' -import 'inter-ui' import './i18n' import App from './pages/App' import store from './state' import ApplicationUpdater from './state/application/updater' -import TransactionUpdater from './state/transactions/updater' import ListsUpdater from './state/lists/updater' -import UserUpdater from './state/user/updater' import MulticallUpdater from './state/multicall/updater' +import TransactionUpdater from './state/transactions/updater' +import UserUpdater from './state/user/updater' import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme' +import getLibrary from './utils/getLibrary' const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName) @@ -23,12 +23,6 @@ if ('ethereum' in window) { ;(window.ethereum as any).autoRefreshOnNetworkChange = false } -function getLibrary(provider: any): Web3Provider { - const library = new Web3Provider(provider) - library.pollingInterval = 15000 - return library -} - const GOOGLE_ANALYTICS_ID: string | undefined = process.env.REACT_APP_GOOGLE_ANALYTICS_ID if (typeof GOOGLE_ANALYTICS_ID === 'string') { ReactGA.initialize(GOOGLE_ANALYTICS_ID) @@ -59,21 +53,19 @@ function Updaters() { } ReactDOM.render( - <> + - <> - - - + + - , + , document.getElementById('root') ) diff --git a/src/pages/AppBody.tsx b/src/pages/AppBody.tsx index 3c80b43994..2e9c4a368e 100644 --- a/src/pages/AppBody.tsx +++ b/src/pages/AppBody.tsx @@ -1,7 +1,7 @@ import React from 'react' import styled from 'styled-components' -export const BodyWrapper = styled.div<{ disabled?: boolean }>` +export const BodyWrapper = styled.div` position: relative; max-width: 420px; width: 100%; @@ -10,13 +10,11 @@ export const BodyWrapper = styled.div<{ disabled?: boolean }>` 0px 24px 32px rgba(0, 0, 0, 0.01); border-radius: 30px; padding: 1rem; - opacity: ${({ disabled }) => (disabled ? '0.4' : '1')}; - pointer-events: ${({ disabled }) => disabled && 'none'}; ` /** * The styled container element that wraps the content of most pages and the tabs. */ -export default function AppBody({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) { - return {children} +export default function AppBody({ children }: { children: React.ReactNode }) { + return {children} } diff --git a/src/pages/MigrateV1/index.tsx b/src/pages/MigrateV1/index.tsx index 3c1406c924..e3fb676fd1 100644 --- a/src/pages/MigrateV1/index.tsx +++ b/src/pages/MigrateV1/index.tsx @@ -7,7 +7,7 @@ import { SearchInput } from '../../components/SearchModal/styleds' import { useAllTokenV1Exchanges } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' import { useAllTokens, useToken } from '../../hooks/Tokens' -import { useDefaultTokenList } from '../../state/lists/hooks' +import { useSelectedTokenList } from '../../state/lists/hooks' import { useTokenBalancesWithLoadingIndicator } from '../../state/wallet/hooks' import { BackArrow, TYPE } from '../../theme' import { LightCard } from '../../components/Card' @@ -17,7 +17,7 @@ import V1PositionCard from '../../components/PositionCard/V1' import QuestionHelper from '../../components/QuestionHelper' import { Dots } from '../../components/swap/styleds' import { useAddUserToken } from '../../state/user/hooks' -import { isDefaultToken } from '../../utils' +import { isTokenOnList } from '../../utils' export default function MigrateV1() { const theme = useContext(ThemeContext) @@ -28,15 +28,15 @@ export default function MigrateV1() { // automatically add the search token const token = useToken(tokenSearch) - const defaultTokens = useDefaultTokenList() - const isDefault = isDefaultToken(defaultTokens, token) + const selectedTokenListTokens = useSelectedTokenList() + const isOnSelectedList = isTokenOnList(selectedTokenListTokens, token) const allTokens = useAllTokens() const addToken = useAddUserToken() useEffect(() => { - if (token && !isDefault && !allTokens[token.address]) { + if (token && !isOnSelectedList && !allTokens[token.address]) { addToken(token) } - }, [token, isDefault, addToken, allTokens]) + }, [token, isOnSelectedList, addToken, allTokens]) // get V1 LP balances const V1Exchanges = useAllTokenV1Exchanges() diff --git a/src/pages/PoolFinder/index.tsx b/src/pages/PoolFinder/index.tsx index e9b212c314..99617b4dec 100644 --- a/src/pages/PoolFinder/index.tsx +++ b/src/pages/PoolFinder/index.tsx @@ -185,7 +185,7 @@ export default function PoolFinder() { onCurrencySelect={handleCurrencySelect} onDismiss={handleSearchDismiss} showCommonBases - hiddenCurrency={(activeField === Fields.TOKEN0 ? currency1 : currency0) ?? undefined} + selectedCurrency={(activeField === Fields.TOKEN0 ? currency1 : currency0) ?? undefined} /> ) diff --git a/src/pages/RemoveLiquidity/index.tsx b/src/pages/RemoveLiquidity/index.tsx index ef4729967e..4024b3066b 100644 --- a/src/pages/RemoveLiquidity/index.tsx +++ b/src/pages/RemoveLiquidity/index.tsx @@ -29,6 +29,7 @@ import { useTransactionAdder } from '../../state/transactions/hooks' import { StyledInternalLink, TYPE } from '../../theme' import { calculateGasMargin, calculateSlippageAmount, getRouterContract } from '../../utils' import { currencyId } from '../../utils/currencyId' +import useDebouncedChangeHandler from '../../utils/useDebouncedChangeHandler' import { wrappedCurrency } from '../../utils/wrappedCurrency' import AppBody from '../AppBody' import { ClickableText, MaxButton, Wrapper } from '../Pool/styleds' @@ -458,6 +459,11 @@ export default function RemoveLiquidity({ setTxHash('') }, [onUserInput, txHash]) + const [innerLiquidityPercentage, setInnerLiquidityPercentage] = useDebouncedChangeHandler( + Number.parseInt(parsedAmounts[Field.LIQUIDITY_PERCENT].toFixed(0)), + liquidityPercentChangeCallback + ) + return ( <> @@ -499,10 +505,7 @@ export default function RemoveLiquidity({ {!showDetailed && ( <> - + onUserInput(Field.LIQUIDITY_PERCENT, '25')} width="20%"> 25% diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index b7b2534e2c..239249fe2a 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -1,5 +1,5 @@ -import { CurrencyAmount, JSBI, Trade } from '@uniswap/sdk' -import React, { useCallback, useContext, useEffect, useState } from 'react' +import { CurrencyAmount, JSBI, Token, Trade } from '@uniswap/sdk' +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { ArrowDown } from 'react-feather' import ReactGA from 'react-ga' import { Text } from 'rebass' @@ -17,11 +17,12 @@ import BetterTradeLink from '../../components/swap/BetterTradeLink' import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee' import { ArrowWrapper, BottomGrouping, Dots, SwapCallbackError, Wrapper } from '../../components/swap/styleds' import TradePrice from '../../components/swap/TradePrice' -import { TokenWarningCards } from '../../components/TokenWarningCard' +import TokenWarningModal from '../../components/TokenWarningModal' import { BETTER_TRADE_LINK_THRESHOLD, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' import { getTradeVersion, isTradeBetter } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' +import { useCurrency } from '../../hooks/Tokens' import { ApprovalState, useApproveCallbackFromTrade } from '../../hooks/useApproveCallback' import useENSAddress from '../../hooks/useENSAddress' import { useSwapCallback } from '../../hooks/useSwapCallback' @@ -35,12 +36,7 @@ import { useSwapActionHandlers, useSwapState } from '../../state/swap/hooks' -import { - useExpertModeManager, - useTokenWarningDismissal, - useUserDeadline, - useUserSlippageTolerance -} from '../../state/user/hooks' +import { useExpertModeManager, useUserDeadline, useUserSlippageTolerance } from '../../state/user/hooks' import { LinkStyledButton, TYPE } from '../../theme' import { maxAmountSpend } from '../../utils/maxAmountSpend' import { computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' @@ -48,9 +44,23 @@ import AppBody from '../AppBody' import { ClickableText } from '../Pool/styleds' export default function Swap() { - useDefaultsFromURLSearch() + const loadedUrlParams = useDefaultsFromURLSearch() - const { account, chainId } = useActiveWeb3React() + // token warning stuff + const [loadedInputCurrency, loadedOutputCurrency] = [ + useCurrency(loadedUrlParams?.inputCurrencyId), + useCurrency(loadedUrlParams?.outputCurrencyId) + ] + const [dismissTokenWarning, setDismissTokenWarning] = useState(false) + const urlLoadedTokens: Token[] = useMemo( + () => [loadedInputCurrency, loadedOutputCurrency]?.filter((c): c is Token => c instanceof Token) ?? [], + [loadedInputCurrency, loadedOutputCurrency] + ) + const handleConfirmTokenWarning = useCallback(() => { + setDismissTokenWarning(true) + }, []) + + const { account } = useActiveWeb3React() const theme = useContext(ThemeContext) // toggle wallet when disconnected @@ -230,11 +240,6 @@ export default function Swap() { (approvalSubmitted && approval === ApprovalState.APPROVED)) && !(priceImpactSeverity > 3 && !isExpertMode) - const [dismissedToken0] = useTokenWarningDismissal(chainId, currencies[Field.INPUT]) - const [dismissedToken1] = useTokenWarningDismissal(chainId, currencies[Field.OUTPUT]) - const showWarning = - (!dismissedToken0 && !!currencies[Field.INPUT]) || (!dismissedToken1 && !!currencies[Field.OUTPUT]) - const handleConfirmDismiss = useCallback(() => { setSwapState({ showConfirm: false, tradeToConfirm, attemptingTxn, swapErrorMessage, txHash }) // if there was a tx hash, we want to clear the input @@ -247,10 +252,30 @@ export default function Swap() { setSwapState({ tradeToConfirm: trade, swapErrorMessage, txHash, attemptingTxn, showConfirm }) }, [attemptingTxn, showConfirm, swapErrorMessage, trade, txHash]) + const handleInputSelect = useCallback( + inputCurrency => { + setApprovalSubmitted(false) // reset 2 step UI for approvals + onCurrencySelection(Field.INPUT, inputCurrency) + }, + [onCurrencySelection] + ) + + const handleMaxInput = useCallback(() => { + maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) + }, [maxAmountInput, onUserInput]) + + const handleOutputSelect = useCallback(outputCurrency => onCurrencySelection(Field.OUTPUT, outputCurrency), [ + onCurrencySelection + ]) + return ( <> - {showWarning && } - + 0 && !dismissTokenWarning} + tokens={urlLoadedTokens} + onConfirm={handleConfirmTokenWarning} + /> + { - maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) - }} - onCurrencySelect={currency => { - setApprovalSubmitted(false) // reset 2 step UI for approvals - onCurrencySelection(Field.INPUT, currency) - }} + onMax={handleMaxInput} + onCurrencySelect={handleInputSelect} otherCurrency={currencies[Field.OUTPUT]} id="swap-currency-input" /> @@ -310,7 +330,7 @@ export default function Swap() { label={independentField === Field.INPUT && !showWrap ? 'To (estimated)' : 'To'} showMaxButton={false} currency={currencies[Field.OUTPUT]} - onCurrencySelect={address => onCurrencySelection(Field.OUTPUT, address)} + onCurrencySelect={handleOutputSelect} otherCurrency={currencies[Field.INPUT]} id="swap-currency-output" /> @@ -351,7 +371,7 @@ export default function Swap() { Slippage Tolerance - {allowedSlippage ? allowedSlippage / 100 : '-'}% + {allowedSlippage / 100}% )} diff --git a/src/state/application/actions.ts b/src/state/application/actions.ts index 325fed6dbd..92cf02d5b9 100644 --- a/src/state/application/actions.ts +++ b/src/state/application/actions.ts @@ -21,5 +21,5 @@ export type PopupContent = export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('updateBlockNumber') export const toggleWalletModal = createAction('toggleWalletModal') export const toggleSettingsMenu = createAction('toggleSettingsMenu') -export const addPopup = createAction<{ key?: string; content: PopupContent }>('addPopup') +export const addPopup = createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('addPopup') export const removePopup = createAction<{ key: string }>('removePopup') diff --git a/src/state/application/reducer.test.ts b/src/state/application/reducer.test.ts index 0ddd2d3345..9cad4176e5 100644 --- a/src/state/application/reducer.test.ts +++ b/src/state/application/reducer.test.ts @@ -25,6 +25,7 @@ describe('application reducer', () => { expect(typeof list[0].key).toEqual('string') expect(list[0].show).toEqual(true) expect(list[0].content).toEqual({ txn: { hash: 'abc', summary: 'test', success: true } }) + expect(list[0].removeAfterMs).toEqual(15000) }) it('replaces any existing popups with the same key', () => { @@ -35,6 +36,7 @@ describe('application reducer', () => { expect(list[0].key).toEqual('abc') expect(list[0].show).toEqual(true) expect(list[0].content).toEqual({ txn: { hash: 'def', summary: 'test2', success: false } }) + expect(list[0].removeAfterMs).toEqual(15000) }) }) diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index 89d90414c1..57c52880fe 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -8,7 +8,7 @@ import { updateBlockNumber } from './actions' -type PopupList = Array<{ key: string; show: boolean; content: PopupContent }> +type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }> export interface ApplicationState { blockNumber: { [chainId: number]: number } @@ -40,12 +40,13 @@ export default createReducer(initialState, builder => .addCase(toggleSettingsMenu, state => { state.settingsMenuOpen = !state.settingsMenuOpen }) - .addCase(addPopup, (state, { payload: { content, key } }) => { + .addCase(addPopup, (state, { payload: { content, key, removeAfterMs = 15000 } }) => { state.popupList = (key ? state.popupList.filter(popup => popup.key !== key) : state.popupList).concat([ { key: key || nanoid(), show: true, - content + content, + removeAfterMs } ]) }) diff --git a/src/state/index.ts b/src/state/index.ts index 2828e2dc17..6804a54d30 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -25,7 +25,7 @@ const store = configureStore({ multicall, lists }, - middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })], + middleware: [...getDefaultMiddleware({ thunk: false }), save({ states: PERSISTED_KEYS })], preloadedState: load({ states: PERSISTED_KEYS }) }) diff --git a/src/state/lists/actions.ts b/src/state/lists/actions.ts index cd0752dd45..9114ad540b 100644 --- a/src/state/lists/actions.ts +++ b/src/state/lists/actions.ts @@ -1,54 +1,18 @@ -import { createAction, createAsyncThunk } from '@reduxjs/toolkit' +import { ActionCreatorWithPayload, createAction } from '@reduxjs/toolkit' import { TokenList, Version } from '@uniswap/token-lists' -import schema from '@uniswap/token-lists/src/tokenlist.schema.json' -import Ajv from 'ajv' -import uriToHttp from '../../utils/uriToHttp' -const tokenListValidator = new Ajv({ allErrors: true }).compile(schema) - -/** - * Contains the logic for resolving a URL to a valid token list - * @param listUrl list url - */ -async function getTokenList(listUrl: string): Promise { - const urls = uriToHttp(listUrl) - for (const url of urls) { - let response - try { - response = await fetch(url) - if (!response.ok) continue - } catch (error) { - console.error(`failed to fetch list ${listUrl} at uri ${url}`) - continue - } - - const json = await response.json() - if (!tokenListValidator(json)) { - throw new Error( - tokenListValidator.errors?.reduce((memo, error) => { - const add = `${error.dataPath} ${error.message ?? ''}` - return memo.length > 0 ? `${memo}; ${add}` : `${add}` - }, '') ?? 'Token list failed validation' - ) - } - return json - } - throw new Error('Unrecognized list URL protocol.') +export const fetchTokenList: Readonly<{ + pending: ActionCreatorWithPayload<{ url: string; requestId: string }> + fulfilled: ActionCreatorWithPayload<{ url: string; tokenList: TokenList; requestId: string }> + rejected: ActionCreatorWithPayload<{ url: string; errorMessage: string; requestId: string }> +}> = { + pending: createAction('lists/fetchTokenList/pending'), + fulfilled: createAction('lists/fetchTokenList/fulfilled'), + rejected: createAction('lists/fetchTokenList/rejected') } -const fetchCache: { [url: string]: Promise } = {} -export const fetchTokenList = createAsyncThunk( - 'lists/fetchTokenList', - (url: string) => - // this makes it so we only ever fetch a list a single time concurrently - (fetchCache[url] = - fetchCache[url] ?? - getTokenList(url).catch(error => { - delete fetchCache[url] - throw error - })) -) - export const acceptListUpdate = createAction('lists/acceptListUpdate') export const addList = createAction('lists/addList') +export const removeList = createAction('lists/removeList') +export const selectList = createAction('lists/selectList') export const rejectVersionUpdate = createAction('lists/rejectVersionUpdate') diff --git a/src/state/lists/hooks.ts b/src/state/lists/hooks.ts index 9ec7cbe6b6..da63274c6b 100644 --- a/src/state/lists/hooks.ts +++ b/src/state/lists/hooks.ts @@ -1,18 +1,24 @@ import { ChainId, Token } from '@uniswap/sdk' -import { TokenInfo, TokenList } from '@uniswap/token-lists' +import { Tags, TokenInfo, TokenList } from '@uniswap/token-lists' import { useMemo } from 'react' import { useSelector } from 'react-redux' -import { DEFAULT_TOKEN_LIST_URL } from '../../constants' import { AppState } from '../index' +type TagDetails = Tags[keyof Tags] +export interface TagInfo extends TagDetails { + id: string +} + /** * Token instances created from token info. */ export class WrappedTokenInfo extends Token { public readonly tokenInfo: TokenInfo - constructor(tokenInfo: TokenInfo) { + public readonly tags: TagInfo[] + constructor(tokenInfo: TokenInfo, tags: TagInfo[]) { super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name) this.tokenInfo = tokenInfo + this.tags = tags } public get logoURI(): string | undefined { return this.tokenInfo.logoURI @@ -33,7 +39,7 @@ const EMPTY_LIST: TokenAddressMap = { } const listCache: WeakMap | null = - 'WeakMap' in window ? new WeakMap() : null + typeof WeakMap !== 'undefined' ? new WeakMap() : null export function listToTokenMap(list: TokenList): TokenAddressMap { const result = listCache?.get(list) @@ -41,7 +47,14 @@ export function listToTokenMap(list: TokenList): TokenAddressMap { const map = list.tokens.reduce( (tokenMap, tokenInfo) => { - const token = new WrappedTokenInfo(tokenInfo) + const tags: TagInfo[] = + tokenInfo.tags + ?.map(tagId => { + if (!list.tags?.[tagId]) return undefined + return { ...list.tags[tagId], id: tagId } + }) + ?.filter((x): x is TagInfo => Boolean(x)) ?? [] + const token = new WrappedTokenInfo(tokenInfo, tags) if (tokenMap[token.chainId][token.address] !== undefined) throw Error('Duplicate tokens.') return { ...tokenMap, @@ -57,17 +70,38 @@ export function listToTokenMap(list: TokenList): TokenAddressMap { return map } -export function useTokenList(url: string): TokenAddressMap { +export function useTokenList(url: string | undefined): TokenAddressMap { const lists = useSelector(state => state.lists.byUrl) return useMemo(() => { + if (!url) return EMPTY_LIST const current = lists[url]?.current if (!current) return EMPTY_LIST - return listToTokenMap(current) + try { + return listToTokenMap(current) + } catch (error) { + console.error('Could not show token list due to error', error) + return EMPTY_LIST + } }, [lists, url]) } -export function useDefaultTokenList(): TokenAddressMap { - return useTokenList(DEFAULT_TOKEN_LIST_URL) +export function useSelectedListUrl(): string | undefined { + return useSelector(state => state.lists.selectedListUrl) +} + +export function useSelectedTokenList(): TokenAddressMap { + return useTokenList(useSelectedListUrl()) +} + +export function useSelectedListInfo(): { current: TokenList | null; pending: TokenList | null; loading: boolean } { + const selectedUrl = useSelectedListUrl() + const listsByUrl = useSelector(state => state.lists.byUrl) + const list = selectedUrl ? listsByUrl[selectedUrl] : undefined + return { + current: list?.current ?? null, + pending: list?.pendingUpdate ?? null, + loading: list?.loadingRequestId !== null + } } // returns all downloaded current lists diff --git a/src/state/lists/reducer.test.ts b/src/state/lists/reducer.test.ts index 15b2ff0e00..de1555afb4 100644 --- a/src/state/lists/reducer.test.ts +++ b/src/state/lists/reducer.test.ts @@ -1,6 +1,9 @@ import { createStore, Store } from 'redux' -import { fetchTokenList, acceptListUpdate, addList } from './actions' +import { DEFAULT_LIST_OF_LISTS, DEFAULT_TOKEN_LIST_URL } from '../../constants/lists' +import { updateVersion } from '../user/actions' +import { fetchTokenList, acceptListUpdate, addList, removeList, selectList } from './actions' import reducer, { ListsState } from './reducer' +import UNISWAP_DEFAULT_TOKEN_LIST from '@uniswap/default-token-list' const STUB_TOKEN_LIST = { name: '', @@ -27,14 +30,15 @@ describe('list reducer', () => { beforeEach(() => { store = createStore(reducer, { - byUrl: {} + byUrl: {}, + selectedListUrl: undefined }) }) describe('fetchTokenList', () => { describe('pending', () => { it('sets pending', () => { - store.dispatch(fetchTokenList.pending('request-id', 'fake-url')) + store.dispatch(fetchTokenList.pending({ requestId: 'request-id', url: 'fake-url' })) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -43,7 +47,8 @@ describe('list reducer', () => { current: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) @@ -56,10 +61,11 @@ describe('list reducer', () => { pendingUpdate: null, loadingRequestId: null } - } + }, + selectedListUrl: undefined }) - store.dispatch(fetchTokenList.pending('request-id', 'fake-url')) + store.dispatch(fetchTokenList.pending({ requestId: 'request-id', url: 'fake-url' })) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -68,14 +74,17 @@ describe('list reducer', () => { loadingRequestId: 'request-id', pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) }) describe('fulfilled', () => { it('saves the list', () => { - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -84,13 +93,18 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) it('does not save the list in pending if current is same', () => { - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -99,14 +113,19 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) it('does not save to current if list is newer patch version', () => { - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) - store.dispatch(fetchTokenList.fulfilled(PATCHED_STUB_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: PATCHED_STUB_LIST, requestId: 'request-id', url: 'fake-url' }) + ) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -115,13 +134,18 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: PATCHED_STUB_LIST } - } + }, + selectedListUrl: undefined }) }) it('does not save to current if list is newer minor version', () => { - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) - store.dispatch(fetchTokenList.fulfilled(MINOR_UPDATED_STUB_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: MINOR_UPDATED_STUB_LIST, requestId: 'request-id', url: 'fake-url' }) + ) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -130,13 +154,18 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: MINOR_UPDATED_STUB_LIST } - } + }, + selectedListUrl: undefined }) }) it('does not save to pending if list is newer major version', () => { - store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' }) + ) - store.dispatch(fetchTokenList.fulfilled(MAJOR_UPDATED_STUB_LIST, 'request-id', 'fake-url')) + store.dispatch( + fetchTokenList.fulfilled({ tokenList: MAJOR_UPDATED_STUB_LIST, requestId: 'request-id', url: 'fake-url' }) + ) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -145,16 +174,18 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: MAJOR_UPDATED_STUB_LIST } - } + }, + selectedListUrl: undefined }) }) }) describe('rejected', () => { it('no-op if not loading', () => { - store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url')) + store.dispatch(fetchTokenList.rejected({ requestId: 'request-id', errorMessage: 'abcd', url: 'fake-url' })) expect(store.getState()).toEqual({ - byUrl: {} + byUrl: {}, + selectedListUrl: undefined }) }) @@ -167,9 +198,10 @@ describe('list reducer', () => { loadingRequestId: 'request-id', pendingUpdate: null } - } + }, + selectedListUrl: undefined }) - store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url')) + store.dispatch(fetchTokenList.rejected({ requestId: 'request-id', errorMessage: 'abcd', url: 'fake-url' })) expect(store.getState()).toEqual({ byUrl: { 'fake-url': { @@ -178,7 +210,8 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) }) @@ -195,7 +228,8 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) it('no op for existing list', () => { @@ -207,7 +241,8 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) store.dispatch(addList('fake-url')) expect(store.getState()).toEqual({ @@ -218,7 +253,8 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined }) }) }) @@ -233,7 +269,8 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: PATCHED_STUB_LIST } - } + }, + selectedListUrl: undefined }) store.dispatch(acceptListUpdate('fake-url')) expect(store.getState()).toEqual({ @@ -244,7 +281,251 @@ describe('list reducer', () => { loadingRequestId: null, pendingUpdate: null } - } + }, + selectedListUrl: undefined + }) + }) + }) + + describe('removeList', () => { + it('deletes the list key', () => { + store = createStore(reducer, { + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + } + }, + selectedListUrl: undefined + }) + store.dispatch(removeList('fake-url')) + expect(store.getState()).toEqual({ + byUrl: {}, + selectedListUrl: undefined + }) + }) + it('unselects the list if selected', () => { + store = createStore(reducer, { + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + } + }, + selectedListUrl: 'fake-url' + }) + store.dispatch(removeList('fake-url')) + expect(store.getState()).toEqual({ + byUrl: {}, + selectedListUrl: undefined + }) + }) + }) + + describe('selectList', () => { + it('sets the selected list url', () => { + store = createStore(reducer, { + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + } + }, + selectedListUrl: undefined + }) + store.dispatch(selectList('fake-url')) + expect(store.getState()).toEqual({ + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + } + }, + selectedListUrl: 'fake-url' + }) + }) + it('selects if not present already', () => { + store = createStore(reducer, { + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + } + }, + selectedListUrl: undefined + }) + store.dispatch(selectList('fake-url-invalid')) + expect(store.getState()).toEqual({ + byUrl: { + 'fake-url': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: PATCHED_STUB_LIST + }, + 'fake-url-invalid': { + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: 'fake-url-invalid' + }) + }) + it('works if list already added', () => { + store = createStore(reducer, { + byUrl: { + 'fake-url': { + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: undefined + }) + store.dispatch(selectList('fake-url')) + expect(store.getState()).toEqual({ + byUrl: { + 'fake-url': { + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: 'fake-url' + }) + }) + }) + + describe('updateVersion', () => { + describe('never initialized', () => { + beforeEach(() => { + store = createStore(reducer, { + byUrl: { + 'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + }, + 'https://unpkg.com/@uniswap/default-token-list@latest': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: undefined + }) + store.dispatch(updateVersion()) + }) + + it('clears the current lists', () => { + expect( + store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'] + ).toBeUndefined() + expect(store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest']).toBeUndefined() + }) + + it('puts in all the new lists', () => { + expect(Object.keys(store.getState().byUrl)).toEqual(DEFAULT_LIST_OF_LISTS) + }) + it('all lists are empty', () => { + const s = store.getState() + Object.keys(s.byUrl).forEach(url => { + if (url === DEFAULT_TOKEN_LIST_URL) { + expect(s.byUrl[url]).toEqual({ + error: null, + current: UNISWAP_DEFAULT_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + }) + } else { + expect(s.byUrl[url]).toEqual({ + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null + }) + } + }) + }) + it('sets initialized lists', () => { + expect(store.getState().lastInitializedDefaultListOfLists).toEqual(DEFAULT_LIST_OF_LISTS) + }) + }) + describe('initialized with a different set of lists', () => { + beforeEach(() => { + store = createStore(reducer, { + byUrl: { + 'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + }, + 'https://unpkg.com/@uniswap/default-token-list@latest': { + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: undefined, + lastInitializedDefaultListOfLists: ['https://unpkg.com/@uniswap/default-token-list@latest'] + }) + store.dispatch(updateVersion()) + }) + + it('does not remove lists not in last initialized list of lists', () => { + expect( + store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'] + ).toEqual({ + error: null, + current: STUB_TOKEN_LIST, + loadingRequestId: null, + pendingUpdate: null + }) + }) + it('removes lists in the last initialized list of lists', () => { + expect(store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest']).toBeUndefined() + }) + + it('adds all the lists in the default list of lists', () => { + expect(Object.keys(store.getState().byUrl)).toContain(DEFAULT_TOKEN_LIST_URL) + }) + + it('each of those initialized lists is empty', () => { + const byUrl = store.getState().byUrl + // note we don't expect the uniswap default list to be prepopulated + // this is ok. + Object.keys(byUrl).forEach(url => { + if (url !== 'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json') { + expect(byUrl[url]).toEqual({ + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null + }) + } + }) + }) + + it('sets initialized lists', () => { + expect(store.getState().lastInitializedDefaultListOfLists).toEqual(DEFAULT_LIST_OF_LISTS) }) }) }) diff --git a/src/state/lists/reducer.ts b/src/state/lists/reducer.ts index e43b6c5ef1..4a539f69d9 100644 --- a/src/state/lists/reducer.ts +++ b/src/state/lists/reducer.ts @@ -1,8 +1,10 @@ import { createReducer } from '@reduxjs/toolkit' import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists' import { TokenList } from '@uniswap/token-lists/dist/types' +import { DEFAULT_LIST_OF_LISTS, DEFAULT_TOKEN_LIST_URL } from '../../constants/lists' import { updateVersion } from '../user/actions' -import { acceptListUpdate, addList, fetchTokenList } from './actions' +import { acceptListUpdate, addList, fetchTokenList, removeList, selectList } from './actions' +import UNISWAP_DEFAULT_LIST from '@uniswap/default-token-list' export interface ListsState { readonly byUrl: { @@ -13,15 +15,40 @@ export interface ListsState { readonly error: string | null } } + // this contains the default list of lists from the last time the updateVersion was called, i.e. the app was reloaded + readonly lastInitializedDefaultListOfLists?: string[] + readonly selectedListUrl: string | undefined } +const NEW_LIST_STATE: ListsState['byUrl'][string] = { + error: null, + current: null, + loadingRequestId: null, + pendingUpdate: null +} + +type Mutable = { -readonly [P in keyof T]: T[P] extends ReadonlyArray ? U[] : T[P] } + const initialState: ListsState = { - byUrl: {} + lastInitializedDefaultListOfLists: DEFAULT_LIST_OF_LISTS, + byUrl: { + ...DEFAULT_LIST_OF_LISTS.reduce>((memo, listUrl) => { + memo[listUrl] = NEW_LIST_STATE + return memo + }, {}), + [DEFAULT_TOKEN_LIST_URL]: { + error: null, + current: UNISWAP_DEFAULT_LIST, + loadingRequestId: null, + pendingUpdate: null + } + }, + selectedListUrl: undefined } export default createReducer(initialState, builder => builder - .addCase(fetchTokenList.pending, (state, { meta: { arg: url, requestId } }) => { + .addCase(fetchTokenList.pending, (state, { payload: { requestId, url } }) => { state.byUrl[url] = { current: null, pendingUpdate: null, @@ -30,19 +57,22 @@ export default createReducer(initialState, builder => error: null } }) - .addCase(fetchTokenList.fulfilled, (state, { payload: tokenList, meta: { arg: url } }) => { + .addCase(fetchTokenList.fulfilled, (state, { payload: { requestId, tokenList, url } }) => { const current = state.byUrl[url]?.current + const loadingRequestId = state.byUrl[url]?.loadingRequestId // no-op if update does nothing if (current) { - const type = getVersionUpgrade(current.version, tokenList.version) - if (type === VersionUpgrade.NONE) return - state.byUrl[url] = { - ...state.byUrl[url], - loadingRequestId: null, - error: null, - current: current, - pendingUpdate: tokenList + const upgradeType = getVersionUpgrade(current.version, tokenList.version) + if (upgradeType === VersionUpgrade.NONE) return + if (loadingRequestId === null || loadingRequestId === requestId) { + state.byUrl[url] = { + ...state.byUrl[url], + loadingRequestId: null, + error: null, + current: current, + pendingUpdate: tokenList + } } } else { state.byUrl[url] = { @@ -54,7 +84,7 @@ export default createReducer(initialState, builder => } } }) - .addCase(fetchTokenList.rejected, (state, { error, meta: { requestId, arg: url } }) => { + .addCase(fetchTokenList.rejected, (state, { payload: { url, requestId, errorMessage } }) => { if (state.byUrl[url]?.loadingRequestId !== requestId) { // no-op since it's not the latest request return @@ -63,19 +93,29 @@ export default createReducer(initialState, builder => state.byUrl[url] = { ...state.byUrl[url], loadingRequestId: null, - error: error.message ?? 'Unknown error', + error: errorMessage, current: null, pendingUpdate: null } }) + .addCase(selectList, (state, { payload: url }) => { + state.selectedListUrl = url + // automatically adds list + if (!state.byUrl[url]) { + state.byUrl[url] = NEW_LIST_STATE + } + }) .addCase(addList, (state, { payload: url }) => { if (!state.byUrl[url]) { - state.byUrl[url] = { - loadingRequestId: null, - pendingUpdate: null, - current: null, - error: null - } + state.byUrl[url] = NEW_LIST_STATE + } + }) + .addCase(removeList, (state, { payload: url }) => { + if (state.byUrl[url]) { + delete state.byUrl[url] + } + if (state.selectedListUrl === url) { + state.selectedListUrl = Object.keys(state.byUrl)[0] } }) .addCase(acceptListUpdate, (state, { payload: url }) => { @@ -89,6 +129,30 @@ export default createReducer(initialState, builder => } }) .addCase(updateVersion, state => { - delete state.byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'] + // state loaded from localStorage, but new lists have never been initialized + if (!state.lastInitializedDefaultListOfLists) { + state.byUrl = initialState.byUrl + state.selectedListUrl = undefined + } else if (state.lastInitializedDefaultListOfLists) { + const lastInitializedSet = state.lastInitializedDefaultListOfLists.reduce>( + (s, l) => s.add(l), + new Set() + ) + const newListOfListsSet = DEFAULT_LIST_OF_LISTS.reduce>((s, l) => s.add(l), new Set()) + + DEFAULT_LIST_OF_LISTS.forEach(listUrl => { + if (!lastInitializedSet.has(listUrl)) { + state.byUrl[listUrl] = NEW_LIST_STATE + } + }) + + state.lastInitializedDefaultListOfLists.forEach(listUrl => { + if (!newListOfListsSet.has(listUrl)) { + delete state.byUrl[listUrl] + } + }) + } + + state.lastInitializedDefaultListOfLists = DEFAULT_LIST_OF_LISTS }) ) diff --git a/src/state/lists/updater.ts b/src/state/lists/updater.ts index a3ececc828..a7c88c595e 100644 --- a/src/state/lists/updater.ts +++ b/src/state/lists/updater.ts @@ -1,36 +1,43 @@ import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { DEFAULT_TOKEN_LIST_URL } from '../../constants' +import { useActiveWeb3React } from '../../hooks' +import { useFetchListCallback } from '../../hooks/useFetchListCallback' +import useInterval from '../../hooks/useInterval' +import useIsWindowVisible from '../../hooks/useIsWindowVisible' import { addPopup } from '../application/actions' import { AppDispatch, AppState } from '../index' -import { acceptListUpdate, addList, fetchTokenList } from './actions' +import { acceptListUpdate } from './actions' export default function Updater(): null { + const { library } = useActiveWeb3React() const dispatch = useDispatch() const lists = useSelector(state => state.lists.byUrl) - // we should always fetch the default token list, so add it - useEffect(() => { - if (!lists[DEFAULT_TOKEN_LIST_URL]) dispatch(addList(DEFAULT_TOKEN_LIST_URL)) - }, [dispatch, lists]) + const isWindowVisible = useIsWindowVisible() - // on initial mount, refetch all the lists in storage - useEffect(() => { - Object.keys(lists).forEach(listUrl => dispatch(fetchTokenList(listUrl) as any)) - // we only do this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch]) + const fetchList = useFetchListCallback() + + const fetchAllListsCallback = useCallback(() => { + if (!isWindowVisible) return + Object.keys(lists).forEach(url => + fetchList(url).catch(error => console.debug('interval list fetching error', error)) + ) + }, [fetchList, isWindowVisible, lists]) + + // fetch all lists every 10 minutes, but only after we initialize library + useInterval(fetchAllListsCallback, library ? 1000 * 60 * 10 : null) // whenever a list is not loaded and not loading, try again to load it useEffect(() => { Object.keys(lists).forEach(listUrl => { const list = lists[listUrl] + if (!list.current && !list.loadingRequestId && !list.error) { - dispatch(fetchTokenList(listUrl) as any) + fetchList(listUrl).catch(error => console.debug('list added fetching error', error)) } }) - }, [dispatch, lists]) + }, [dispatch, fetchList, library, lists]) // automatically update lists if versions are minor/patch useEffect(() => { @@ -43,7 +50,6 @@ export default function Updater(): null { throw new Error('unexpected no version bump') case VersionUpgrade.PATCH: case VersionUpgrade.MINOR: - case VersionUpgrade.MAJOR: const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens) // automatically update minor/patch as long as bump matches the min update if (bump >= min) { @@ -68,21 +74,21 @@ export default function Updater(): null { } break - // this will be turned on later - // case VersionUpgrade.MAJOR: - // dispatch( - // addPopup({ - // key: listUrl, - // content: { - // listUpdate: { - // listUrl, - // auto: false, - // oldList: list.current, - // newList: list.pendingUpdate - // } - // } - // }) - // ) + case VersionUpgrade.MAJOR: + dispatch( + addPopup({ + key: listUrl, + content: { + listUpdate: { + listUrl, + auto: false, + oldList: list.current, + newList: list.pendingUpdate + } + }, + removeAfterMs: null + }) + ) } } }) diff --git a/src/state/swap/hooks.ts b/src/state/swap/hooks.ts index fa4d6dc43a..491d5e3e39 100644 --- a/src/state/swap/hooks.ts +++ b/src/state/swap/hooks.ts @@ -3,7 +3,7 @@ import { Version } from '../../hooks/useToggledVersion' import { parseUnits } from '@ethersproject/units' import { Currency, CurrencyAmount, ETHER, JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk' import { ParsedQs } from 'qs' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useV1Trade } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' @@ -246,10 +246,15 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState { } // updates the swap state to use the defaults for a given network -export function useDefaultsFromURLSearch() { +export function useDefaultsFromURLSearch(): + | { inputCurrencyId: string | undefined; outputCurrencyId: string | undefined } + | undefined { const { chainId } = useActiveWeb3React() const dispatch = useDispatch() const parsedQs = useParsedQueryString() + const [result, setResult] = useState< + { inputCurrencyId: string | undefined; outputCurrencyId: string | undefined } | undefined + >() useEffect(() => { if (!chainId) return @@ -264,6 +269,10 @@ export function useDefaultsFromURLSearch() { recipient: parsed.recipient }) ) + + setResult({ inputCurrencyId: parsed[Field.INPUT].currencyId, outputCurrencyId: parsed[Field.OUTPUT].currencyId }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, chainId]) + + return result } diff --git a/src/state/user/actions.ts b/src/state/user/actions.ts index 2765408d61..acea35368e 100644 --- a/src/state/user/actions.ts +++ b/src/state/user/actions.ts @@ -27,4 +27,3 @@ export const addSerializedPair = createAction<{ serializedPair: SerializedPair } export const removeSerializedPair = createAction<{ chainId: number; tokenAAddress: string; tokenBAddress: string }>( 'removeSerializedPair' ) -export const dismissTokenWarning = createAction<{ chainId: number; tokenAddress: string }>('dismissTokenWarning') diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index 41b922c6e0..b18899d90e 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -1,4 +1,4 @@ -import { ChainId, Pair, Token, Currency } from '@uniswap/sdk' +import { ChainId, Pair, Token } from '@uniswap/sdk' import flatMap from 'lodash.flatmap' import { useCallback, useMemo } from 'react' import { shallowEqual, useDispatch, useSelector } from 'react-redux' @@ -10,7 +10,6 @@ import { AppDispatch, AppState } from '../index' import { addSerializedPair, addSerializedToken, - dismissTokenWarning, removeSerializedToken, SerializedPair, SerializedToken, @@ -19,8 +18,6 @@ import { updateUserExpertMode, updateUserSlippageTolerance } from './actions' -import { useDefaultTokenList } from '../lists/hooks' -import { isDefaultToken } from '../../utils' function serializeToken(token: Token): SerializedToken { return { @@ -163,36 +160,6 @@ export function usePairAdder(): (pair: Pair) => void { ) } -/** - * Returns whether a token warning has been dismissed and a callback to dismiss it, - * iff it has not already been dismissed and is a valid token. - */ -export function useTokenWarningDismissal(chainId?: number, token?: Currency): [boolean, null | (() => void)] { - const dismissalState = useSelector( - state => state.user.dismissedTokenWarnings - ) - - const dispatch = useDispatch() - - // get default list, mark as dismissed if on list - const defaultList = useDefaultTokenList() - const isDefault = isDefaultToken(defaultList, token) - - return useMemo(() => { - if (!chainId || !token) return [false, null] - - const dismissed: boolean = - token instanceof Token ? dismissalState?.[chainId]?.[token.address] === true || isDefault : true - - const callback = - dismissed || !(token instanceof Token) - ? null - : () => dispatch(dismissTokenWarning({ chainId, tokenAddress: token.address })) - - return [dismissed, callback] - }, [chainId, token, dismissalState, isDefault, dispatch]) -} - /** * Given two tokens return the liquidity token that represents its liquidity shares * @param tokenA one of the two tokens diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index 62d227cff1..7833d7e068 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -3,7 +3,6 @@ import { createReducer } from '@reduxjs/toolkit' import { addSerializedPair, addSerializedToken, - dismissTokenWarning, removeSerializedPair, removeSerializedToken, SerializedPair, @@ -39,13 +38,6 @@ export interface UserState { } } - // the token warnings that the user has dismissed - dismissedTokenWarnings?: { - [chainId: number]: { - [tokenAddress: string]: true - } - } - pairs: { [chainId: number]: { // keyed by token0Address:token1Address @@ -75,11 +67,13 @@ export default createReducer(initialState, builder => builder .addCase(updateVersion, state => { // slippage isnt being tracked in local storage, reset to default + // noinspection SuspiciousTypeOfGuard if (typeof state.userSlippageTolerance !== 'number') { state.userSlippageTolerance = INITIAL_ALLOWED_SLIPPAGE } // deadline isnt being tracked in local storage, reset to default + // noinspection SuspiciousTypeOfGuard if (typeof state.userDeadline !== 'number') { state.userDeadline = DEFAULT_DEADLINE_FROM_NOW } @@ -116,11 +110,6 @@ export default createReducer(initialState, builder => delete state.tokens[chainId][address] state.timestamp = currentTimestamp() }) - .addCase(dismissTokenWarning, (state, { payload: { chainId, tokenAddress } }) => { - state.dismissedTokenWarnings = state.dismissedTokenWarnings ?? {} - state.dismissedTokenWarnings[chainId] = state.dismissedTokenWarnings[chainId] ?? {} - state.dismissedTokenWarnings[chainId][tokenAddress] = true - }) .addCase(addSerializedPair, (state, { payload: { serializedPair } }) => { if ( serializedPair.token0.chainId === serializedPair.token1.chainId && diff --git a/src/theme/components.tsx b/src/theme/components.tsx index e849892a1f..4fb288c367 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -40,22 +40,22 @@ export const CloseIcon = styled(X)<{ onClick: () => void }>` ` // A button that triggers some onClick result, but looks like a link. -export const LinkStyledButton = styled.button` +export const LinkStyledButton = styled.button<{ disabled?: boolean }>` border: none; text-decoration: none; background: none; - cursor: pointer; - color: ${({ theme }) => theme.primary1}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + color: ${({ theme, disabled }) => (disabled ? theme.text2 : theme.primary1)}; font-weight: 500; :hover { - text-decoration: underline; + text-decoration: ${({ disabled }) => (disabled ? null : 'underline')}; } :focus { outline: none; - text-decoration: underline; + text-decoration: ${({ disabled }) => (disabled ? null : 'underline')}; } :active { diff --git a/src/theme/index.tsx b/src/theme/index.tsx index b46511a1da..a1f1ec3ace 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -52,10 +52,10 @@ export function colors(darkMode: boolean): Colors { bg2: darkMode ? '#2C2F36' : '#F7F8FA', bg3: darkMode ? '#40444F' : '#EDEEF2', bg4: darkMode ? '#565A69' : '#CED0D9', - bg5: darkMode ? '#565A69' : '#888D9B', + bg5: darkMode ? '#6C7284' : '#888D9B', //specialty colors - modalBG: darkMode ? 'rgba(0,0,0,42.5)' : 'rgba(0,0,0,0.3)', + modalBG: darkMode ? 'rgba(0,0,0,.425)' : 'rgba(0,0,0,0.3)', advancedBG: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.6)', //primary colors diff --git a/src/utils/content-hash.d.ts b/src/utils/content-hash.d.ts new file mode 100644 index 0000000000..5fedf8a21d --- /dev/null +++ b/src/utils/content-hash.d.ts @@ -0,0 +1,4 @@ +declare module 'content-hash' { + declare function decode(x: string): string + declare function getCodec(x: string): string +} diff --git a/src/utils/contenthashToUri.test.skip.ts b/src/utils/contenthashToUri.test.skip.ts new file mode 100644 index 0000000000..7a44dd1c64 --- /dev/null +++ b/src/utils/contenthashToUri.test.skip.ts @@ -0,0 +1,21 @@ +import contenthashToUri, { hexToUint8Array } from './contenthashToUri' + +// this test is skipped for now because importing CID results in +// TypeError: TextDecoder is not a constructor + +describe('#contenthashToUri', () => { + it('1inch.tokens.eth contenthash', () => { + expect(contenthashToUri('0xe3010170122013e051d1cfff20606de36845d4fe28deb9861a319a5bc8596fa4e610e8803918')).toEqual( + 'ipfs://QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1' + ) + }) + it('uniswap.eth contenthash', () => { + expect(contenthashToUri('0xe5010170000f6170702e756e69737761702e6f7267')).toEqual('ipns://app.uniswap.org') + }) +}) + +describe('#hexToUint8Array', () => { + it('common case', () => { + expect(hexToUint8Array('0x010203fdfeff')).toEqual(new Uint8Array([1, 2, 3, 253, 254, 255])) + }) +}) diff --git a/src/utils/contenthashToUri.ts b/src/utils/contenthashToUri.ts new file mode 100644 index 0000000000..6d294322a1 --- /dev/null +++ b/src/utils/contenthashToUri.ts @@ -0,0 +1,43 @@ +import CID from 'cids' +import { getCodec, rmPrefix } from 'multicodec' +import { decode, toB58String } from 'multihashes' + +export function hexToUint8Array(hex: string): Uint8Array { + hex = hex.startsWith('0x') ? hex.substr(2) : hex + if (hex.length % 2 !== 0) throw new Error('hex must have length that is multiple of 2') + const arr = new Uint8Array(hex.length / 2) + for (let i = 0; i < arr.length; i++) { + arr[i] = parseInt(hex.substr(i * 2, 2), 16) + } + return arr +} + +const UTF_8_DECODER = new TextDecoder() + +/** + * Returns the URI representation of the content hash for supported codecs + * @param contenthash to decode + */ +export default function contenthashToUri(contenthash: string): string { + const buff = hexToUint8Array(contenthash) + const codec = getCodec(buff as Buffer) // the typing is wrong for @types/multicodec + switch (codec) { + case 'ipfs-ns': { + const data = rmPrefix(buff as Buffer) + const cid = new CID(data) + return `ipfs://${toB58String(cid.multihash)}` + } + case 'ipns-ns': { + const data = rmPrefix(buff as Buffer) + const cid = new CID(data) + const multihash = decode(cid.multihash) + if (multihash.name === 'identity') { + return `ipns://${UTF_8_DECODER.decode(multihash.digest).trim()}` + } else { + return `ipns://${toB58String(cid.multihash)}` + } + } + default: + throw new Error(`Unrecognized codec: ${codec}`) + } +} diff --git a/src/utils/getLibrary.ts b/src/utils/getLibrary.ts new file mode 100644 index 0000000000..a00efd5af7 --- /dev/null +++ b/src/utils/getLibrary.ts @@ -0,0 +1,7 @@ +import { Web3Provider } from '@ethersproject/providers' + +export default function getLibrary(provider: any): Web3Provider { + const library = new Web3Provider(provider) + library.pollingInterval = 15000 + return library +} diff --git a/src/utils/getTokenList.ts b/src/utils/getTokenList.ts new file mode 100644 index 0000000000..00865dafc5 --- /dev/null +++ b/src/utils/getTokenList.ts @@ -0,0 +1,69 @@ +import { TokenList } from '@uniswap/token-lists' +import schema from '@uniswap/token-lists/src/tokenlist.schema.json' +import Ajv from 'ajv' +import contenthashToUri from './contenthashToUri' +import { parseENSAddress } from './parseENSAddress' +import uriToHttp from './uriToHttp' + +const tokenListValidator = new Ajv({ allErrors: true }).compile(schema) + +/** + * Contains the logic for resolving a list URL to a validated token list + * @param listUrl list url + * @param resolveENSContentHash resolves an ens name to a contenthash + */ +export default async function getTokenList( + listUrl: string, + resolveENSContentHash: (ensName: string) => Promise +): Promise { + const parsedENS = parseENSAddress(listUrl) + let urls: string[] + if (parsedENS) { + let contentHashUri + try { + contentHashUri = await resolveENSContentHash(parsedENS.ensName) + } catch (error) { + console.debug(`Failed to resolve ENS name: ${parsedENS.ensName}`, error) + throw new Error(`Failed to resolve ENS name: ${parsedENS.ensName}`) + } + let translatedUri + try { + translatedUri = contenthashToUri(contentHashUri) + } catch (error) { + console.debug('Failed to translate contenthash to URI', contentHashUri) + throw new Error(`Failed to translate contenthash to URI: ${contentHashUri}`) + } + urls = uriToHttp(`${translatedUri}${parsedENS.ensPath ?? ''}`) + } else { + urls = uriToHttp(listUrl) + } + for (let i = 0; i < urls.length; i++) { + const url = urls[i] + const isLast = i === urls.length - 1 + let response + try { + response = await fetch(url) + } catch (error) { + console.debug('Failed to fetch list', listUrl, error) + if (isLast) throw new Error(`Failed to download list ${listUrl}`) + continue + } + + if (!response.ok) { + if (isLast) throw new Error(`Failed to download list ${listUrl}`) + continue + } + + const json = await response.json() + if (!tokenListValidator(json)) { + const validationErrors: string = + tokenListValidator.errors?.reduce((memo, error) => { + const add = `${error.dataPath} ${error.message ?? ''}` + return memo.length > 0 ? `${memo}; ${add}` : `${add}` + }, '') ?? 'unknown error' + throw new Error(`Token list failed validation: ${validationErrors}`) + } + return json + } + throw new Error('Unrecognized list URL protocol.') +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 48d417fac9..5d5635718b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -99,7 +99,7 @@ export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string } -export function isDefaultToken(defaultTokens: TokenAddressMap, currency?: Currency): boolean { +export function isTokenOnList(defaultTokens: TokenAddressMap, currency?: Currency): boolean { if (currency === ETHER) return true return Boolean(currency instanceof Token && defaultTokens[currency.chainId]?.[currency.address]) } diff --git a/src/utils/listVersionLabel.ts b/src/utils/listVersionLabel.ts new file mode 100644 index 0000000000..8717e0a5ea --- /dev/null +++ b/src/utils/listVersionLabel.ts @@ -0,0 +1,5 @@ +import { Version } from '@uniswap/token-lists' + +export default function listVersionLabel(version: Version): string { + return `v${version.major}.${version.minor}.${version.patch}` +} diff --git a/src/utils/multihashes.d.ts b/src/utils/multihashes.d.ts new file mode 100644 index 0000000000..262ed17e2f --- /dev/null +++ b/src/utils/multihashes.d.ts @@ -0,0 +1,4 @@ +declare module 'multihashes' { + declare function decode(buff: Uint8Array): { code: number; name: string; length: number; digest: Uint8Array } + declare function toB58String(hash: Uint8Array): string +} diff --git a/src/utils/parseENSAddress.test.ts b/src/utils/parseENSAddress.test.ts new file mode 100644 index 0000000000..0cbc70c0c4 --- /dev/null +++ b/src/utils/parseENSAddress.test.ts @@ -0,0 +1,14 @@ +import { parseENSAddress } from './parseENSAddress' + +describe('parseENSAddress', () => { + it('test cases', () => { + expect(parseENSAddress('hello.eth')).toEqual({ ensName: 'hello.eth', ensPath: undefined }) + expect(parseENSAddress('hello.eth/')).toEqual({ ensName: 'hello.eth', ensPath: '/' }) + expect(parseENSAddress('hello.world.eth/')).toEqual({ ensName: 'hello.world.eth', ensPath: '/' }) + expect(parseENSAddress('hello.world.eth/abcdef')).toEqual({ ensName: 'hello.world.eth', ensPath: '/abcdef' }) + expect(parseENSAddress('abso.lutely')).toEqual(undefined) + expect(parseENSAddress('abso.lutely.eth')).toEqual({ ensName: 'abso.lutely.eth', ensPath: undefined }) + expect(parseENSAddress('eth')).toEqual(undefined) + expect(parseENSAddress('eth/hello-world')).toEqual(undefined) + }) +}) diff --git a/src/utils/parseENSAddress.ts b/src/utils/parseENSAddress.ts new file mode 100644 index 0000000000..4250e4922a --- /dev/null +++ b/src/utils/parseENSAddress.ts @@ -0,0 +1,7 @@ +const ENS_NAME_REGEX = /^(([a-zA-Z0-9]+\.)+)eth(\/.*)?$/ + +export function parseENSAddress(ensAddress: string): { ensName: string; ensPath: string | undefined } | undefined { + const match = ensAddress.match(ENS_NAME_REGEX) + if (!match) return + return { ensName: `${match[1].toLowerCase()}eth`, ensPath: match[3] } +} diff --git a/src/utils/props-of-excluding.ts b/src/utils/props-of-excluding.ts deleted file mode 100644 index 123b77863a..0000000000 --- a/src/utils/props-of-excluding.ts +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react' - -/** - * Helper type that returns the props type of another component, excluding - * any of the keys passed as the optional second argument. - */ -type PropsOfExcluding = TComponent extends React.ComponentType - ? TExcludingProps extends string | number | symbol - ? Omit - : P - : unknown - -export default PropsOfExcluding diff --git a/src/utils/resolveENSContentHash.ts b/src/utils/resolveENSContentHash.ts new file mode 100644 index 0000000000..2c2619aad4 --- /dev/null +++ b/src/utils/resolveENSContentHash.ts @@ -0,0 +1,67 @@ +import { Contract } from '@ethersproject/contracts' +import { Provider } from '@ethersproject/abstract-provider' +import { namehash } from 'ethers/lib/utils' + +const REGISTRAR_ABI = [ + { + constant: true, + inputs: [ + { + name: 'node', + type: 'bytes32' + } + ], + name: 'resolver', + outputs: [ + { + name: 'resolverAddress', + type: 'address' + } + ], + payable: false, + stateMutability: 'view', + type: 'function' + } +] +const REGISTRAR_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' + +const RESOLVER_ABI = [ + { + constant: true, + inputs: [ + { + internalType: 'bytes32', + name: 'node', + type: 'bytes32' + } + ], + name: 'contenthash', + outputs: [ + { + internalType: 'bytes', + name: '', + type: 'bytes' + } + ], + payable: false, + stateMutability: 'view', + type: 'function' + } +] + +// cache the resolver contracts since most of them are the public resolver +function resolverContract(resolverAddress: string, provider: Provider): Contract { + return new Contract(resolverAddress, RESOLVER_ABI, provider) +} + +/** + * Fetches and decodes the result of an ENS contenthash lookup on mainnet to a URI + * @param ensName to resolve + * @param provider provider to use to fetch the data + */ +export default async function resolveENSContentHash(ensName: string, provider: Provider): Promise { + const ensRegistrarContract = new Contract(REGISTRAR_ADDRESS, REGISTRAR_ABI, provider) + const hash = namehash(ensName) + const resolverAddress = await ensRegistrarContract.resolver(hash) + return resolverContract(resolverAddress, provider).contenthash(hash) +} diff --git a/src/utils/uriToHttp.test.ts b/src/utils/uriToHttp.test.ts index c46976838d..dc37266896 100644 --- a/src/utils/uriToHttp.test.ts +++ b/src/utils/uriToHttp.test.ts @@ -2,7 +2,7 @@ import uriToHttp from './uriToHttp' describe('uriToHttp', () => { it('returns .eth.link for ens names', () => { - expect(uriToHttp('t2crtokens.eth')).toEqual(['https://t2crtokens.eth.link']) + expect(uriToHttp('t2crtokens.eth')).toEqual([]) }) it('returns https first for http', () => { expect(uriToHttp('http://test.com')).toEqual(['https://test.com', 'http://test.com']) diff --git a/src/utils/uriToHttp.ts b/src/utils/uriToHttp.ts index 8979714402..8d334b0f87 100644 --- a/src/utils/uriToHttp.ts +++ b/src/utils/uriToHttp.ts @@ -1,27 +1,21 @@ /** - * Given a URI that may be ipfs, or http, or an ENS name, return the fetchable http(s) URLs for the same content - * @param uri to convert to http url + * Given a URI that may be ipfs, ipns, http, or https protocol, return the fetch-able http(s) URLs for the same content + * @param uri to convert to fetch-able http url */ export default function uriToHttp(uri: string): string[] { - try { - const parsed = new URL(uri) - if (parsed.protocol === 'http:') { - return ['https' + uri.substr(4), uri] - } else if (parsed.protocol === 'https:') { + const protocol = uri.split(':')[0].toLowerCase() + switch (protocol) { + case 'https': return [uri] - } else if (parsed.protocol === 'ipfs:') { - const hash = parsed.href.match(/^ipfs:(\/\/)?(.*)$/)?.[2] + case 'http': + return ['https' + uri.substr(4), uri] + case 'ipfs': + const hash = uri.match(/^ipfs:(\/\/)?(.*)$/i)?.[2] return [`https://cloudflare-ipfs.com/ipfs/${hash}/`, `https://ipfs.io/ipfs/${hash}/`] - } else if (parsed.protocol === 'ipns:') { - const name = parsed.href.match(/^ipns:(\/\/)?(.*)$/)?.[2] + case 'ipns': + const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2] return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`] - } else { + default: return [] - } - } catch (error) { - if (uri.toLowerCase().endsWith('.eth')) { - return [`https://${uri.toLowerCase()}.link`] - } - return [] } } diff --git a/src/utils/useDebouncedChangeHandler.tsx b/src/utils/useDebouncedChangeHandler.tsx new file mode 100644 index 0000000000..21a71f9b90 --- /dev/null +++ b/src/utils/useDebouncedChangeHandler.tsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * Easy way to debounce the handling of a rapidly changing value, e.g. a changing slider input + * @param value value that is rapidly changing + * @param onChange change handler that should receive the debounced updates to the value + * @param debouncedMs how long we should wait for changes to be applied + */ +export default function useDebouncedChangeHandler( + value: T, + onChange: (newValue: T) => void, + debouncedMs = 100 +): [T, (value: T) => void] { + const [inner, setInner] = useState(() => value) + const timer = useRef>() + + const onChangeInner = useCallback( + (newValue: T) => { + setInner(newValue) + if (timer.current) { + clearTimeout(timer.current) + } + timer.current = setTimeout(() => { + onChange(newValue) + timer.current = undefined + }, debouncedMs) + }, + [debouncedMs, onChange] + ) + + useEffect(() => { + if (timer.current) { + clearTimeout(timer.current) + timer.current = undefined + } + setInner(value) + }, [value]) + + return [inner, onChangeInner] +} diff --git a/yarn.lock b/yarn.lock index 67b7f1bb4b..1527c065f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2326,6 +2326,13 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/multicodec@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/multicodec/-/multicodec-1.0.0.tgz#9c9c2df84ea5006c65a048873600f71c4565a397" + integrity sha512-UZkJT3rb8AfT2S1bTk7Gj+1wP9GJQ4zSnHDycRxEiI4yPOn47s5rSK86w/EFHvnNBhsu3zl+XNbTnBcxBd9dAQ== + dependencies: + "@types/node" "*" + "@types/node@*": version "14.0.26" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" @@ -2404,6 +2411,13 @@ "@types/history" "*" "@types/react" "*" +"@types/react-virtualized-auto-sizer@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272" + integrity sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg== + dependencies: + "@types/react" "*" + "@types/react-window@^1.8.2": version "1.8.2" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" @@ -2550,6 +2564,11 @@ semver "^7.3.2" tsutils "^3.17.1" +"@uniswap/default-token-list@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@uniswap/default-token-list/-/default-token-list-1.3.0.tgz#fd40e14165b31ff098b031b58ce8ca5ab81b3247" + integrity sha512-EsHVHVn7UgtxrhiM6tcBCXic56dTpl1WiKcIZhFyTFkA0vja7iZIJ3FYiYxT56g4hT0B9Lm7ZdBaPvEY3d1/eQ== + "@uniswap/lib@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@uniswap/lib/-/lib-1.1.1.tgz#0afd29601846c16e5d082866cbb24a9e0758e6bc" @@ -2568,10 +2587,10 @@ tiny-warning "^1.0.3" toformat "^2.0.0" -"@uniswap/token-lists@^1.0.0-beta.11": - version "1.0.0-beta.11" - resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.11.tgz#365dd55d536c67fa550554c0658391bfbc2930b7" - integrity sha512-dGHdb58d+rN7G164ziPP6omb1R0hwBVgs95er83OzXKkVRlLKE/FLSdgpDaTxLj1war+P/hZXw2/ToYcKFsobQ== +"@uniswap/token-lists@^1.0.0-beta.14": + version "1.0.0-beta.14" + resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.14.tgz#599ae9eb04121736fd2e309a31227b0343d77511" + integrity sha512-9+qzJqlQ6U4CE4RrMl1Gkh+ISWfnY/5bO7zFi+UGiJ2IEcZUYJm3bE2AcUc5g6WNAm7CL0uf1FU+fz3JlamOIg== "@uniswap/v2-core@1.0.0": version "1.0.0" @@ -4751,6 +4770,17 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +cids@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cids/-/cids-1.0.0.tgz#5a148c4adbf3c56c45bbcf9000451907dfe8b5b2" + integrity sha512-HEBCIElSiXlkgZq3dgHJc3eDcnFteFp96N8/1/oqX5lkxBtB66sZ12jqEP3g7Ut++jEk6kIUGifQ1Qrya1jcNQ== + dependencies: + class-is "^1.1.0" + multibase "^3.0.0" + multicodec "^2.0.0" + multihashes "^3.0.1" + uint8arrays "^1.0.0" + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -4759,6 +4789,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +class-is@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/class-is/-/class-is-1.1.0.tgz#9d3c0fba0440d211d843cec3dedfa48055005825" + integrity sha512-rhjH9AG1fvabIDoGRVH587413LPjTZgmDF9fOFCbFJQV4yuocX1mHxxvXI4g3cGwbVY9wAYIoKlg1N79frJKQw== + class-utils@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -10247,6 +10282,14 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +multibase@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/multibase/-/multibase-3.0.0.tgz#f56eb828ee5c00241fe860ed5e2d144c8b9821f4" + integrity sha512-fuB+zfRbF5zWV4L+CPM0dgA0gX7DHG/IMyzwhVi2RxbRVWn41Wk7SkKW8cxYDGOg6TVh7XgyoesjOAYrB1HBAA== + dependencies: + base-x "^3.0.8" + web-encoding "^1.0.2" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -10260,6 +10303,23 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" thunky "^1.0.2" +multicodec@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/multicodec/-/multicodec-2.0.0.tgz#e0c41d99ce29d5f92a9406a6a31169a3802a1ca3" + integrity sha512-2SLsdTCXqOpUfoSHkTaVzxnjjl5fsSO283Idb9rAYgKGVu188NFP5KncuZ8Ifg8H2gc5GOi2rkuhLumqv9nweQ== + dependencies: + uint8arrays "1.0.0" + varint "^5.0.0" + +multihashes@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-3.0.1.tgz#607c243d5e04ec022ac76c9c114e08416216f019" + integrity sha512-fFY67WOtb0359IjDZxaCU3gJILlkwkFbxbwrK9Bej5+NqNaYztzLOj8/NgMNMg/InxmhK+Uu8S/U4EcqsHzB7Q== + dependencies: + multibase "^3.0.0" + uint8arrays "^1.0.0" + varint "^5.0.0" + mute-stream@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" @@ -12423,6 +12483,11 @@ react-use-gesture@^6.0.14: resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-6.0.14.tgz#ab2d35ef72a5fb6060a6160eb12568c276f8a4b1" integrity sha512-d9cnZJ0DOFd3FIO76J776DyhtbODgbxGKu19lvc1aSNTnRV5EKr9V4Uda188l2Qh0Va3pqWGxEQlw72r2cmnFQ== +react-virtualized-auto-sizer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" + integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg== + react-window@^1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1" @@ -14376,6 +14441,22 @@ ua-parser-js@^0.7.21: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== +uint8arrays@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.0.0.tgz#9cf979517f85c32d6ef54adf824e3499bb715331" + integrity sha512-14tqEVujDREW7YwonSZZwLvo7aFDfX7b6ubvM/U7XvZol+CC/LbhaX/550VlWmhddAL9Wou1sxp0Of3tGqXigg== + dependencies: + multibase "^3.0.0" + web-encoding "^1.0.2" + +uint8arrays@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.1.0.tgz#d034aa65399a9fd213a1579e323f0b29f67d0ed2" + integrity sha512-cLdlZ6jnFczsKf5IH1gPHTtcHtPGho5r4CvctohmQjw8K7Q3gFdfIGHxSTdTaCKrL4w09SsPRJTqRS0drYeszA== + dependencies: + multibase "^3.0.0" + web-encoding "^1.0.2" + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -14599,6 +14680,11 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== +varint@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/varint/-/varint-5.0.0.tgz#d826b89f7490732fabc0c0ed693ed475dcb29ebf" + integrity sha1-2Ca4n3SQcy+rwMDtaT7Uddyynr8= + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -14705,6 +14791,11 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +web-encoding@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.0.2.tgz#e5050d4826597f242eb6e0e5fede05e3b9512ca5" + integrity sha512-fe9pqxglgy25Z4Ds+2GwZIrOnLxeozydMj0iV8zx0ZNxE3MPTseF4oXGrrBuH4vSkoDYDXoPRRFY/FEYitEUTA== + web3-provider-engine@15.0.12: version "15.0.12" resolved "https://registry.yarnpkg.com/web3-provider-engine/-/web3-provider-engine-15.0.12.tgz#24d7f2f6fb6de856824c7306291018c4fc543ac3"