Commit a76ece6c authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: prompt for interaction "in your wallet" (#3585)

* feat: prompt approval in wallet

* feat: prompt wrap in wallet

* feat: prompt confirm in wallet

* fix: animations

* fix: test typing
parent 334e137f
...@@ -5,10 +5,20 @@ import { ReactNode, useMemo } from 'react' ...@@ -5,10 +5,20 @@ import { ReactNode, useMemo } from 'react'
import Button from './Button' import Button from './Button'
import Row from './Row' import Row from './Row'
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`
const StyledButton = styled(Button)` const StyledButton = styled(Button)`
animation: ${fadeIn} 0.25s ease-in;
border-radius: ${({ theme }) => theme.borderRadius}em; border-radius: ${({ theme }) => theme.borderRadius}em;
flex-grow: 1; flex-grow: 1;
transition: background-color 0.25s ease-out, flex-grow 0.25s ease-out, padding 0.25s ease-out; transition: background-color 0.25s ease-out, flex-grow 0.25s ease-out, padding 0.25s ease-in;
:disabled { :disabled {
margin: -1px; margin: -1px;
...@@ -35,6 +45,8 @@ const actionCss = css` ...@@ -35,6 +45,8 @@ const actionCss = css`
${ActionRow} { ${ActionRow} {
animation: ${grow} 0.25s ease-in; animation: ${grow} 0.25s ease-in;
flex-grow: 1;
justify-content: flex-start;
white-space: nowrap; white-space: nowrap;
} }
...@@ -58,7 +70,7 @@ export interface Action { ...@@ -58,7 +70,7 @@ export interface Action {
message: ReactNode message: ReactNode
icon?: Icon icon?: Icon
onClick?: () => void onClick?: () => void
children: ReactNode children?: ReactNode
} }
export interface BaseProps { export interface BaseProps {
...@@ -72,11 +84,13 @@ export default function ActionButton({ color = 'accent', disabled, action, onCli ...@@ -72,11 +84,13 @@ export default function ActionButton({ color = 'accent', disabled, action, onCli
const textColor = useMemo(() => (color === 'accent' && !disabled ? 'onAccent' : 'currentColor'), [color, disabled]) const textColor = useMemo(() => (color === 'accent' && !disabled ? 'onAccent' : 'currentColor'), [color, disabled])
return ( return (
<Overlay hasAction={Boolean(action)} flex align="stretch"> <Overlay hasAction={Boolean(action)} flex align="stretch">
<StyledButton color={color} disabled={disabled} onClick={action ? action.onClick : onClick}> {(action ? action.onClick : true) && (
<ThemedText.TransitionButton buttonSize={action ? 'medium' : 'large'} color={textColor}> <StyledButton color={color} disabled={disabled} onClick={action?.onClick || onClick}>
{action ? action.children : children} <ThemedText.TransitionButton buttonSize={action ? 'medium' : 'large'} color={textColor}>
</ThemedText.TransitionButton> {action?.children || children}
</StyledButton> </ThemedText.TransitionButton>
</StyledButton>
)}
{action && ( {action && (
<ActionRow gap={0.5}> <ActionRow gap={0.5}>
<LargeIcon color="currentColor" icon={action.icon || AlertTriangle} /> <LargeIcon color="currentColor" icon={action.icon || AlertTriangle} />
......
...@@ -41,7 +41,7 @@ function Fixture() { ...@@ -41,7 +41,7 @@ function Fixture() {
return trade ? ( return trade ? (
<Modal color="dialog"> <Modal color="dialog">
<SummaryDialog <SummaryDialog
onConfirm={() => void 0} onConfirm={async () => void 0}
trade={trade} trade={trade}
slippage={slippage} slippage={slippage}
inputUSDC={inputUSDC} inputUSDC={inputUSDC}
......
...@@ -9,7 +9,7 @@ import Expando from 'lib/components/Expando' ...@@ -9,7 +9,7 @@ import Expando from 'lib/components/Expando'
import Row from 'lib/components/Row' import Row from 'lib/components/Row'
import { Slippage } from 'lib/hooks/useSlippage' import { Slippage } from 'lib/hooks/useSlippage'
import { PriceImpact } from 'lib/hooks/useUSDCPriceImpact' import { PriceImpact } from 'lib/hooks/useUSDCPriceImpact'
import { AlertTriangle, BarChart, Info } from 'lib/icons' import { AlertTriangle, BarChart, Info, Spinner } from 'lib/icons'
import styled, { ThemedText } from 'lib/theme' import styled, { ThemedText } from 'lib/theme'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
...@@ -94,16 +94,27 @@ function ConfirmButton({ ...@@ -94,16 +94,27 @@ function ConfirmButton({
}: { }: {
trade: Trade<Currency, Currency, TradeType> trade: Trade<Currency, Currency, TradeType>
highPriceImpact: boolean highPriceImpact: boolean
onConfirm: () => void onConfirm: () => Promise<void>
}) { }) {
const [ackPriceImpact, setAckPriceImpact] = useState(false) const [ackPriceImpact, setAckPriceImpact] = useState(false)
const [ackTrade, setAckTrade] = useState(trade) const [ackTrade, setAckTrade] = useState(trade)
const doesTradeDiffer = useMemo( const doesTradeDiffer = useMemo(
() => Boolean(trade && ackTrade && tradeMeaningfullyDiffers(trade, ackTrade)), () => Boolean(trade && ackTrade && tradeMeaningfullyDiffers(trade, ackTrade)),
[ackTrade, trade] [ackTrade, trade]
) )
const [isPending, setIsPending] = useState(false)
const onClick = useCallback(async () => {
setIsPending(true)
await onConfirm()
setIsPending(false)
}, [onConfirm])
const action = useMemo((): Action | undefined => { const action = useMemo((): Action | undefined => {
if (doesTradeDiffer) { if (isPending) {
return { message: <Trans>Confirm in your wallet</Trans>, icon: Spinner }
} else if (doesTradeDiffer) {
return { return {
message: <Trans>Price updated</Trans>, message: <Trans>Price updated</Trans>,
icon: BarChart, icon: BarChart,
...@@ -118,10 +129,10 @@ function ConfirmButton({ ...@@ -118,10 +129,10 @@ function ConfirmButton({
} }
} }
return return
}, [ackPriceImpact, doesTradeDiffer, highPriceImpact, trade]) }, [ackPriceImpact, doesTradeDiffer, highPriceImpact, isPending, trade])
return ( return (
<ActionButton onClick={onConfirm} action={action}> <ActionButton onClick={onClick} action={action}>
<Trans>Confirm swap</Trans> <Trans>Confirm swap</Trans>
</ActionButton> </ActionButton>
) )
...@@ -133,7 +144,7 @@ interface SummaryDialogProps { ...@@ -133,7 +144,7 @@ interface SummaryDialogProps {
inputUSDC?: CurrencyAmount<Currency> inputUSDC?: CurrencyAmount<Currency>
outputUSDC?: CurrencyAmount<Currency> outputUSDC?: CurrencyAmount<Currency>
impact?: PriceImpact impact?: PriceImpact
onConfirm: () => void onConfirm: () => Promise<void>
} }
export function SummaryDialog({ trade, slippage, inputUSDC, outputUSDC, impact, onConfirm }: SummaryDialogProps) { export function SummaryDialog({ trade, slippage, inputUSDC, outputUSDC, impact, onConfirm }: SummaryDialogProps) {
......
...@@ -8,9 +8,11 @@ import { useAddTransaction } from 'lib/hooks/transactions' ...@@ -8,9 +8,11 @@ import { useAddTransaction } from 'lib/hooks/transactions'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import { useSetOldestValidBlock } from 'lib/hooks/useIsValidBlock' import { useSetOldestValidBlock } from 'lib/hooks/useIsValidBlock'
import useTransactionDeadline from 'lib/hooks/useTransactionDeadline' import useTransactionDeadline from 'lib/hooks/useTransactionDeadline'
import { Spinner } from 'lib/icons'
import { displayTxHashAtom, feeOptionsAtom, Field } from 'lib/state/swap' import { displayTxHashAtom, feeOptionsAtom, Field } from 'lib/state/swap'
import { TransactionType } from 'lib/state/transactions' import { TransactionType } from 'lib/state/transactions'
import { useTheme } from 'lib/theme' import { useTheme } from 'lib/theme'
import { isAnimating } from 'lib/utils/animations'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant'
...@@ -46,7 +48,7 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) { ...@@ -46,7 +48,7 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
const deadline = useTransactionDeadline() const deadline = useTransactionDeadline()
const { type: wrapType, callback: wrapCallback } = useWrapCallback() const { type: wrapType, callback: wrapCallback } = useWrapCallback()
const { approvalData, signatureData } = useApprovalData(optimizedTrade, slippage, inputCurrencyAmount) const { approvalAction, signatureData } = useApprovalData(optimizedTrade, slippage, inputCurrencyAmount)
const { callback: swapCallback } = useSwapCallback({ const { callback: swapCallback } = useSwapCallback({
trade: optimizedTrade, trade: optimizedTrade,
allowedSlippage: slippage.allowed, allowedSlippage: slippage.allowed,
...@@ -66,7 +68,9 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) { ...@@ -66,7 +68,9 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
const setDisplayTxHash = useUpdateAtom(displayTxHashAtom) const setDisplayTxHash = useUpdateAtom(displayTxHashAtom)
const setOldestValidBlock = useSetOldestValidBlock() const setOldestValidBlock = useSetOldestValidBlock()
const [isPending, setIsPending] = useState(false)
const onWrap = useCallback(async () => { const onWrap = useCallback(async () => {
setIsPending(true)
try { try {
const transaction = await wrapCallback?.() const transaction = await wrapCallback?.()
if (!transaction) return if (!transaction) return
...@@ -82,7 +86,22 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) { ...@@ -82,7 +86,22 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
// TODO(zzmp): Surface errors from wrap. // TODO(zzmp): Surface errors from wrap.
console.log(e) console.log(e)
} }
// Only reset pending after any queued animations to avoid layout thrashing, because a
// successful wrap will open the status dialog and immediately cover the button.
const postWrap = () => {
setIsPending(false)
document.removeEventListener('animationend', postWrap)
}
if (isAnimating(document)) {
document.addEventListener('animationend', postWrap)
} else {
postWrap()
}
}, [addTransaction, chainId, setDisplayTxHash, wrapCallback, wrapType]) }, [addTransaction, chainId, setDisplayTxHash, wrapCallback, wrapType])
// Reset the pending state if user updates the swap.
useEffect(() => setIsPending(false), [inputCurrencyAmount, trade])
const onSwap = useCallback(async () => { const onSwap = useCallback(async () => {
try { try {
const transaction = await swapCallback?.() const transaction = await swapCallback?.()
...@@ -96,13 +115,24 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) { ...@@ -96,13 +115,24 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
outputCurrencyAmount: trade.trade.outputAmount, outputCurrencyAmount: trade.trade.outputAmount,
}) })
setDisplayTxHash(transaction.hash) setDisplayTxHash(transaction.hash)
setOpen(false)
// Set the block containing the response to the oldest valid block to ensure that the // Set the block containing the response to the oldest valid block to ensure that the
// completed trade's impact is reflected in future fetched trades. // completed trade's impact is reflected in future fetched trades.
transaction.wait(1).then((receipt) => { transaction.wait(1).then((receipt) => {
setOldestValidBlock(receipt.blockNumber) setOldestValidBlock(receipt.blockNumber)
}) })
// Only reset open after any queued animations to avoid layout thrashing, because a
// successful swap will open the status dialog and immediately cover the summary dialog.
const postSwap = () => {
setOpen(false)
document.removeEventListener('animationend', postSwap)
}
if (isAnimating(document)) {
document.addEventListener('animationend', postSwap)
} else {
postSwap()
}
} catch (e) { } catch (e) {
// TODO(zzmp): Surface errors from swap. // TODO(zzmp): Surface errors from swap.
console.log(e) console.log(e)
...@@ -122,11 +152,13 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) { ...@@ -122,11 +152,13 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
if (disableSwap) { if (disableSwap) {
return { disabled: true } return { disabled: true }
} else if (wrapType === WrapType.NONE) { } else if (wrapType === WrapType.NONE) {
return approvalData || { onClick: () => setOpen(true) } return approvalAction ? { action: approvalAction } : { onClick: () => setOpen(true) }
} else { } else {
return { onClick: onWrap } return isPending
? { action: { message: <Trans>Confirm in your wallet</Trans>, icon: Spinner } }
: { onClick: onWrap }
} }
}, [approvalData, disableSwap, onWrap, wrapType]) }, [approvalAction, disableSwap, isPending, onWrap, wrapType])
const Label = useCallback(() => { const Label = useCallback(() => {
switch (wrapType) { switch (wrapType) {
case WrapType.UNWRAP: case WrapType.UNWRAP:
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { ActionButtonProps } from 'lib/components/ActionButton' import { Action } from 'lib/components/ActionButton'
import EtherscanLink from 'lib/components/EtherscanLink' import EtherscanLink from 'lib/components/EtherscanLink'
import { import {
ApproveOrPermitState, ApproveOrPermitState,
...@@ -12,7 +12,7 @@ import { useAddTransaction, usePendingApproval } from 'lib/hooks/transactions' ...@@ -12,7 +12,7 @@ import { useAddTransaction, usePendingApproval } from 'lib/hooks/transactions'
import { Slippage } from 'lib/hooks/useSlippage' import { Slippage } from 'lib/hooks/useSlippage'
import { Spinner } from 'lib/icons' import { Spinner } from 'lib/icons'
import { TransactionType } from 'lib/state/transactions' import { TransactionType } from 'lib/state/transactions'
import { useCallback, useMemo } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExplorerDataType } from 'utils/getExplorerLink' import { ExplorerDataType } from 'utils/getExplorerLink'
export function useIsPendingApproval(token?: Token, spender?: string): boolean { export function useIsPendingApproval(token?: Token, spender?: string): boolean {
...@@ -32,61 +32,57 @@ export default function useApprovalData( ...@@ -32,61 +32,57 @@ export default function useApprovalData(
currencyAmount currencyAmount
) )
const [isPending, setIsPending] = useState(false)
const addTransaction = useAddTransaction() const addTransaction = useAddTransaction()
const onApprove = useCallback(async () => { const onApprove = useCallback(async () => {
setIsPending(true)
const transaction = await handleApproveOrPermit() const transaction = await handleApproveOrPermit()
if (transaction) { if (transaction) {
addTransaction({ type: TransactionType.APPROVAL, ...transaction }) addTransaction({ type: TransactionType.APPROVAL, ...transaction })
} }
setIsPending(false)
}, [addTransaction, handleApproveOrPermit]) }, [addTransaction, handleApproveOrPermit])
// Reset the pending state if currency changes.
useEffect(() => setIsPending(false), [currency])
const approvalHash = usePendingApproval(currency?.isToken ? currency : undefined, useSwapRouterAddress(trade)) const approvalHash = usePendingApproval(currency?.isToken ? currency : undefined, useSwapRouterAddress(trade))
const approvalData = useMemo((): Partial<ActionButtonProps> | undefined => { const approvalAction = useMemo((): Action | undefined => {
if (!trade || !currency) return if (!trade || !currency) return
if (approvalState === ApproveOrPermitState.REQUIRES_APPROVAL) { switch (approvalState) {
return { case ApproveOrPermitState.REQUIRES_APPROVAL:
action: { if (isPending) {
return { message: <Trans>Approve in your wallet</Trans>, icon: Spinner }
}
return {
message: <Trans>Approve {currency.symbol} first</Trans>, message: <Trans>Approve {currency.symbol} first</Trans>,
onClick: onApprove, onClick: onApprove,
children: <Trans>Approve</Trans>, children: <Trans>Approve</Trans>,
}, }
} case ApproveOrPermitState.REQUIRES_SIGNATURE:
} else if (approvalState === ApproveOrPermitState.REQUIRES_SIGNATURE) { if (isPending) {
return { return { message: <Trans>Allow in your wallet</Trans>, icon: Spinner }
action: { }
return {
message: <Trans>Allow {currency.symbol} first</Trans>, message: <Trans>Allow {currency.symbol} first</Trans>,
onClick: onApprove, onClick: onApprove,
children: <Trans>Allow</Trans>, children: <Trans>Allow</Trans>,
}, }
} case ApproveOrPermitState.PENDING_APPROVAL:
} return {
if (approvalState === ApproveOrPermitState.PENDING_APPROVAL) {
return {
disabled: true,
action: {
message: ( message: (
<EtherscanLink type={ExplorerDataType.TRANSACTION} data={approvalHash}> <EtherscanLink type={ExplorerDataType.TRANSACTION} data={approvalHash}>
<Trans>Approval pending</Trans> <Trans>Approval pending</Trans>
</EtherscanLink> </EtherscanLink>
), ),
icon: Spinner, icon: Spinner,
children: <Trans>Approve</Trans>, }
}, case ApproveOrPermitState.PENDING_SIGNATURE:
} return { message: <Trans>Allowance pending</Trans>, icon: Spinner }
} case ApproveOrPermitState.APPROVED:
if (approvalState === ApproveOrPermitState.PENDING_SIGNATURE) { return
return {
disabled: true,
action: {
message: <Trans>Allowance pending</Trans>,
icon: Spinner,
children: <Trans>Allow</Trans>,
},
}
} }
return }, [approvalHash, approvalState, currency, isPending, onApprove, trade])
}, [approvalHash, approvalState, currency, onApprove, trade])
return { approvalData, signatureData: signatureData ?? undefined } return { approvalAction, signatureData: signatureData ?? undefined }
} }
...@@ -6,7 +6,7 @@ import { SWAP_ROUTER_ADDRESSES, V2_ROUTER_ADDRESS, V3_ROUTER_ADDRESS } from 'con ...@@ -6,7 +6,7 @@ import { SWAP_ROUTER_ADDRESSES, V2_ROUTER_ADDRESS, V3_ROUTER_ADDRESS } from 'con
import useActiveWeb3React from 'hooks/useActiveWeb3React' import useActiveWeb3React from 'hooks/useActiveWeb3React'
import { useERC20PermitFromTrade, UseERC20PermitState } from 'hooks/useERC20Permit' import { useERC20PermitFromTrade, UseERC20PermitState } from 'hooks/useERC20Permit'
import useTransactionDeadline from 'lib/hooks/useTransactionDeadline' import useTransactionDeadline from 'lib/hooks/useTransactionDeadline'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo } from 'react'
import { getTxOptimizedSwapRouter, SwapRouterVersion } from 'utils/getTxOptimizedSwapRouter' import { getTxOptimizedSwapRouter, SwapRouterVersion } from 'utils/getTxOptimizedSwapRouter'
import { ApprovalState, useApproval, useApprovalStateForSpender } from '../useApproval' import { ApprovalState, useApproval, useApprovalStateForSpender } from '../useApproval'
...@@ -166,12 +166,8 @@ export const useApproveOrPermit = ( ...@@ -166,12 +166,8 @@ export const useApproveOrPermit = (
gatherPermitSignature, gatherPermitSignature,
} = useERC20PermitFromTrade(trade, allowedSlippage, deadline) } = useERC20PermitFromTrade(trade, allowedSlippage, deadline)
// Track when the interaction is blocked on a wallet so a PENDING state can be returned.
const [isPendingWallet, setIsPendingWallet] = useState(false)
// If permit is supported, trigger a signature, if not create approval transaction. // If permit is supported, trigger a signature, if not create approval transaction.
const handleApproveOrPermit = useCallback(async () => { const handleApproveOrPermit = useCallback(async () => {
setIsPendingWallet(true)
try { try {
if (signatureState === UseERC20PermitState.NOT_SIGNED && gatherPermitSignature) { if (signatureState === UseERC20PermitState.NOT_SIGNED && gatherPermitSignature) {
try { try {
...@@ -187,8 +183,6 @@ export const useApproveOrPermit = ( ...@@ -187,8 +183,6 @@ export const useApproveOrPermit = (
} }
} catch (e) { } catch (e) {
// Swallow approval errors - user rejections do not need to be displayed. // Swallow approval errors - user rejections do not need to be displayed.
} finally {
setIsPendingWallet(false)
} }
}, [signatureState, gatherPermitSignature, getApproval]) }, [signatureState, gatherPermitSignature, getApproval])
...@@ -200,11 +194,11 @@ export const useApproveOrPermit = ( ...@@ -200,11 +194,11 @@ export const useApproveOrPermit = (
} else if (approval !== ApprovalState.NOT_APPROVED || signatureState === UseERC20PermitState.SIGNED) { } else if (approval !== ApprovalState.NOT_APPROVED || signatureState === UseERC20PermitState.SIGNED) {
return ApproveOrPermitState.APPROVED return ApproveOrPermitState.APPROVED
} else if (gatherPermitSignature) { } else if (gatherPermitSignature) {
return isPendingWallet ? ApproveOrPermitState.PENDING_SIGNATURE : ApproveOrPermitState.REQUIRES_SIGNATURE return ApproveOrPermitState.REQUIRES_SIGNATURE
} else { } else {
return isPendingWallet ? ApproveOrPermitState.PENDING_APPROVAL : ApproveOrPermitState.REQUIRES_APPROVAL return ApproveOrPermitState.REQUIRES_APPROVAL
} }
}, [approval, gatherPermitSignature, isPendingWallet, signatureState]) }, [approval, gatherPermitSignature, signatureState])
return { return {
approvalState, approvalState,
......
import { RefObject } from 'react' import { RefObject } from 'react'
export function isAnimating(node: HTMLElement) { export function isAnimating(node: Animatable | Document) {
return (node.getAnimations().length ?? 0) > 0 return (node.getAnimations().length ?? 0) > 0
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment