feat(send page): remove send page, implement recipient feature in swap page (#934)
* quick poc for removing swap page * accidental import * error for recipient field * query parameter working * undo id change * tweaks to match mocks better * tweaks to match mocks better * some extra integration tests * clean up nav tabs a bit * clean up nav tabs a bit * space swap/pool better * stop selecting button text when double clicking * remove unused transfer modal header * add info to swap confirm modal * shorten address * improve summary message, remove unused send callback, fix react ga event * fix lint errors * arrow color
This commit is contained in:
parent
8a845ee0e9
commit
21c1484c0e
@ -1,9 +1,7 @@
|
||||
describe('Send', () => {
|
||||
beforeEach(() => cy.visit('/send'))
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#sending-no-swap-input')
|
||||
.type('0.001', { delay: 200 })
|
||||
.should('have.value', '0.001')
|
||||
it('should redirect', () => {
|
||||
cy.url().should('include', '/swap')
|
||||
})
|
||||
})
|
||||
|
@ -40,4 +40,15 @@ describe('Swap', () => {
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').should('contain', 'Confirm Swap')
|
||||
})
|
||||
|
||||
it('add a recipient', () => {
|
||||
cy.get('#add-recipient-button').click()
|
||||
cy.get('#recipient').should('exist')
|
||||
})
|
||||
|
||||
it('remove recipient', () => {
|
||||
cy.get('#add-recipient-button').click()
|
||||
cy.get('#remove-recipient-button').click()
|
||||
cy.get('#recipient').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { CheckCircle, Triangle, ExternalLink as LinkIcon } from 'react-feather'
|
||||
import { CheckCircle, Triangle } from 'react-feather'
|
||||
|
||||
import { useActiveWeb3React } from '../../hooks'
|
||||
import { getEtherscanLink } from '../../utils'
|
||||
@ -50,8 +50,7 @@ export default function Transaction({ hash }: { hash: string }) {
|
||||
<TransactionWrapper>
|
||||
<TransactionState href={getEtherscanLink(chainId, hash, 'transaction')} pending={pending} success={success}>
|
||||
<RowFixed>
|
||||
<TransactionStatusText>{summary ? summary : hash}</TransactionStatusText>
|
||||
<LinkIcon size={16} />
|
||||
<TransactionStatusText>{summary ?? hash} ↗</TransactionStatusText>
|
||||
</RowFixed>
|
||||
<IconWrapper pending={pending} success={success}>
|
||||
{pending ? <Loader /> : success ? <CheckCircle size="16" /> : <Triangle size="16" />}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect, useContext } from 'react'
|
||||
import React, { useContext, useCallback } from 'react'
|
||||
import styled, { ThemeContext } from 'styled-components'
|
||||
import useDebounce from '../../hooks/useDebounce'
|
||||
|
||||
import { isAddress } from '../../utils'
|
||||
import useENS from '../../hooks/useENS'
|
||||
import { useActiveWeb3React } from '../../hooks'
|
||||
import { ExternalLink, TYPE } from '../../theme'
|
||||
import { AutoColumn } from '../Column'
|
||||
@ -24,6 +22,8 @@ const ContainerRow = styled.div<{ error: boolean }>`
|
||||
align-items: center;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid ${({ error, theme }) => (error ? theme.red1 : theme.bg2)};
|
||||
transition: border-color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')},
|
||||
color 500ms ${({ error }) => (error ? 'step-end' : 'step-start')};
|
||||
background-color: ${({ theme }) => theme.bg1};
|
||||
`
|
||||
|
||||
@ -39,6 +39,7 @@ const Input = styled.input<{ error?: boolean }>`
|
||||
flex: 1 1 auto;
|
||||
width: 0;
|
||||
background-color: ${({ theme }) => theme.bg1};
|
||||
transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')};
|
||||
color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -64,120 +65,65 @@ const Input = styled.input<{ error?: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
interface Value {
|
||||
address: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export default function AddressInputPanel({
|
||||
initialInput = '',
|
||||
onChange,
|
||||
onError
|
||||
id,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
initialInput?: string
|
||||
onChange: (val: { address: string; name?: string }) => void
|
||||
onError: (error: boolean, input: string) => void
|
||||
id?: string
|
||||
// the typed string value
|
||||
value: string
|
||||
// triggers whenever the typed value changes
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const { chainId, library } = useActiveWeb3React()
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
const [input, setInput] = useState(initialInput ? initialInput : '')
|
||||
const debouncedInput = useDebounce(input, 200)
|
||||
const { address, loading, name } = useENS(value)
|
||||
|
||||
const [data, setData] = useState<{ address: string; name: string }>({ address: undefined, name: undefined })
|
||||
const [error, setError] = useState<boolean>(false)
|
||||
|
||||
// keep data and errors in sync
|
||||
useEffect(() => {
|
||||
onChange({ address: data.address, name: data.name })
|
||||
}, [onChange, data.address, data.name])
|
||||
useEffect(() => {
|
||||
onError(error, input)
|
||||
}, [onError, error, input])
|
||||
|
||||
// run parser on debounced input
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
// if the input is an address, try to look up its name
|
||||
if (isAddress(debouncedInput)) {
|
||||
library
|
||||
.lookupAddress(debouncedInput)
|
||||
.then(name => {
|
||||
if (stale) return
|
||||
// if an ENS name exists, set it as the destination
|
||||
if (name) {
|
||||
setInput(name)
|
||||
} else {
|
||||
setData({ address: debouncedInput, name: '' })
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (stale) return
|
||||
setData({ address: debouncedInput, name: '' })
|
||||
setError(null)
|
||||
})
|
||||
}
|
||||
// otherwise try to look up the address of the input, treated as an ENS name
|
||||
else {
|
||||
if (debouncedInput !== '') {
|
||||
library
|
||||
.resolveName(debouncedInput)
|
||||
.then(address => {
|
||||
if (stale) return
|
||||
// if the debounced input name resolves to an address
|
||||
if (address) {
|
||||
setData({ address: address, name: debouncedInput })
|
||||
setError(null)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (stale) return
|
||||
setError(true)
|
||||
})
|
||||
} else if (debouncedInput === '') {
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [debouncedInput, library])
|
||||
|
||||
function onInput(event) {
|
||||
setData({ address: undefined, name: undefined })
|
||||
setError(false)
|
||||
const handleInput = useCallback(
|
||||
event => {
|
||||
const input = event.target.value
|
||||
const checksummedInput = isAddress(input.replace(/\s/g, '')) // delete whitespace
|
||||
setInput(checksummedInput || input)
|
||||
}
|
||||
const withoutSpaces = input.replace(/\s+/g, '')
|
||||
onChange(withoutSpaces)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const error = Boolean(value.length > 0 && !loading && !address)
|
||||
|
||||
return (
|
||||
<InputPanel>
|
||||
<ContainerRow error={input !== '' && error}>
|
||||
<InputPanel id={id}>
|
||||
<ContainerRow error={error}>
|
||||
<InputContainer>
|
||||
<AutoColumn gap="md">
|
||||
<RowBetween>
|
||||
<TYPE.black color={theme.text2} fontWeight={500} fontSize={14}>
|
||||
Recipient
|
||||
</TYPE.black>
|
||||
{data.address && (
|
||||
<ExternalLink
|
||||
href={getEtherscanLink(chainId, data.name || data.address, 'address')}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
{address && (
|
||||
<ExternalLink href={getEtherscanLink(chainId, name ?? address, 'address')} style={{ fontSize: '14px' }}>
|
||||
(View on Etherscan)
|
||||
</ExternalLink>
|
||||
)}
|
||||
</RowBetween>
|
||||
<Input
|
||||
className="recipient-address-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
placeholder="Wallet Address or ENS name"
|
||||
error={input !== '' && error}
|
||||
onChange={onInput}
|
||||
value={input}
|
||||
error={error}
|
||||
pattern="^(0x[a-fA-F0-9]{40})$"
|
||||
onChange={handleInput}
|
||||
value={value}
|
||||
/>
|
||||
</AutoColumn>
|
||||
</InputContainer>
|
||||
|
@ -1,37 +1,18 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { darken } from 'polished'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { withRouter, NavLink, Link as HistoryLink, RouteComponentProps } from 'react-router-dom'
|
||||
import useBodyKeyDown from '../../hooks/useBodyKeyDown'
|
||||
import { NavLink, Link as HistoryLink } from 'react-router-dom'
|
||||
|
||||
import { CursorPointer } from '../../theme'
|
||||
import { ArrowLeft } from 'react-feather'
|
||||
import { RowBetween } from '../Row'
|
||||
import QuestionHelper from '../QuestionHelper'
|
||||
|
||||
const tabOrder = [
|
||||
{
|
||||
path: '/swap',
|
||||
textKey: 'swap',
|
||||
regex: /\/swap/
|
||||
},
|
||||
{
|
||||
path: '/send',
|
||||
textKey: 'send',
|
||||
regex: /\/send/
|
||||
},
|
||||
{
|
||||
path: '/pool',
|
||||
textKey: 'pool',
|
||||
regex: /\/pool/
|
||||
}
|
||||
]
|
||||
|
||||
const Tabs = styled.div`
|
||||
${({ theme }) => theme.flexRowNoWrap}
|
||||
align-items: center;
|
||||
border-radius: 3rem;
|
||||
justify-content: space-evenly;
|
||||
`
|
||||
|
||||
const activeClassName = 'ACTIVE'
|
||||
@ -43,7 +24,6 @@ const StyledNavLink = styled(NavLink).attrs({
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3rem;
|
||||
flex: 1 0 auto;
|
||||
border-radius: 3rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@ -68,43 +48,59 @@ const ActiveText = styled.div`
|
||||
font-size: 20px;
|
||||
`
|
||||
|
||||
const ArrowLink = styled(ArrowLeft)`
|
||||
const StyledArrowLeft = styled(ArrowLeft)`
|
||||
color: ${({ theme }) => theme.text1};
|
||||
`
|
||||
|
||||
function NavigationTabs({ location: { pathname }, history }: RouteComponentProps<{}>) {
|
||||
export function SwapPoolTabs({ active }: { active: 'swap' | 'pool' }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const navigate = useCallback(
|
||||
direction => {
|
||||
const tabIndex = tabOrder.findIndex(({ regex }) => pathname.match(regex))
|
||||
history.push(tabOrder[(tabIndex + tabOrder.length + direction) % tabOrder.length].path)
|
||||
},
|
||||
[pathname, history]
|
||||
)
|
||||
const navigateRight = useCallback(() => {
|
||||
navigate(1)
|
||||
}, [navigate])
|
||||
const navigateLeft = useCallback(() => {
|
||||
navigate(-1)
|
||||
}, [navigate])
|
||||
|
||||
useBodyKeyDown('ArrowRight', navigateRight)
|
||||
useBodyKeyDown('ArrowLeft', navigateLeft)
|
||||
|
||||
const adding = pathname.match('/add')
|
||||
const removing = pathname.match('/remove')
|
||||
const finding = pathname.match('/find')
|
||||
const creating = pathname.match('/create')
|
||||
|
||||
return (
|
||||
<>
|
||||
{adding || removing ? (
|
||||
<Tabs style={{ marginBottom: '20px' }}>
|
||||
<StyledNavLink id={`swap-nav-link`} to={'/swap'} isActive={() => active === 'swap'}>
|
||||
{t('swap')}
|
||||
</StyledNavLink>
|
||||
<StyledNavLink id={`pool-nav-link`} to={'/pool'} isActive={() => active === 'pool'}>
|
||||
{t('pool')}
|
||||
</StyledNavLink>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreatePoolTabs() {
|
||||
return (
|
||||
<Tabs>
|
||||
<RowBetween style={{ padding: '1rem' }}>
|
||||
<CursorPointer onClick={() => history.push('/pool')}>
|
||||
<ArrowLink />
|
||||
</CursorPointer>
|
||||
<HistoryLink to="/pool">
|
||||
<StyledArrowLeft />
|
||||
</HistoryLink>
|
||||
<ActiveText>Create Pool</ActiveText>
|
||||
<QuestionHelper text={'Use this interface to create a new pool.'} />
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export function FindPoolTabs() {
|
||||
return (
|
||||
<Tabs>
|
||||
<RowBetween style={{ padding: '1rem' }}>
|
||||
<HistoryLink to="/pool">
|
||||
<StyledArrowLeft />
|
||||
</HistoryLink>
|
||||
<ActiveText>Import Pool</ActiveText>
|
||||
<QuestionHelper text={"Use this tool to find pairs that don't automatically appear in the interface."} />
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export function AddRemoveTabs({ adding }: { adding: boolean }) {
|
||||
return (
|
||||
<Tabs>
|
||||
<RowBetween style={{ padding: '1rem' }}>
|
||||
<HistoryLink to="/pool">
|
||||
<StyledArrowLeft />
|
||||
</HistoryLink>
|
||||
<ActiveText>{adding ? 'Add' : 'Remove'} Liquidity</ActiveText>
|
||||
<QuestionHelper
|
||||
text={
|
||||
@ -115,42 +111,5 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps
|
||||
/>
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
) : finding ? (
|
||||
<Tabs>
|
||||
<RowBetween style={{ padding: '1rem' }}>
|
||||
<HistoryLink to="/pool">
|
||||
<ArrowLink />
|
||||
</HistoryLink>
|
||||
<ActiveText>Import Pool</ActiveText>
|
||||
<QuestionHelper text={"Use this tool to find pairs that don't automatically appear in the interface."} />
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
) : creating ? (
|
||||
<Tabs>
|
||||
<RowBetween style={{ padding: '1rem' }}>
|
||||
<HistoryLink to="/pool">
|
||||
<ArrowLink />
|
||||
</HistoryLink>
|
||||
<ActiveText>Create Pool</ActiveText>
|
||||
<QuestionHelper text={'Use this interface to create a new pool.'} />
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Tabs style={{ marginBottom: '20px' }}>
|
||||
{tabOrder.map(({ path, textKey, regex }) => (
|
||||
<StyledNavLink
|
||||
id={`${textKey}-nav-link`}
|
||||
key={path}
|
||||
to={path}
|
||||
isActive={(_, { pathname }) => !!pathname.match(regex)}
|
||||
>
|
||||
{t(textKey)}
|
||||
</StyledNavLink>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withRouter(NavigationTabs)
|
||||
|
@ -62,9 +62,7 @@ export default function TxnPopup({
|
||||
{success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />}
|
||||
</div>
|
||||
<AutoColumn gap="8px">
|
||||
<TYPE.body fontWeight={500}>
|
||||
{summary ? summary : 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}
|
||||
</TYPE.body>
|
||||
<TYPE.body fontWeight={500}>{summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}</TYPE.body>
|
||||
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink>
|
||||
</AutoColumn>
|
||||
<Fader count={count} />
|
||||
|
@ -130,7 +130,7 @@ export default function Web3Status() {
|
||||
const { active, account, connector, error } = useWeb3React()
|
||||
const contextNetwork = useWeb3React(NetworkContextName)
|
||||
|
||||
const ENSName = useENSName(account)
|
||||
const { ENSName } = useENSName(account)
|
||||
|
||||
const allTransactions = useAllTransactions()
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { Text } from 'rebass'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { Field } from '../../state/swap/actions'
|
||||
import { TYPE } from '../../theme'
|
||||
import { isAddress, shortenAddress } from '../../utils'
|
||||
import { AutoColumn } from '../Column'
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import TokenLogo from '../TokenLogo'
|
||||
@ -15,13 +16,15 @@ export default function SwapModalHeader({
|
||||
formattedAmounts,
|
||||
slippageAdjustedAmounts,
|
||||
priceImpactSeverity,
|
||||
independentField
|
||||
independentField,
|
||||
recipient
|
||||
}: {
|
||||
tokens: { [field in Field]?: Token }
|
||||
formattedAmounts: { [field in Field]?: string }
|
||||
slippageAdjustedAmounts: { [field in Field]?: TokenAmount }
|
||||
priceImpactSeverity: number
|
||||
independentField: Field
|
||||
recipient: string | null
|
||||
}) {
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
@ -71,6 +74,14 @@ export default function SwapModalHeader({
|
||||
</TYPE.italic>
|
||||
)}
|
||||
</AutoColumn>
|
||||
{recipient !== null ? (
|
||||
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }}>
|
||||
<TYPE.main>
|
||||
Output will be sent to{' '}
|
||||
<b title={recipient}>{isAddress(recipient) ? shortenAddress(recipient) : recipient}</b>
|
||||
</TYPE.main>
|
||||
</AutoColumn>
|
||||
) : null}
|
||||
</AutoColumn>
|
||||
)
|
||||
}
|
||||
|
@ -1,55 +0,0 @@
|
||||
import { TokenAmount } from '@uniswap/sdk'
|
||||
import React from 'react'
|
||||
import { Text } from 'rebass'
|
||||
import { useActiveWeb3React } from '../../hooks'
|
||||
import { ExternalLink, TYPE } from '../../theme'
|
||||
import { getEtherscanLink } from '../../utils'
|
||||
import Copy from '../AccountDetails/Copy'
|
||||
import { AutoColumn } from '../Column'
|
||||
import { AutoRow, RowBetween } from '../Row'
|
||||
import TokenLogo from '../TokenLogo'
|
||||
|
||||
export function TransferModalHeader({
|
||||
recipient,
|
||||
ENSName,
|
||||
amount
|
||||
}: {
|
||||
recipient: string
|
||||
ENSName: string
|
||||
amount: TokenAmount
|
||||
}) {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
return (
|
||||
<AutoColumn gap="lg" style={{ marginTop: '40px' }}>
|
||||
<RowBetween>
|
||||
<Text fontSize={36} fontWeight={500}>
|
||||
{amount?.toSignificant(6)} {amount?.token?.symbol}
|
||||
</Text>
|
||||
<TokenLogo address={amount?.token?.address} size={'30px'} />
|
||||
</RowBetween>
|
||||
<TYPE.darkGray fontSize={20}>To</TYPE.darkGray>
|
||||
{ENSName ? (
|
||||
<AutoColumn gap="lg">
|
||||
<TYPE.blue fontSize={36}>{ENSName}</TYPE.blue>
|
||||
<AutoRow gap="10px">
|
||||
<ExternalLink href={getEtherscanLink(chainId, ENSName, 'address')}>
|
||||
<TYPE.blue fontSize={18}>
|
||||
{recipient?.slice(0, 8)}...{recipient?.slice(34, 42)}↗
|
||||
</TYPE.blue>
|
||||
</ExternalLink>
|
||||
<Copy toCopy={recipient} />
|
||||
</AutoRow>
|
||||
</AutoColumn>
|
||||
) : (
|
||||
<AutoRow gap="10px">
|
||||
<ExternalLink href={getEtherscanLink(chainId, recipient, 'address')}>
|
||||
<TYPE.blue fontSize={36}>
|
||||
{recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}↗
|
||||
</TYPE.blue>
|
||||
</ExternalLink>
|
||||
<Copy toCopy={recipient} />
|
||||
</AutoRow>
|
||||
)}
|
||||
</AutoColumn>
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import styled from 'styled-components'
|
||||
import styled, { css } from 'styled-components'
|
||||
import { AutoColumn } from '../Column'
|
||||
import { Text } from 'rebass'
|
||||
|
||||
@ -8,17 +8,18 @@ export const Wrapper = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export const ArrowWrapper = styled.div`
|
||||
export const ArrowWrapper = styled.div<{ clickable: boolean }>`
|
||||
padding: 2px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
${({ clickable }) =>
|
||||
clickable
|
||||
? css`
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
: null}
|
||||
`
|
||||
|
||||
export const SectionBreak = styled.div`
|
||||
|
21
src/hooks/useENS.ts
Normal file
21
src/hooks/useENS.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { isAddress } from '../utils'
|
||||
import useENSAddress from './useENSAddress'
|
||||
import useENSName from './useENSName'
|
||||
|
||||
/**
|
||||
* Given a name or address, does a lookup to resolve to an address and name
|
||||
* @param nameOrAddress ENS name or address
|
||||
*/
|
||||
export default function useENS(
|
||||
nameOrAddress?: string | null
|
||||
): { loading: boolean; address: string | null; name: string | null } {
|
||||
const validated = isAddress(nameOrAddress)
|
||||
const reverseLookup = useENSName(validated ? validated : undefined)
|
||||
const lookup = useENSAddress(nameOrAddress)
|
||||
|
||||
return {
|
||||
loading: reverseLookup.loading || lookup.loading,
|
||||
address: validated ? validated : lookup.address,
|
||||
name: reverseLookup.ENSName ? reverseLookup.ENSName : !validated && lookup.address ? nameOrAddress || null : null
|
||||
}
|
||||
}
|
46
src/hooks/useENSAddress.ts
Normal file
46
src/hooks/useENSAddress.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useActiveWeb3React } from './index'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}, [library, ensName])
|
||||
|
||||
return address
|
||||
}
|
@ -6,39 +6,43 @@ import { useActiveWeb3React } from './index'
|
||||
* 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): string | null {
|
||||
export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } {
|
||||
const { library } = useActiveWeb3React()
|
||||
|
||||
const [ENSName, setENSName] = useState<string | null>(null)
|
||||
const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({
|
||||
loading: false,
|
||||
ENSName: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!library || !address) return
|
||||
const validated = isAddress(address)
|
||||
if (validated) {
|
||||
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(name)
|
||||
setENSName({ loading: false, ENSName: name })
|
||||
} else {
|
||||
setENSName(null)
|
||||
setENSName({ loading: false, ENSName: null })
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setENSName(null)
|
||||
setENSName({ loading: false, ENSName: null })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
setENSName(null)
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [library, address])
|
||||
|
||||
return ENSName
|
||||
|
@ -1,70 +0,0 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { TransactionResponse } from '@ethersproject/providers'
|
||||
import { WETH, TokenAmount, JSBI, ChainId } from '@uniswap/sdk'
|
||||
import { useMemo } from 'react'
|
||||
import { useTransactionAdder } from '../state/transactions/hooks'
|
||||
import { useTokenBalanceTreatingWETHasETH } from '../state/wallet/hooks'
|
||||
|
||||
import { calculateGasMargin, getSigner, isAddress } from '../utils'
|
||||
import { useTokenContract } from './useContract'
|
||||
import { useActiveWeb3React } from './index'
|
||||
import useENSName from './useENSName'
|
||||
|
||||
// returns a callback for sending a token amount, treating WETH as ETH
|
||||
// returns null with invalid arguments
|
||||
export function useSendCallback(amount?: TokenAmount, recipient?: string): null | (() => Promise<string>) {
|
||||
const { library, account, chainId } = useActiveWeb3React()
|
||||
const addTransaction = useTransactionAdder()
|
||||
const ensName = useENSName(recipient)
|
||||
const tokenContract = useTokenContract(amount?.token?.address)
|
||||
const balance = useTokenBalanceTreatingWETHasETH(account ?? undefined, amount?.token)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!amount) return null
|
||||
if (!amount.greaterThan(JSBI.BigInt(0))) return null
|
||||
if (!isAddress(recipient)) return null
|
||||
if (!balance) return null
|
||||
if (balance.lessThan(amount)) return null
|
||||
|
||||
const token = amount?.token
|
||||
|
||||
return async function onSend(): Promise<string> {
|
||||
if (!chainId || !library || !account || !tokenContract) {
|
||||
throw new Error('missing dependencies in onSend callback')
|
||||
}
|
||||
if (token.equals(WETH[chainId as ChainId])) {
|
||||
return getSigner(library, account)
|
||||
.sendTransaction({ to: recipient, value: BigNumber.from(amount.raw.toString()) })
|
||||
.then((response: TransactionResponse) => {
|
||||
addTransaction(response, {
|
||||
summary: 'Send ' + amount.toSignificant(3) + ' ' + token?.symbol + ' to ' + (ensName ?? recipient)
|
||||
})
|
||||
return response.hash
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error('Failed to transfer ETH', error)
|
||||
throw error
|
||||
})
|
||||
} else {
|
||||
return tokenContract.estimateGas
|
||||
.transfer(recipient, amount.raw.toString())
|
||||
.then(estimatedGasLimit =>
|
||||
tokenContract
|
||||
.transfer(recipient, amount.raw.toString(), {
|
||||
gasLimit: calculateGasMargin(estimatedGasLimit)
|
||||
})
|
||||
.then((response: TransactionResponse) => {
|
||||
addTransaction(response, {
|
||||
summary: 'Send ' + amount.toSignificant(3) + ' ' + token.symbol + ' to ' + (ensName ?? recipient)
|
||||
})
|
||||
return response.hash
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
console.error('Failed token transfer', error)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [addTransaction, library, account, chainId, amount, ensName, recipient, tokenContract, balance])
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { MaxUint256 } from '@ethersproject/constants'
|
||||
import { Contract } from '@ethersproject/contracts'
|
||||
import { ChainId, Trade, TradeType, WETH } from '@uniswap/sdk'
|
||||
import { Trade, TradeType, WETH } from '@uniswap/sdk'
|
||||
import { useMemo } from 'react'
|
||||
import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, ROUTER_ADDRESS } from '../constants'
|
||||
import { useTokenAllowance } from '../data/Allowances'
|
||||
import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1'
|
||||
import { Field } from '../state/swap/actions'
|
||||
import { useTransactionAdder } from '../state/transactions/hooks'
|
||||
import { calculateGasMargin, getRouterContract, isAddress } from '../utils'
|
||||
import { calculateGasMargin, getRouterContract, shortenAddress, isAddress } from '../utils'
|
||||
import { computeSlippageAdjustedAmounts } from '../utils/prices'
|
||||
import { useActiveWeb3React } from './index'
|
||||
import { useV1ExchangeContract } from './useContract'
|
||||
import useENSName from './useENSName'
|
||||
import useENS from './useENS'
|
||||
import { Version } from './useToggledVersion'
|
||||
|
||||
enum SwapType {
|
||||
@ -59,15 +59,16 @@ function getSwapType(trade: Trade | undefined): SwapType | undefined {
|
||||
// returns a function that will execute a swap, if the parameters are all valid
|
||||
// and the user has approved the slippage adjusted input amount for the trade
|
||||
export function useSwapCallback(
|
||||
trade?: Trade, // trade to execute, required
|
||||
trade: Trade | undefined, // trade to execute, required
|
||||
allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips
|
||||
deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now
|
||||
to?: string // recipient of output, optional
|
||||
recipientAddressOrName: string // the ENS name or address of the recipient of the trade
|
||||
): null | (() => Promise<string>) {
|
||||
const { account, chainId, library } = useActiveWeb3React()
|
||||
const addTransaction = useTransactionAdder()
|
||||
const recipient = to ? isAddress(to) : account
|
||||
const ensName = useENSName(to)
|
||||
|
||||
const { address: recipient } = useENS(recipientAddressOrName)
|
||||
|
||||
const tradeVersion = getTradeVersion(trade)
|
||||
const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true)
|
||||
const inputAllowance = useTokenAllowance(
|
||||
@ -77,7 +78,7 @@ export function useSwapCallback(
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!trade || !recipient || !tradeVersion) return null
|
||||
if (!trade || !recipient || !library || !account || !tradeVersion || !chainId) return null
|
||||
|
||||
// will always be defined
|
||||
const {
|
||||
@ -89,17 +90,13 @@ export function useSwapCallback(
|
||||
|
||||
// no allowance
|
||||
if (
|
||||
!trade.inputAmount.token.equals(WETH[chainId as ChainId]) &&
|
||||
!trade.inputAmount.token.equals(WETH[chainId]) &&
|
||||
(!inputAllowance || slippageAdjustedInput.greaterThan(inputAllowance))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return async function onSwap() {
|
||||
if (!chainId || !library || !account) {
|
||||
throw new Error('missing dependencies in onSwap callback')
|
||||
}
|
||||
|
||||
const contract: Contract | null =
|
||||
tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange
|
||||
if (!contract) {
|
||||
@ -283,7 +280,12 @@ export function useSwapCallback(
|
||||
const outputAmount = slippageAdjustedOutput.toSignificant(3)
|
||||
|
||||
const base = `Swap ${inputAmount} ${inputSymbol} for ${outputAmount} ${outputSymbol}`
|
||||
const withRecipient = recipient === account ? base : `${base} to ${ensName ?? recipient}`
|
||||
const withRecipient =
|
||||
recipient === account
|
||||
? base
|
||||
: `${base} to ${
|
||||
isAddress(recipientAddressOrName) ? shortenAddress(recipientAddressOrName) : recipientAddressOrName
|
||||
}`
|
||||
|
||||
const withVersion =
|
||||
tradeVersion === Version.v2 ? withRecipient : `${withRecipient} on ${tradeVersion.toUpperCase()}`
|
||||
@ -310,15 +312,15 @@ export function useSwapCallback(
|
||||
}, [
|
||||
trade,
|
||||
recipient,
|
||||
tradeVersion,
|
||||
allowedSlippage,
|
||||
chainId,
|
||||
inputAllowance,
|
||||
library,
|
||||
account,
|
||||
tradeVersion,
|
||||
chainId,
|
||||
allowedSlippage,
|
||||
inputAllowance,
|
||||
v1Exchange,
|
||||
deadline,
|
||||
addTransaction,
|
||||
ensName
|
||||
recipientAddressOrName,
|
||||
addTransaction
|
||||
])
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import { Field } from '../../state/mint/actions'
|
||||
import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback'
|
||||
import { useWalletModalToggle } from '../../state/application/hooks'
|
||||
import { useUserSlippageTolerance, useUserDeadline, useIsExpertMode } from '../../state/user/hooks'
|
||||
import { AddRemoveTabs } from '../../components/NavigationTabs'
|
||||
|
||||
export default function AddLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) {
|
||||
useDefaultsFromURLMatchParams(params)
|
||||
@ -307,6 +308,7 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps<
|
||||
return (
|
||||
<>
|
||||
<AppBody>
|
||||
<AddRemoveTabs adding={true} />
|
||||
<Wrapper>
|
||||
<ConfirmationModal
|
||||
isOpen={showConfirm}
|
||||
|
@ -14,7 +14,6 @@ import RemoveV1Exchange from './MigrateV1/RemoveV1Exchange'
|
||||
import Pool from './Pool'
|
||||
import PoolFinder from './PoolFinder'
|
||||
import RemoveLiquidity from './RemoveLiquidity'
|
||||
import Send from './Send'
|
||||
import Swap from './Swap'
|
||||
import { RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects'
|
||||
|
||||
@ -69,7 +68,7 @@ export default function App() {
|
||||
<Switch>
|
||||
<Route exact strict path="/swap" component={Swap} />
|
||||
<Route exact strict path="/swap/:outputCurrency" component={RedirectToSwap} />
|
||||
<Route exact strict path="/send" component={Send} />
|
||||
<Route exact strict path="/send" component={RedirectPathToSwapOnly} />
|
||||
<Route exact strict path="/find" component={PoolFinder} />
|
||||
<Route exact strict path="/pool" component={Pool} />
|
||||
<Route exact strict path="/create" component={CreatePool} />
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import NavigationTabs from '../components/NavigationTabs'
|
||||
|
||||
export const BodyWrapper = styled.div`
|
||||
position: relative;
|
||||
@ -17,10 +16,5 @@ export const BodyWrapper = styled.div`
|
||||
* The styled container element that wraps the content of most pages and the tabs.
|
||||
*/
|
||||
export default function AppBody({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<NavigationTabs />
|
||||
<>{children}</>
|
||||
</BodyWrapper>
|
||||
)
|
||||
return <BodyWrapper>{children}</BodyWrapper>
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { RouteComponentProps, Redirect } from 'react-router-dom'
|
||||
import { Token, WETH } from '@uniswap/sdk'
|
||||
import { CreatePoolTabs } from '../../components/NavigationTabs'
|
||||
import AppBody from '../AppBody'
|
||||
|
||||
import Row, { AutoRow } from '../../components/Row'
|
||||
@ -56,6 +57,7 @@ export default function CreatePool({ location }: RouteComponentProps) {
|
||||
|
||||
return (
|
||||
<AppBody>
|
||||
<CreatePoolTabs />
|
||||
<AutoColumn gap="20px">
|
||||
<AutoColumn gap="24px">
|
||||
{!token0Address ? (
|
||||
|
@ -2,6 +2,7 @@ import React, { useState, useContext, useCallback } from 'react'
|
||||
import styled, { ThemeContext } from 'styled-components'
|
||||
import { JSBI } from '@uniswap/sdk'
|
||||
import { RouteComponentProps } from 'react-router-dom'
|
||||
import { SwapPoolTabs } from '../../components/NavigationTabs'
|
||||
|
||||
import Question from '../../components/QuestionHelper'
|
||||
import PairSearchModal from '../../components/SearchModal/PairSearchModal'
|
||||
@ -70,6 +71,7 @@ export default function Pool({ history }: RouteComponentProps) {
|
||||
|
||||
return (
|
||||
<AppBody>
|
||||
<SwapPoolTabs active={'pool'} />
|
||||
<AutoColumn gap="lg" justify="center">
|
||||
<ButtonPrimary
|
||||
id="join-pool-button"
|
||||
|
@ -5,6 +5,7 @@ import { Text } from 'rebass'
|
||||
import { ButtonDropdownLight } from '../../components/Button'
|
||||
import { LightCard } from '../../components/Card'
|
||||
import { AutoColumn, ColumnCenter } from '../../components/Column'
|
||||
import { FindPoolTabs } from '../../components/NavigationTabs'
|
||||
import PositionCard from '../../components/PositionCard'
|
||||
import Row from '../../components/Row'
|
||||
import TokenSearchModal from '../../components/SearchModal/TokenSearchModal'
|
||||
@ -61,6 +62,7 @@ export default function PoolFinder() {
|
||||
|
||||
return (
|
||||
<AppBody>
|
||||
<FindPoolTabs />
|
||||
<AutoColumn gap="md">
|
||||
<ButtonDropdownLight
|
||||
onClick={() => {
|
||||
|
@ -13,6 +13,7 @@ import { AutoColumn, ColumnCenter } from '../../components/Column'
|
||||
import ConfirmationModal from '../../components/ConfirmationModal'
|
||||
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
|
||||
import DoubleLogo from '../../components/DoubleLogo'
|
||||
import { AddRemoveTabs } from '../../components/NavigationTabs'
|
||||
import PositionCard from '../../components/PositionCard'
|
||||
import Row, { RowBetween, RowFixed } from '../../components/Row'
|
||||
|
||||
@ -390,6 +391,7 @@ export default function RemoveLiquidity({ match: { params } }: RouteComponentPro
|
||||
return (
|
||||
<>
|
||||
<AppBody>
|
||||
<AddRemoveTabs adding={false} />
|
||||
<Wrapper>
|
||||
<ConfirmationModal
|
||||
isOpen={showConfirm}
|
||||
|
@ -1,592 +0,0 @@
|
||||
import { JSBI, TokenAmount, WETH } from '@uniswap/sdk'
|
||||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import { ArrowDown } from 'react-feather'
|
||||
import ReactGA from 'react-ga'
|
||||
import { Text } from 'rebass'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import AddressInputPanel from '../../components/AddressInputPanel'
|
||||
import { ButtonError, ButtonLight, ButtonPrimary, ButtonSecondary } from '../../components/Button'
|
||||
import Card, { BlueCard, GreyCard } from '../../components/Card'
|
||||
import { AutoColumn, ColumnCenter } from '../../components/Column'
|
||||
import ConfirmationModal from '../../components/ConfirmationModal'
|
||||
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
|
||||
import { AutoRow, RowBetween } from '../../components/Row'
|
||||
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
|
||||
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
|
||||
import SwapModalFooter from '../../components/swap/SwapModalFooter'
|
||||
import { ArrowWrapper, BottomGrouping, Dots, InputGroup, StyledNumerical, Wrapper } from '../../components/swap/styleds'
|
||||
import TradePrice from '../../components/swap/TradePrice'
|
||||
import { TransferModalHeader } from '../../components/swap/TransferModalHeader'
|
||||
import BetterTradeLink from '../../components/swap/BetterTradeLink'
|
||||
import TokenLogo from '../../components/TokenLogo'
|
||||
import { TokenWarningCards } from '../../components/TokenWarningCard'
|
||||
import { INITIAL_ALLOWED_SLIPPAGE, MIN_ETH, BETTER_TRADE_LINK_THRESHOLD } from '../../constants'
|
||||
import { getTradeVersion, isTradeBetter } from '../../data/V1'
|
||||
import { useActiveWeb3React } from '../../hooks'
|
||||
import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback'
|
||||
import { useSendCallback } from '../../hooks/useSendCallback'
|
||||
import { useSwapCallback } from '../../hooks/useSwapCallback'
|
||||
import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks'
|
||||
import useToggledVersion, { Version } from '../../hooks/useToggledVersion'
|
||||
import { Field } from '../../state/swap/actions'
|
||||
import {
|
||||
useDefaultsFromURLSearch,
|
||||
useDerivedSwapInfo,
|
||||
useSwapActionHandlers,
|
||||
useSwapState
|
||||
} from '../../state/swap/hooks'
|
||||
import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks'
|
||||
import { CursorPointer, TYPE } from '../../theme'
|
||||
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
|
||||
import AppBody from '../AppBody'
|
||||
import { useUserSlippageTolerance, useUserDeadline, useExpertModeManager } from '../../state/user/hooks'
|
||||
import { ClickableText } from '../Pool/styleds'
|
||||
|
||||
export default function Send() {
|
||||
// override auto ETH populate to allow for single inputs or swap and send
|
||||
useDefaultsFromURLSearch(true)
|
||||
|
||||
// text translation
|
||||
// const { t } = useTranslation()
|
||||
const { chainId, account } = useActiveWeb3React()
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
// toggle wallet when disconnected
|
||||
const toggleWalletModal = useWalletModalToggle()
|
||||
|
||||
// for expert mode
|
||||
const toggleSettings = useToggleSettingsMenu()
|
||||
const [expertMode] = useExpertModeManager()
|
||||
|
||||
// sending state
|
||||
const [sendingWithSwap, setSendingWithSwap] = useState<boolean>(false)
|
||||
const [recipient, setRecipient] = useState<string>('')
|
||||
const [ENS, setENS] = useState<string>('')
|
||||
const [recipientError, setRecipientError] = useState<string | null>('Enter a Recipient')
|
||||
|
||||
// trade details, check query params for initial state
|
||||
const {
|
||||
independentField,
|
||||
typedValue,
|
||||
[Field.OUTPUT]: { address: output }
|
||||
} = useSwapState()
|
||||
|
||||
// if output is valid set to sending view (will reset to undefined on remove swap)
|
||||
useEffect(() => {
|
||||
if (output) {
|
||||
setSendingWithSwap(true)
|
||||
}
|
||||
}, [output])
|
||||
|
||||
const {
|
||||
parsedAmount,
|
||||
bestTrade: bestTradeV2,
|
||||
tokenBalances,
|
||||
tokens,
|
||||
error: swapError,
|
||||
v1Trade
|
||||
} = useDerivedSwapInfo()
|
||||
|
||||
const toggledVersion = useToggledVersion()
|
||||
const bestTrade = {
|
||||
[Version.v1]: v1Trade,
|
||||
[Version.v2]: bestTradeV2
|
||||
}[toggledVersion]
|
||||
|
||||
const betterTradeLinkVersion: Version | undefined =
|
||||
toggledVersion === Version.v2 && isTradeBetter(bestTradeV2, v1Trade, BETTER_TRADE_LINK_THRESHOLD)
|
||||
? Version.v1
|
||||
: toggledVersion === Version.v1 && isTradeBetter(v1Trade, bestTradeV2)
|
||||
? Version.v2
|
||||
: undefined
|
||||
|
||||
const parsedAmounts = {
|
||||
[Field.INPUT]: independentField === Field.INPUT ? parsedAmount : bestTrade?.inputAmount,
|
||||
[Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : bestTrade?.outputAmount
|
||||
}
|
||||
|
||||
const isSwapValid = !swapError && !recipientError && bestTrade
|
||||
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
|
||||
|
||||
// modal and loading
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false) // show confirmation modal
|
||||
const [attemptingTxn, setAttemptingTxn] = useState<boolean>(false) // waiting for user confirmaion/rejection
|
||||
const [txHash, setTxHash] = useState<string>('')
|
||||
const [deadline] = useUserDeadline() // custom from user settings
|
||||
const [allowedSlippage] = useUserSlippageTolerance() // custom from user settings
|
||||
|
||||
const route = bestTrade?.route
|
||||
const userHasSpecifiedInputOutput =
|
||||
!!tokens[Field.INPUT] &&
|
||||
!!tokens[Field.OUTPUT] &&
|
||||
!!parsedAmounts[independentField] &&
|
||||
parsedAmounts[independentField].greaterThan(JSBI.BigInt(0))
|
||||
const noRoute = !route
|
||||
|
||||
// check whether the user has approved the router on the input token
|
||||
const [approval, approveCallback] = useApproveCallbackFromTrade(bestTrade, allowedSlippage)
|
||||
|
||||
// check if user has gone through approval process, used to show two step buttons, reset on token change
|
||||
const [approvalSubmitted, setApprovalSubmitted] = useState<boolean>(false)
|
||||
|
||||
// mark when a user has submitted an approval, reset onTokenSelection for input field
|
||||
useEffect(() => {
|
||||
if (approval === ApprovalState.PENDING) {
|
||||
setApprovalSubmitted(true)
|
||||
}
|
||||
}, [approval, approvalSubmitted])
|
||||
|
||||
const formattedAmounts = {
|
||||
[independentField]: typedValue,
|
||||
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
|
||||
}
|
||||
|
||||
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(bestTrade, allowedSlippage)
|
||||
|
||||
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(bestTrade)
|
||||
|
||||
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers()
|
||||
|
||||
const maxAmountInput: TokenAmount =
|
||||
!!tokenBalances[Field.INPUT] &&
|
||||
!!tokens[Field.INPUT] &&
|
||||
!!WETH[chainId] &&
|
||||
tokenBalances[Field.INPUT].greaterThan(
|
||||
new TokenAmount(tokens[Field.INPUT], tokens[Field.INPUT].equals(WETH[chainId]) ? MIN_ETH : '0')
|
||||
)
|
||||
? tokens[Field.INPUT].equals(WETH[chainId])
|
||||
? tokenBalances[Field.INPUT].subtract(new TokenAmount(WETH[chainId], MIN_ETH))
|
||||
: tokenBalances[Field.INPUT]
|
||||
: undefined
|
||||
const atMaxAmountInput: boolean =
|
||||
!!maxAmountInput && !!parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined
|
||||
|
||||
const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline, recipient)
|
||||
|
||||
function onSwap() {
|
||||
if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) {
|
||||
return
|
||||
}
|
||||
|
||||
setAttemptingTxn(true)
|
||||
swapCallback()
|
||||
.then(hash => {
|
||||
setAttemptingTxn(false)
|
||||
setTxHash(hash)
|
||||
|
||||
ReactGA.event({
|
||||
category: 'Send',
|
||||
action: recipient === account ? 'Swap w/o Send' : 'Swap w/ Send',
|
||||
label: [
|
||||
bestTrade.inputAmount.token.symbol,
|
||||
bestTrade.outputAmount.token.symbol,
|
||||
getTradeVersion(bestTrade)
|
||||
].join('/')
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
setAttemptingTxn(false)
|
||||
// we only care if the error is something _other_ than the user rejected the tx
|
||||
if (error?.code !== 4001) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sendCallback = useSendCallback(parsedAmounts?.[Field.INPUT], recipient)
|
||||
const isSendValid = sendCallback !== null && (sendingWithSwap === false || approval === ApprovalState.APPROVED)
|
||||
|
||||
async function onSend() {
|
||||
setAttemptingTxn(true)
|
||||
sendCallback()
|
||||
.then(hash => {
|
||||
setAttemptingTxn(false)
|
||||
setTxHash(hash)
|
||||
|
||||
ReactGA.event({ category: 'Send', action: 'Send', label: tokens[Field.INPUT]?.symbol })
|
||||
})
|
||||
.catch(error => {
|
||||
setAttemptingTxn(false)
|
||||
// we only care if the error is something _other_ than the user rejected the tx
|
||||
if (error?.code !== 4001) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const [showInverted, setShowInverted] = useState<boolean>(false)
|
||||
|
||||
// warnings on slippage
|
||||
const severity = !sendingWithSwap ? 0 : warningSeverity(priceImpactWithoutFee)
|
||||
|
||||
// show approval buttons when: no errors on input, not approved or pending, or has been approved in this session
|
||||
const showApproveFlow =
|
||||
((sendingWithSwap && isSwapValid) || (!sendingWithSwap && isSendValid)) &&
|
||||
(approval === ApprovalState.NOT_APPROVED ||
|
||||
approval === ApprovalState.PENDING ||
|
||||
(approvalSubmitted && approval === ApprovalState.APPROVED)) &&
|
||||
!(severity > 3 && !expertMode)
|
||||
|
||||
function modalHeader() {
|
||||
if (!sendingWithSwap) {
|
||||
return <TransferModalHeader amount={parsedAmounts?.[Field.INPUT]} ENSName={ENS} recipient={recipient} />
|
||||
}
|
||||
|
||||
if (sendingWithSwap) {
|
||||
return (
|
||||
<AutoColumn gap="lg" style={{ marginTop: '40px' }}>
|
||||
<AutoColumn gap="sm">
|
||||
<AutoRow gap="10px">
|
||||
<TokenLogo address={tokens[Field.OUTPUT]?.address} size={'30px'} />
|
||||
<Text fontSize={36} fontWeight={500}>
|
||||
{slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4)} {tokens[Field.OUTPUT]?.symbol}
|
||||
</Text>
|
||||
</AutoRow>
|
||||
<BlueCard>
|
||||
Via {parsedAmounts[Field.INPUT]?.toSignificant(4)} {tokens[Field.INPUT]?.symbol} swap
|
||||
</BlueCard>
|
||||
</AutoColumn>
|
||||
<AutoColumn gap="sm">
|
||||
<TYPE.darkGray fontSize={20}>To</TYPE.darkGray>
|
||||
<TYPE.blue fontSize={36}>
|
||||
{recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}
|
||||
</TYPE.blue>
|
||||
</AutoColumn>
|
||||
</AutoColumn>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function modalBottom() {
|
||||
if (!sendingWithSwap) {
|
||||
return (
|
||||
<AutoColumn>
|
||||
<ButtonPrimary onClick={onSend} id="confirm-send">
|
||||
<Text color="white" fontSize={20}>
|
||||
Confirm send
|
||||
</Text>
|
||||
</ButtonPrimary>
|
||||
</AutoColumn>
|
||||
)
|
||||
}
|
||||
|
||||
if (sendingWithSwap) {
|
||||
return (
|
||||
<SwapModalFooter
|
||||
trade={bestTrade}
|
||||
onSwap={onSwap}
|
||||
setShowInverted={setShowInverted}
|
||||
severity={severity}
|
||||
showInverted={showInverted}
|
||||
slippageAdjustedAmounts={slippageAdjustedAmounts}
|
||||
priceImpactWithoutFee={priceImpactWithoutFee}
|
||||
parsedAmounts={parsedAmounts}
|
||||
realizedLPFee={realizedLPFee}
|
||||
confirmText={severity > 2 ? 'Send Anyway' : 'Confirm Send'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// text to show while loading
|
||||
const pendingText: string = sendingWithSwap
|
||||
? `Sending ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol} to ${recipient}`
|
||||
: `Sending ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${tokens[Field.INPUT]?.symbol} to ${recipient}`
|
||||
|
||||
const allBalances = useAllTokenBalancesTreatingWETHasETH() // only for 0 balance token selection behavior
|
||||
const swapState = useSwapState()
|
||||
function _onTokenSelect(address: string) {
|
||||
// if no user balance - switch view to a send with swap
|
||||
const hasBalance = allBalances?.[address]?.greaterThan('0') ?? false
|
||||
if (!hasBalance) {
|
||||
onTokenSelection(
|
||||
Field.INPUT,
|
||||
swapState[Field.INPUT]?.address === address ? null : swapState[Field.INPUT]?.address
|
||||
)
|
||||
onTokenSelection(Field.OUTPUT, address)
|
||||
setSendingWithSwap(true)
|
||||
} else {
|
||||
onTokenSelection(Field.INPUT, address)
|
||||
}
|
||||
}
|
||||
|
||||
function _onRecipient(result) {
|
||||
if (result.address) {
|
||||
setRecipient(result.address)
|
||||
} else {
|
||||
setRecipient('')
|
||||
}
|
||||
if (result.name) {
|
||||
setENS(result.name)
|
||||
}
|
||||
}
|
||||
|
||||
const sendAmountError =
|
||||
!sendingWithSwap && JSBI.equal(parsedAmounts?.[Field.INPUT]?.raw ?? JSBI.BigInt(0), JSBI.BigInt(0))
|
||||
? 'Enter an amount'
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{sendingWithSwap ? <TokenWarningCards tokens={tokens} /> : null}
|
||||
<AppBody>
|
||||
<Wrapper id="send-page">
|
||||
<ConfirmationModal
|
||||
isOpen={showConfirm}
|
||||
title={sendingWithSwap ? 'Confirm swap and send' : 'Confirm Send'}
|
||||
onDismiss={() => {
|
||||
setShowConfirm(false)
|
||||
if (txHash) {
|
||||
onUserInput(Field.INPUT, '')
|
||||
}
|
||||
setTxHash('')
|
||||
}}
|
||||
attemptingTxn={attemptingTxn}
|
||||
hash={txHash}
|
||||
topContent={modalHeader}
|
||||
bottomContent={modalBottom}
|
||||
pendingText={pendingText}
|
||||
/>
|
||||
{!sendingWithSwap && (
|
||||
<AutoColumn justify="center" style={{ marginBottom: '1rem' }}>
|
||||
<InputGroup gap="lg" justify="center">
|
||||
<StyledNumerical
|
||||
id="sending-no-swap-input"
|
||||
value={formattedAmounts[Field.INPUT]}
|
||||
onUserInput={val => onUserInput(Field.INPUT, val)}
|
||||
/>
|
||||
<CurrencyInputPanel
|
||||
field={Field.INPUT}
|
||||
value={formattedAmounts[Field.INPUT]}
|
||||
onUserInput={(field, val) => onUserInput(Field.INPUT, val)}
|
||||
onMax={() => {
|
||||
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
|
||||
}}
|
||||
showMaxButton={!atMaxAmountInput}
|
||||
token={tokens[Field.INPUT]}
|
||||
onTokenSelection={address => _onTokenSelect(address)}
|
||||
hideBalance={true}
|
||||
hideInput={true}
|
||||
showSendWithSwap={true}
|
||||
label={''}
|
||||
id="swap-currency-input"
|
||||
otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address}
|
||||
/>
|
||||
</InputGroup>
|
||||
<RowBetween style={{ width: 'fit-content' }}>
|
||||
<ButtonSecondary
|
||||
width="fit-content"
|
||||
style={{ fontSize: '14px' }}
|
||||
padding={'4px 8px'}
|
||||
onClick={() => setSendingWithSwap(true)}
|
||||
>
|
||||
+ Add a swap
|
||||
</ButtonSecondary>
|
||||
{account && (
|
||||
<ButtonSecondary
|
||||
style={{ fontSize: '14px', marginLeft: '8px' }}
|
||||
padding={'4px 8px'}
|
||||
width="fit-content"
|
||||
disabled={atMaxAmountInput}
|
||||
onClick={() => {
|
||||
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
|
||||
}}
|
||||
>
|
||||
Input Max
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</RowBetween>
|
||||
</AutoColumn>
|
||||
)}
|
||||
<AutoColumn gap={'md'}>
|
||||
{sendingWithSwap && (
|
||||
<>
|
||||
<CurrencyInputPanel
|
||||
field={Field.INPUT}
|
||||
label={independentField === Field.OUTPUT && parsedAmounts[Field.INPUT] ? 'From (estimated)' : 'From'}
|
||||
value={formattedAmounts[Field.INPUT]}
|
||||
showMaxButton={!atMaxAmountInput}
|
||||
token={tokens[Field.INPUT]}
|
||||
onUserInput={onUserInput}
|
||||
onMax={() => {
|
||||
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
|
||||
}}
|
||||
onTokenSelection={address => {
|
||||
setApprovalSubmitted(false)
|
||||
onTokenSelection(Field.INPUT, address)
|
||||
}}
|
||||
otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address}
|
||||
id="swap-currency-input"
|
||||
/>
|
||||
{sendingWithSwap ? (
|
||||
<ColumnCenter>
|
||||
<RowBetween padding="0 1rem 0 12px">
|
||||
<ArrowWrapper onClick={onSwitchTokens}>
|
||||
<ArrowDown size="16" color={theme.text2} onClick={onSwitchTokens} />
|
||||
</ArrowWrapper>
|
||||
<ButtonSecondary
|
||||
onClick={() => {
|
||||
setSendingWithSwap(false)
|
||||
onTokenSelection(Field.OUTPUT, null)
|
||||
}}
|
||||
style={{ marginRight: '0px', width: 'auto', fontSize: '14px' }}
|
||||
padding={'4px 6px'}
|
||||
>
|
||||
Remove Swap
|
||||
</ButtonSecondary>
|
||||
</RowBetween>
|
||||
</ColumnCenter>
|
||||
) : (
|
||||
<CursorPointer>
|
||||
<AutoColumn style={{ padding: '0 1rem' }}>
|
||||
<ArrowWrapper>
|
||||
<ArrowDown
|
||||
size="16"
|
||||
onClick={onSwitchTokens}
|
||||
color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2}
|
||||
/>
|
||||
</ArrowWrapper>
|
||||
</AutoColumn>
|
||||
</CursorPointer>
|
||||
)}
|
||||
<CurrencyInputPanel
|
||||
field={Field.OUTPUT}
|
||||
value={formattedAmounts[Field.OUTPUT]}
|
||||
onUserInput={onUserInput}
|
||||
label={independentField === Field.INPUT && parsedAmounts[Field.OUTPUT] ? 'To (estimated)' : 'To'}
|
||||
showMaxButton={false}
|
||||
token={tokens[Field.OUTPUT]}
|
||||
onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)}
|
||||
otherSelectedTokenAddress={tokens[Field.INPUT]?.address}
|
||||
id="swap-currency-output"
|
||||
/>
|
||||
{sendingWithSwap && (
|
||||
<RowBetween padding="0 1rem 0 12px">
|
||||
<ArrowDown size="16" color={theme.text2} />
|
||||
</RowBetween>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AutoColumn gap="lg" justify="center">
|
||||
<AddressInputPanel
|
||||
onChange={_onRecipient}
|
||||
onError={(error: boolean, input) => {
|
||||
if (error && input !== '') {
|
||||
setRecipientError('Invalid Recipient')
|
||||
} else if (error && input === '') {
|
||||
setRecipientError('Enter a Recipient')
|
||||
} else {
|
||||
setRecipientError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AutoColumn>
|
||||
{sendingWithSwap && (
|
||||
<Card padding={'.25rem .75rem 0 .75rem'} borderRadius={'20px'}>
|
||||
<AutoColumn gap="4px">
|
||||
<RowBetween align="center">
|
||||
<Text fontWeight={500} fontSize={14} color={theme.text2}>
|
||||
Price
|
||||
</Text>
|
||||
<TradePrice
|
||||
inputToken={tokens[Field.INPUT]}
|
||||
outputToken={tokens[Field.OUTPUT]}
|
||||
price={bestTrade?.executionPrice}
|
||||
showInverted={showInverted}
|
||||
setShowInverted={setShowInverted}
|
||||
/>
|
||||
</RowBetween>
|
||||
|
||||
{allowedSlippage !== INITIAL_ALLOWED_SLIPPAGE && (
|
||||
<RowBetween align="center">
|
||||
<ClickableText>
|
||||
<Text fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
|
||||
Slippage Tolerance
|
||||
</Text>
|
||||
</ClickableText>
|
||||
<ClickableText>
|
||||
<Text fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
|
||||
{allowedSlippage ? allowedSlippage / 100 : '-'}%
|
||||
</Text>
|
||||
</ClickableText>
|
||||
</RowBetween>
|
||||
)}
|
||||
</AutoColumn>
|
||||
</Card>
|
||||
)}
|
||||
</AutoColumn>
|
||||
<BottomGrouping>
|
||||
{!account ? (
|
||||
<ButtonLight
|
||||
onClick={() => {
|
||||
toggleWalletModal()
|
||||
}}
|
||||
>
|
||||
Connect Wallet
|
||||
</ButtonLight>
|
||||
) : noRoute && userHasSpecifiedInputOutput ? (
|
||||
<GreyCard style={{ textAlign: 'center' }}>
|
||||
<TYPE.main mb="4px">Insufficient liquidity for this trade.</TYPE.main>
|
||||
</GreyCard>
|
||||
) : showApproveFlow ? (
|
||||
<RowBetween>
|
||||
<ButtonPrimary
|
||||
onClick={approveCallback}
|
||||
disabled={approval !== ApprovalState.NOT_APPROVED || approvalSubmitted}
|
||||
width="48%"
|
||||
altDisbaledStyle={approval === ApprovalState.PENDING} // show solid button while waiting
|
||||
>
|
||||
{approval === ApprovalState.PENDING ? (
|
||||
<Dots>Approving</Dots>
|
||||
) : approvalSubmitted && approval === ApprovalState.APPROVED ? (
|
||||
'Approved'
|
||||
) : (
|
||||
'Approve ' + tokens[Field.INPUT]?.symbol
|
||||
)}
|
||||
</ButtonPrimary>
|
||||
<ButtonError
|
||||
onClick={() => {
|
||||
expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true)
|
||||
}}
|
||||
width="48%"
|
||||
id="send-button"
|
||||
disabled={approval !== ApprovalState.APPROVED}
|
||||
error={sendingWithSwap && isSwapValid && severity > 2}
|
||||
>
|
||||
<Text fontSize={16} fontWeight={500}>
|
||||
{severity > 3 && !expertMode ? `Price Impact High` : `Send${severity > 2 ? ' Anyway' : ''}`}
|
||||
</Text>
|
||||
</ButtonError>
|
||||
</RowBetween>
|
||||
) : (
|
||||
<ButtonError
|
||||
onClick={() => {
|
||||
expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true)
|
||||
}}
|
||||
id="send-button"
|
||||
disabled={
|
||||
(sendingWithSwap && !isSwapValid) ||
|
||||
(!sendingWithSwap && !isSendValid) ||
|
||||
(severity > 3 && !expertMode && sendingWithSwap)
|
||||
}
|
||||
error={sendingWithSwap && isSwapValid && severity > 2}
|
||||
>
|
||||
<Text fontSize={20} fontWeight={500}>
|
||||
{(sendingWithSwap ? swapError : null) ||
|
||||
sendAmountError ||
|
||||
recipientError ||
|
||||
(severity > 3 && !expertMode && `Price Impact Too High`) ||
|
||||
`Send${severity > 2 ? ' Anyway' : ''}`}
|
||||
</Text>
|
||||
</ButtonError>
|
||||
)}
|
||||
{betterTradeLinkVersion && <BetterTradeLink version={betterTradeLinkVersion} />}
|
||||
</BottomGrouping>
|
||||
</Wrapper>
|
||||
</AppBody>
|
||||
|
||||
<AdvancedSwapDetailsDropdown trade={bestTrade} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -4,12 +4,14 @@ import { ArrowDown } from 'react-feather'
|
||||
import ReactGA from 'react-ga'
|
||||
import { Text } from 'rebass'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import AddressInputPanel from '../../components/AddressInputPanel'
|
||||
import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button'
|
||||
import Card, { GreyCard } from '../../components/Card'
|
||||
import { AutoColumn } from '../../components/Column'
|
||||
import ConfirmationModal from '../../components/ConfirmationModal'
|
||||
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
|
||||
import { RowBetween } from '../../components/Row'
|
||||
import { SwapPoolTabs } from '../../components/NavigationTabs'
|
||||
import { AutoRow, RowBetween } from '../../components/Row'
|
||||
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
|
||||
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
|
||||
import { ArrowWrapper, BottomGrouping, Dots, Wrapper } from '../../components/swap/styleds'
|
||||
@ -20,6 +22,7 @@ import BetterTradeLink from '../../components/swap/BetterTradeLink'
|
||||
import { TokenWarningCards } from '../../components/TokenWarningCard'
|
||||
import { useActiveWeb3React } from '../../hooks'
|
||||
import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback'
|
||||
import useENSAddress from '../../hooks/useENSAddress'
|
||||
import { useSwapCallback } from '../../hooks/useSwapCallback'
|
||||
import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks'
|
||||
import { useExpertModeManager, useUserSlippageTolerance, useUserDeadline } from '../../state/user/hooks'
|
||||
@ -34,7 +37,7 @@ import {
|
||||
useSwapActionHandlers,
|
||||
useSwapState
|
||||
} from '../../state/swap/hooks'
|
||||
import { CursorPointer, TYPE } from '../../theme'
|
||||
import { CursorPointer, LinkStyledButton, TYPE } from '../../theme'
|
||||
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
|
||||
import AppBody from '../AppBody'
|
||||
import { ClickableText } from '../Pool/styleds'
|
||||
@ -57,10 +60,11 @@ export default function Swap() {
|
||||
const [allowedSlippage] = useUserSlippageTolerance()
|
||||
|
||||
// swap state
|
||||
const { independentField, typedValue } = useSwapState()
|
||||
const { independentField, typedValue, recipient } = useSwapState()
|
||||
const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, v1Trade } = useDerivedSwapInfo()
|
||||
const { address: recipientAddress } = useENSAddress(recipient)
|
||||
const toggledVersion = useToggledVersion()
|
||||
const bestTrade = {
|
||||
const trade = {
|
||||
[Version.v1]: v1Trade,
|
||||
[Version.v2]: bestTradeV2
|
||||
}[toggledVersion]
|
||||
@ -73,11 +77,11 @@ export default function Swap() {
|
||||
: undefined
|
||||
|
||||
const parsedAmounts = {
|
||||
[Field.INPUT]: independentField === Field.INPUT ? parsedAmount : bestTrade?.inputAmount,
|
||||
[Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : bestTrade?.outputAmount
|
||||
[Field.INPUT]: independentField === Field.INPUT ? parsedAmount : trade?.inputAmount,
|
||||
[Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : trade?.outputAmount
|
||||
}
|
||||
|
||||
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers()
|
||||
const { onSwitchTokens, onTokenSelection, onUserInput, onChangeRecipient } = useSwapActionHandlers()
|
||||
const isValid = !error
|
||||
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
|
||||
|
||||
@ -91,7 +95,7 @@ export default function Swap() {
|
||||
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
|
||||
}
|
||||
|
||||
const route = bestTrade?.route
|
||||
const route = trade?.route
|
||||
const userHasSpecifiedInputOutput =
|
||||
!!tokens[Field.INPUT] &&
|
||||
!!tokens[Field.OUTPUT] &&
|
||||
@ -100,7 +104,7 @@ export default function Swap() {
|
||||
const noRoute = !route
|
||||
|
||||
// check whether the user has approved the router on the input token
|
||||
const [approval, approveCallback] = useApproveCallbackFromTrade(bestTrade, allowedSlippage)
|
||||
const [approval, approveCallback] = useApproveCallbackFromTrade(trade, allowedSlippage)
|
||||
|
||||
// check if user has gone through approval process, used to show two step buttons, reset on token change
|
||||
const [approvalSubmitted, setApprovalSubmitted] = useState<boolean>(false)
|
||||
@ -126,12 +130,12 @@ export default function Swap() {
|
||||
const atMaxAmountInput: boolean =
|
||||
maxAmountInput && parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined
|
||||
|
||||
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(bestTrade, allowedSlippage)
|
||||
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, allowedSlippage)
|
||||
|
||||
// the callback to execute the swap
|
||||
const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline)
|
||||
const swapCallback = useSwapCallback(trade, allowedSlippage, deadline, recipient)
|
||||
|
||||
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(bestTrade)
|
||||
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade)
|
||||
|
||||
function onSwap() {
|
||||
if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) {
|
||||
@ -145,12 +149,13 @@ export default function Swap() {
|
||||
|
||||
ReactGA.event({
|
||||
category: 'Swap',
|
||||
action: 'Swap w/o Send',
|
||||
label: [
|
||||
bestTrade.inputAmount.token.symbol,
|
||||
bestTrade.outputAmount.token.symbol,
|
||||
getTradeVersion(bestTrade)
|
||||
].join('/')
|
||||
action:
|
||||
recipient === null
|
||||
? 'Swap w/o Send'
|
||||
: (recipientAddress ?? recipient) === account
|
||||
? 'Swap w/o Send + recipient'
|
||||
: 'Swap w/ Send',
|
||||
label: [trade.inputAmount.token.symbol, trade.outputAmount.token.symbol, getTradeVersion(trade)].join('/')
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
@ -185,6 +190,7 @@ export default function Swap() {
|
||||
slippageAdjustedAmounts={slippageAdjustedAmounts}
|
||||
priceImpactSeverity={priceImpactSeverity}
|
||||
independentField={independentField}
|
||||
recipient={recipient}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -201,7 +207,7 @@ export default function Swap() {
|
||||
parsedAmounts={parsedAmounts}
|
||||
priceImpactWithoutFee={priceImpactWithoutFee}
|
||||
slippageAdjustedAmounts={slippageAdjustedAmounts}
|
||||
trade={bestTrade}
|
||||
trade={trade}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -215,6 +221,7 @@ export default function Swap() {
|
||||
<>
|
||||
<TokenWarningCards tokens={tokens} />
|
||||
<AppBody>
|
||||
<SwapPoolTabs active={'swap'} />
|
||||
<Wrapper id="swap-page">
|
||||
<ConfirmationModal
|
||||
isOpen={showConfirm}
|
||||
@ -235,7 +242,6 @@ export default function Swap() {
|
||||
/>
|
||||
|
||||
<AutoColumn gap={'md'}>
|
||||
<>
|
||||
<CurrencyInputPanel
|
||||
field={Field.INPUT}
|
||||
label={independentField === Field.OUTPUT ? 'From (estimated)' : 'From'}
|
||||
@ -255,8 +261,9 @@ export default function Swap() {
|
||||
/>
|
||||
|
||||
<CursorPointer>
|
||||
<AutoColumn style={{ padding: '0 1rem' }}>
|
||||
<ArrowWrapper>
|
||||
<AutoColumn justify="space-between">
|
||||
<AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
|
||||
<ArrowWrapper clickable>
|
||||
<ArrowDown
|
||||
size="16"
|
||||
onClick={() => {
|
||||
@ -266,6 +273,12 @@ export default function Swap() {
|
||||
color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2}
|
||||
/>
|
||||
</ArrowWrapper>
|
||||
{recipient === null ? (
|
||||
<LinkStyledButton id="add-recipient-button" onClick={() => onChangeRecipient('')}>
|
||||
+ add recipient
|
||||
</LinkStyledButton>
|
||||
) : null}
|
||||
</AutoRow>
|
||||
</AutoColumn>
|
||||
</CursorPointer>
|
||||
<CurrencyInputPanel
|
||||
@ -279,7 +292,20 @@ export default function Swap() {
|
||||
otherSelectedTokenAddress={tokens[Field.INPUT]?.address}
|
||||
id="swap-currency-output"
|
||||
/>
|
||||
|
||||
{recipient !== null ? (
|
||||
<>
|
||||
<AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
|
||||
<ArrowWrapper clickable={false}>
|
||||
<ArrowDown size="16" color={theme.text2} />
|
||||
</ArrowWrapper>
|
||||
<LinkStyledButton id="remove-recipient-button" onClick={() => onChangeRecipient(null)}>
|
||||
- remove recipient
|
||||
</LinkStyledButton>
|
||||
</AutoRow>
|
||||
<AddressInputPanel id="recipient" value={recipient} onChange={onChangeRecipient} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Card padding={'.25rem .75rem 0 .75rem'} borderRadius={'20px'}>
|
||||
<AutoColumn gap="4px">
|
||||
@ -290,7 +316,7 @@ export default function Swap() {
|
||||
<TradePrice
|
||||
inputToken={tokens[Field.INPUT]}
|
||||
outputToken={tokens[Field.OUTPUT]}
|
||||
price={bestTrade?.executionPrice}
|
||||
price={trade?.executionPrice}
|
||||
showInverted={showInverted}
|
||||
setShowInverted={setShowInverted}
|
||||
/>
|
||||
@ -371,7 +397,7 @@ export default function Swap() {
|
||||
</Wrapper>
|
||||
</AppBody>
|
||||
|
||||
<AdvancedSwapDetailsDropdown trade={bestTrade} />
|
||||
<AdvancedSwapDetailsDropdown trade={trade} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -13,4 +13,6 @@ export const replaceSwapState = createAction<{
|
||||
typedValue: string
|
||||
inputTokenAddress?: string
|
||||
outputTokenAddress?: string
|
||||
recipient: string | null
|
||||
}>('replaceSwapState')
|
||||
export const setRecipient = createAction<{ recipient: string | null }>('setRecipient')
|
||||
|
@ -18,7 +18,8 @@ describe('hooks', () => {
|
||||
[Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
|
||||
[Field.INPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
typedValue: '20.5',
|
||||
independentField: Field.OUTPUT
|
||||
independentField: Field.OUTPUT,
|
||||
recipient: null
|
||||
})
|
||||
})
|
||||
|
||||
@ -32,7 +33,8 @@ describe('hooks', () => {
|
||||
[Field.INPUT]: { address: '' },
|
||||
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
typedValue: '',
|
||||
independentField: Field.INPUT
|
||||
independentField: Field.INPUT,
|
||||
recipient: null
|
||||
})
|
||||
})
|
||||
|
||||
@ -46,7 +48,58 @@ describe('hooks', () => {
|
||||
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
[Field.INPUT]: { address: '' },
|
||||
typedValue: '20.5',
|
||||
independentField: Field.INPUT
|
||||
independentField: Field.INPUT,
|
||||
recipient: null
|
||||
})
|
||||
})
|
||||
|
||||
test('invalid recipient', () => {
|
||||
expect(
|
||||
queryParametersToSwapState(
|
||||
parse('?outputCurrency=eth&exactAmount=20.5&recipient=abc', { parseArrays: false, ignoreQueryPrefix: true }),
|
||||
ChainId.MAINNET
|
||||
)
|
||||
).toEqual({
|
||||
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
[Field.INPUT]: { address: '' },
|
||||
typedValue: '20.5',
|
||||
independentField: Field.INPUT,
|
||||
recipient: null
|
||||
})
|
||||
})
|
||||
|
||||
test('valid recipient', () => {
|
||||
expect(
|
||||
queryParametersToSwapState(
|
||||
parse('?outputCurrency=eth&exactAmount=20.5&recipient=0x0fF2D1eFd7A57B7562b2bf27F3f37899dB27F4a5', {
|
||||
parseArrays: false,
|
||||
ignoreQueryPrefix: true
|
||||
}),
|
||||
ChainId.MAINNET
|
||||
)
|
||||
).toEqual({
|
||||
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
[Field.INPUT]: { address: '' },
|
||||
typedValue: '20.5',
|
||||
independentField: Field.INPUT,
|
||||
recipient: '0x0fF2D1eFd7A57B7562b2bf27F3f37899dB27F4a5'
|
||||
})
|
||||
})
|
||||
test('accepts any recipient', () => {
|
||||
expect(
|
||||
queryParametersToSwapState(
|
||||
parse('?outputCurrency=eth&exactAmount=20.5&recipient=bob.argent.xyz', {
|
||||
parseArrays: false,
|
||||
ignoreQueryPrefix: true
|
||||
}),
|
||||
ChainId.MAINNET
|
||||
)
|
||||
).toEqual({
|
||||
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
|
||||
[Field.INPUT]: { address: '' },
|
||||
typedValue: '20.5',
|
||||
independentField: Field.INPUT,
|
||||
recipient: 'bob.argent.xyz'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Version } from './../../hooks/useToggledVersion'
|
||||
import useENS from '../../hooks/useENS'
|
||||
import { Version } from '../../hooks/useToggledVersion'
|
||||
import { parseUnits } from '@ethersproject/units'
|
||||
import { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk'
|
||||
import { ParsedQs } from 'qs'
|
||||
@ -12,7 +13,7 @@ import useParsedQueryString from '../../hooks/useParsedQueryString'
|
||||
import { isAddress } from '../../utils'
|
||||
import { AppDispatch, AppState } from '../index'
|
||||
import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks'
|
||||
import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions'
|
||||
import { Field, replaceSwapState, selectToken, setRecipient, switchTokens, typeInput } from './actions'
|
||||
import { SwapState } from './reducer'
|
||||
import useToggledVersion from '../../hooks/useToggledVersion'
|
||||
import { useUserSlippageTolerance } from '../user/hooks'
|
||||
@ -26,6 +27,7 @@ export function useSwapActionHandlers(): {
|
||||
onTokenSelection: (field: Field, address: string) => void
|
||||
onSwitchTokens: () => void
|
||||
onUserInput: (field: Field, typedValue: string) => void
|
||||
onChangeRecipient: (recipient: string | null) => void
|
||||
} {
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const onTokenSelection = useCallback(
|
||||
@ -51,10 +53,18 @@ export function useSwapActionHandlers(): {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const onChangeRecipient = useCallback(
|
||||
(recipient: string | null) => {
|
||||
dispatch(setRecipient({ recipient }))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
onSwitchTokens,
|
||||
onTokenSelection,
|
||||
onUserInput
|
||||
onUserInput,
|
||||
onChangeRecipient
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,11 +103,14 @@ export function useDerivedSwapInfo(): {
|
||||
independentField,
|
||||
typedValue,
|
||||
[Field.INPUT]: { address: tokenInAddress },
|
||||
[Field.OUTPUT]: { address: tokenOutAddress }
|
||||
[Field.OUTPUT]: { address: tokenOutAddress },
|
||||
recipient
|
||||
} = useSwapState()
|
||||
|
||||
const tokenIn = useToken(tokenInAddress)
|
||||
const tokenOut = useToken(tokenOutAddress)
|
||||
const recipientLookup = useENS(recipient ?? undefined)
|
||||
const to: string | null = (recipient === null ? account : recipientLookup.address) ?? null
|
||||
|
||||
const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [
|
||||
tokenIn ?? undefined,
|
||||
@ -138,6 +151,10 @@ export function useDerivedSwapInfo(): {
|
||||
error = error ?? 'Select a token'
|
||||
}
|
||||
|
||||
if (!to) {
|
||||
error = error ?? 'Enter a recipient'
|
||||
}
|
||||
|
||||
const [allowedSlippage] = useUserSlippageTolerance()
|
||||
|
||||
const slippageAdjustedAmounts =
|
||||
@ -172,14 +189,14 @@ export function useDerivedSwapInfo(): {
|
||||
}
|
||||
}
|
||||
|
||||
function parseCurrencyFromURLParameter(urlParam: any, chainId: number, overrideWETH: boolean): string {
|
||||
function parseCurrencyFromURLParameter(urlParam: any, chainId: number): string {
|
||||
if (typeof urlParam === 'string') {
|
||||
const valid = isAddress(urlParam)
|
||||
if (valid) return valid
|
||||
if (urlParam.toLowerCase() === 'eth') return WETH[chainId as ChainId]?.address ?? ''
|
||||
if (valid === false) return WETH[chainId as ChainId]?.address ?? ''
|
||||
}
|
||||
return overrideWETH ? '' : WETH[chainId as ChainId]?.address ?? ''
|
||||
return WETH[chainId as ChainId]?.address ?? ''
|
||||
}
|
||||
|
||||
function parseTokenAmountURLParameter(urlParam: any): string {
|
||||
@ -190,9 +207,20 @@ function parseIndependentFieldURLParameter(urlParam: any): Field {
|
||||
return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT
|
||||
}
|
||||
|
||||
export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId, overrideETH: boolean): SwapState {
|
||||
let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId, overrideETH)
|
||||
let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId, overrideETH)
|
||||
const ENS_NAME_REGEX = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)?$/
|
||||
const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/
|
||||
function validatedRecipient(recipient: any): string | null {
|
||||
if (typeof recipient !== 'string') return null
|
||||
const address = isAddress(recipient)
|
||||
if (address) return address
|
||||
if (ENS_NAME_REGEX.test(recipient)) return recipient
|
||||
if (ADDRESS_REGEX.test(recipient)) return recipient
|
||||
return null
|
||||
}
|
||||
|
||||
export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId): SwapState {
|
||||
let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId)
|
||||
let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId)
|
||||
if (inputCurrency === outputCurrency) {
|
||||
if (typeof parsedQs.outputCurrency === 'string') {
|
||||
inputCurrency = ''
|
||||
@ -201,6 +229,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId,
|
||||
}
|
||||
}
|
||||
|
||||
const recipient = validatedRecipient(parsedQs.recipient)
|
||||
|
||||
return {
|
||||
[Field.INPUT]: {
|
||||
address: inputCurrency
|
||||
@ -209,29 +239,29 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId,
|
||||
address: outputCurrency
|
||||
},
|
||||
typedValue: parseTokenAmountURLParameter(parsedQs.exactAmount),
|
||||
independentField: parseIndependentFieldURLParameter(parsedQs.exactField)
|
||||
independentField: parseIndependentFieldURLParameter(parsedQs.exactField),
|
||||
recipient
|
||||
}
|
||||
}
|
||||
|
||||
// updates the swap state to use the defaults for a given network
|
||||
// set overrideETH to true if dont want to autopopulate ETH
|
||||
export function useDefaultsFromURLSearch(overrideWETH = false) {
|
||||
export function useDefaultsFromURLSearch() {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const parsedQs = useParsedQueryString()
|
||||
|
||||
useEffect(() => {
|
||||
if (!chainId) return
|
||||
const parsed = queryParametersToSwapState(parsedQs, chainId, overrideWETH)
|
||||
const parsed = queryParametersToSwapState(parsedQs, chainId)
|
||||
|
||||
dispatch(
|
||||
replaceSwapState({
|
||||
typedValue: parsed.typedValue,
|
||||
field: parsed.independentField,
|
||||
inputTokenAddress: parsed[Field.INPUT].address,
|
||||
outputTokenAddress: parsed[Field.OUTPUT].address
|
||||
outputTokenAddress: parsed[Field.OUTPUT].address,
|
||||
recipient: parsed.recipient
|
||||
})
|
||||
)
|
||||
// eslint-disable-next-line
|
||||
}, [dispatch, chainId])
|
||||
}, [dispatch, chainId, parsedQs])
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createReducer } from '@reduxjs/toolkit'
|
||||
import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions'
|
||||
import { Field, replaceSwapState, selectToken, setRecipient, switchTokens, typeInput } from './actions'
|
||||
|
||||
export interface SwapState {
|
||||
readonly independentField: Field
|
||||
@ -10,6 +10,8 @@ export interface SwapState {
|
||||
readonly [Field.OUTPUT]: {
|
||||
readonly address: string | undefined
|
||||
}
|
||||
// the typed recipient address, or null if swap should go to sender
|
||||
readonly recipient: string | null
|
||||
}
|
||||
|
||||
const initialState: SwapState = {
|
||||
@ -20,12 +22,15 @@ const initialState: SwapState = {
|
||||
},
|
||||
[Field.OUTPUT]: {
|
||||
address: ''
|
||||
}
|
||||
},
|
||||
recipient: null
|
||||
}
|
||||
|
||||
export default createReducer<SwapState>(initialState, builder =>
|
||||
builder
|
||||
.addCase(replaceSwapState, (state, { payload: { typedValue, field, inputTokenAddress, outputTokenAddress } }) => {
|
||||
.addCase(
|
||||
replaceSwapState,
|
||||
(state, { payload: { typedValue, recipient, field, inputTokenAddress, outputTokenAddress } }) => {
|
||||
return {
|
||||
[Field.INPUT]: {
|
||||
address: inputTokenAddress
|
||||
@ -34,9 +39,11 @@ export default createReducer<SwapState>(initialState, builder =>
|
||||
address: outputTokenAddress
|
||||
},
|
||||
independentField: field,
|
||||
typedValue: typedValue
|
||||
typedValue: typedValue,
|
||||
recipient
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.addCase(selectToken, (state, { payload: { address, field } }) => {
|
||||
const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT
|
||||
if (address === state[otherField].address) {
|
||||
@ -70,4 +77,7 @@ export default createReducer<SwapState>(initialState, builder =>
|
||||
typedValue
|
||||
}
|
||||
})
|
||||
.addCase(setRecipient, (state, { payload: { recipient } }) => {
|
||||
state.recipient = recipient
|
||||
})
|
||||
)
|
||||
|
@ -39,7 +39,7 @@ export function useAllTransactions(): { [txHash: string]: TransactionDetails } {
|
||||
|
||||
const state = useSelector<AppState, TransactionState>(state => state.transactions)
|
||||
|
||||
return state[chainId ?? -1] ?? {}
|
||||
return chainId ? state[chainId] ?? {} : {}
|
||||
}
|
||||
|
||||
export function useIsTransactionPending(transactionHash?: string): boolean {
|
||||
|
@ -188,6 +188,10 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
font-variant: none;
|
||||
|
Loading…
Reference in New Issue
Block a user