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
This commit is contained in:
parent
334e137fb3
commit
a76ece6ce3
@ -5,10 +5,20 @@ import { ReactNode, useMemo } from 'react'
|
||||
import Button from './Button'
|
||||
import Row from './Row'
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
animation: ${fadeIn} 0.25s ease-in;
|
||||
border-radius: ${({ theme }) => theme.borderRadius}em;
|
||||
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 {
|
||||
margin: -1px;
|
||||
@ -35,6 +45,8 @@ const actionCss = css`
|
||||
|
||||
${ActionRow} {
|
||||
animation: ${grow} 0.25s ease-in;
|
||||
flex-grow: 1;
|
||||
justify-content: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -58,7 +70,7 @@ export interface Action {
|
||||
message: ReactNode
|
||||
icon?: Icon
|
||||
onClick?: () => void
|
||||
children: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export interface BaseProps {
|
||||
@ -72,11 +84,13 @@ export default function ActionButton({ color = 'accent', disabled, action, onCli
|
||||
const textColor = useMemo(() => (color === 'accent' && !disabled ? 'onAccent' : 'currentColor'), [color, disabled])
|
||||
return (
|
||||
<Overlay hasAction={Boolean(action)} flex align="stretch">
|
||||
<StyledButton color={color} disabled={disabled} onClick={action ? action.onClick : onClick}>
|
||||
<ThemedText.TransitionButton buttonSize={action ? 'medium' : 'large'} color={textColor}>
|
||||
{action ? action.children : children}
|
||||
</ThemedText.TransitionButton>
|
||||
</StyledButton>
|
||||
{(action ? action.onClick : true) && (
|
||||
<StyledButton color={color} disabled={disabled} onClick={action?.onClick || onClick}>
|
||||
<ThemedText.TransitionButton buttonSize={action ? 'medium' : 'large'} color={textColor}>
|
||||
{action?.children || children}
|
||||
</ThemedText.TransitionButton>
|
||||
</StyledButton>
|
||||
)}
|
||||
{action && (
|
||||
<ActionRow gap={0.5}>
|
||||
<LargeIcon color="currentColor" icon={action.icon || AlertTriangle} />
|
||||
|
@ -41,7 +41,7 @@ function Fixture() {
|
||||
return trade ? (
|
||||
<Modal color="dialog">
|
||||
<SummaryDialog
|
||||
onConfirm={() => void 0}
|
||||
onConfirm={async () => void 0}
|
||||
trade={trade}
|
||||
slippage={slippage}
|
||||
inputUSDC={inputUSDC}
|
||||
|
@ -9,7 +9,7 @@ import Expando from 'lib/components/Expando'
|
||||
import Row from 'lib/components/Row'
|
||||
import { Slippage } from 'lib/hooks/useSlippage'
|
||||
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 { useCallback, useMemo, useState } from 'react'
|
||||
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
|
||||
@ -94,16 +94,27 @@ function ConfirmButton({
|
||||
}: {
|
||||
trade: Trade<Currency, Currency, TradeType>
|
||||
highPriceImpact: boolean
|
||||
onConfirm: () => void
|
||||
onConfirm: () => Promise<void>
|
||||
}) {
|
||||
const [ackPriceImpact, setAckPriceImpact] = useState(false)
|
||||
|
||||
const [ackTrade, setAckTrade] = useState(trade)
|
||||
const doesTradeDiffer = useMemo(
|
||||
() => Boolean(trade && ackTrade && tradeMeaningfullyDiffers(trade, ackTrade)),
|
||||
[ackTrade, trade]
|
||||
)
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const onClick = useCallback(async () => {
|
||||
setIsPending(true)
|
||||
await onConfirm()
|
||||
setIsPending(false)
|
||||
}, [onConfirm])
|
||||
|
||||
const action = useMemo((): Action | undefined => {
|
||||
if (doesTradeDiffer) {
|
||||
if (isPending) {
|
||||
return { message: <Trans>Confirm in your wallet</Trans>, icon: Spinner }
|
||||
} else if (doesTradeDiffer) {
|
||||
return {
|
||||
message: <Trans>Price updated</Trans>,
|
||||
icon: BarChart,
|
||||
@ -118,10 +129,10 @@ function ConfirmButton({
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [ackPriceImpact, doesTradeDiffer, highPriceImpact, trade])
|
||||
}, [ackPriceImpact, doesTradeDiffer, highPriceImpact, isPending, trade])
|
||||
|
||||
return (
|
||||
<ActionButton onClick={onConfirm} action={action}>
|
||||
<ActionButton onClick={onClick} action={action}>
|
||||
<Trans>Confirm swap</Trans>
|
||||
</ActionButton>
|
||||
)
|
||||
@ -133,7 +144,7 @@ interface SummaryDialogProps {
|
||||
inputUSDC?: CurrencyAmount<Currency>
|
||||
outputUSDC?: CurrencyAmount<Currency>
|
||||
impact?: PriceImpact
|
||||
onConfirm: () => void
|
||||
onConfirm: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SummaryDialog({ trade, slippage, inputUSDC, outputUSDC, impact, onConfirm }: SummaryDialogProps) {
|
||||
|
@ -8,9 +8,11 @@ import { useAddTransaction } from 'lib/hooks/transactions'
|
||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
||||
import { useSetOldestValidBlock } from 'lib/hooks/useIsValidBlock'
|
||||
import useTransactionDeadline from 'lib/hooks/useTransactionDeadline'
|
||||
import { Spinner } from 'lib/icons'
|
||||
import { displayTxHashAtom, feeOptionsAtom, Field } from 'lib/state/swap'
|
||||
import { TransactionType } from 'lib/state/transactions'
|
||||
import { useTheme } from 'lib/theme'
|
||||
import { isAnimating } from 'lib/utils/animations'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
@ -46,7 +48,7 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
|
||||
const deadline = useTransactionDeadline()
|
||||
|
||||
const { type: wrapType, callback: wrapCallback } = useWrapCallback()
|
||||
const { approvalData, signatureData } = useApprovalData(optimizedTrade, slippage, inputCurrencyAmount)
|
||||
const { approvalAction, signatureData } = useApprovalData(optimizedTrade, slippage, inputCurrencyAmount)
|
||||
const { callback: swapCallback } = useSwapCallback({
|
||||
trade: optimizedTrade,
|
||||
allowedSlippage: slippage.allowed,
|
||||
@ -66,7 +68,9 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
|
||||
const setDisplayTxHash = useUpdateAtom(displayTxHashAtom)
|
||||
const setOldestValidBlock = useSetOldestValidBlock()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const onWrap = useCallback(async () => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
const transaction = await wrapCallback?.()
|
||||
if (!transaction) return
|
||||
@ -82,7 +86,22 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
|
||||
// TODO(zzmp): Surface errors from wrap.
|
||||
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])
|
||||
// Reset the pending state if user updates the swap.
|
||||
useEffect(() => setIsPending(false), [inputCurrencyAmount, trade])
|
||||
|
||||
const onSwap = useCallback(async () => {
|
||||
try {
|
||||
const transaction = await swapCallback?.()
|
||||
@ -96,13 +115,24 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
|
||||
outputCurrencyAmount: trade.trade.outputAmount,
|
||||
})
|
||||
setDisplayTxHash(transaction.hash)
|
||||
setOpen(false)
|
||||
|
||||
// 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.
|
||||
transaction.wait(1).then((receipt) => {
|
||||
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) {
|
||||
// TODO(zzmp): Surface errors from swap.
|
||||
console.log(e)
|
||||
@ -122,11 +152,13 @@ export default memo(function SwapButton({ disabled }: SwapButtonProps) {
|
||||
if (disableSwap) {
|
||||
return { disabled: true }
|
||||
} else if (wrapType === WrapType.NONE) {
|
||||
return approvalData || { onClick: () => setOpen(true) }
|
||||
return approvalAction ? { action: approvalAction } : { onClick: () => setOpen(true) }
|
||||
} 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(() => {
|
||||
switch (wrapType) {
|
||||
case WrapType.UNWRAP:
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
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 {
|
||||
ApproveOrPermitState,
|
||||
@ -12,7 +12,7 @@ import { useAddTransaction, usePendingApproval } from 'lib/hooks/transactions'
|
||||
import { Slippage } from 'lib/hooks/useSlippage'
|
||||
import { Spinner } from 'lib/icons'
|
||||
import { TransactionType } from 'lib/state/transactions'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ExplorerDataType } from 'utils/getExplorerLink'
|
||||
|
||||
export function useIsPendingApproval(token?: Token, spender?: string): boolean {
|
||||
@ -32,61 +32,57 @@ export default function useApprovalData(
|
||||
currencyAmount
|
||||
)
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const addTransaction = useAddTransaction()
|
||||
const onApprove = useCallback(async () => {
|
||||
setIsPending(true)
|
||||
const transaction = await handleApproveOrPermit()
|
||||
if (transaction) {
|
||||
addTransaction({ type: TransactionType.APPROVAL, ...transaction })
|
||||
}
|
||||
setIsPending(false)
|
||||
}, [addTransaction, handleApproveOrPermit])
|
||||
// Reset the pending state if currency changes.
|
||||
useEffect(() => setIsPending(false), [currency])
|
||||
|
||||
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 (approvalState === ApproveOrPermitState.REQUIRES_APPROVAL) {
|
||||
return {
|
||||
action: {
|
||||
switch (approvalState) {
|
||||
case ApproveOrPermitState.REQUIRES_APPROVAL:
|
||||
if (isPending) {
|
||||
return { message: <Trans>Approve in your wallet</Trans>, icon: Spinner }
|
||||
}
|
||||
return {
|
||||
message: <Trans>Approve {currency.symbol} first</Trans>,
|
||||
onClick: onApprove,
|
||||
children: <Trans>Approve</Trans>,
|
||||
},
|
||||
}
|
||||
} else if (approvalState === ApproveOrPermitState.REQUIRES_SIGNATURE) {
|
||||
return {
|
||||
action: {
|
||||
}
|
||||
case ApproveOrPermitState.REQUIRES_SIGNATURE:
|
||||
if (isPending) {
|
||||
return { message: <Trans>Allow in your wallet</Trans>, icon: Spinner }
|
||||
}
|
||||
return {
|
||||
message: <Trans>Allow {currency.symbol} first</Trans>,
|
||||
onClick: onApprove,
|
||||
children: <Trans>Allow</Trans>,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (approvalState === ApproveOrPermitState.PENDING_APPROVAL) {
|
||||
return {
|
||||
disabled: true,
|
||||
action: {
|
||||
}
|
||||
case ApproveOrPermitState.PENDING_APPROVAL:
|
||||
return {
|
||||
message: (
|
||||
<EtherscanLink type={ExplorerDataType.TRANSACTION} data={approvalHash}>
|
||||
<Trans>Approval pending</Trans>
|
||||
</EtherscanLink>
|
||||
),
|
||||
icon: Spinner,
|
||||
children: <Trans>Approve</Trans>,
|
||||
},
|
||||
}
|
||||
}
|
||||
case ApproveOrPermitState.PENDING_SIGNATURE:
|
||||
return { message: <Trans>Allowance pending</Trans>, icon: Spinner }
|
||||
case ApproveOrPermitState.APPROVED:
|
||||
return
|
||||
}
|
||||
if (approvalState === ApproveOrPermitState.PENDING_SIGNATURE) {
|
||||
return {
|
||||
disabled: true,
|
||||
action: {
|
||||
message: <Trans>Allowance pending</Trans>,
|
||||
icon: Spinner,
|
||||
children: <Trans>Allow</Trans>,
|
||||
},
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [approvalHash, approvalState, currency, onApprove, trade])
|
||||
}, [approvalHash, approvalState, currency, isPending, 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
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import { useERC20PermitFromTrade, UseERC20PermitState } from 'hooks/useERC20Permit'
|
||||
import useTransactionDeadline from 'lib/hooks/useTransactionDeadline'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { getTxOptimizedSwapRouter, SwapRouterVersion } from 'utils/getTxOptimizedSwapRouter'
|
||||
|
||||
import { ApprovalState, useApproval, useApprovalStateForSpender } from '../useApproval'
|
||||
@ -166,12 +166,8 @@ export const useApproveOrPermit = (
|
||||
gatherPermitSignature,
|
||||
} = 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.
|
||||
const handleApproveOrPermit = useCallback(async () => {
|
||||
setIsPendingWallet(true)
|
||||
try {
|
||||
if (signatureState === UseERC20PermitState.NOT_SIGNED && gatherPermitSignature) {
|
||||
try {
|
||||
@ -187,8 +183,6 @@ export const useApproveOrPermit = (
|
||||
}
|
||||
} catch (e) {
|
||||
// Swallow approval errors - user rejections do not need to be displayed.
|
||||
} finally {
|
||||
setIsPendingWallet(false)
|
||||
}
|
||||
}, [signatureState, gatherPermitSignature, getApproval])
|
||||
|
||||
@ -200,11 +194,11 @@ export const useApproveOrPermit = (
|
||||
} else if (approval !== ApprovalState.NOT_APPROVED || signatureState === UseERC20PermitState.SIGNED) {
|
||||
return ApproveOrPermitState.APPROVED
|
||||
} else if (gatherPermitSignature) {
|
||||
return isPendingWallet ? ApproveOrPermitState.PENDING_SIGNATURE : ApproveOrPermitState.REQUIRES_SIGNATURE
|
||||
return ApproveOrPermitState.REQUIRES_SIGNATURE
|
||||
} else {
|
||||
return isPendingWallet ? ApproveOrPermitState.PENDING_APPROVAL : ApproveOrPermitState.REQUIRES_APPROVAL
|
||||
return ApproveOrPermitState.REQUIRES_APPROVAL
|
||||
}
|
||||
}, [approval, gatherPermitSignature, isPendingWallet, signatureState])
|
||||
}, [approval, gatherPermitSignature, signatureState])
|
||||
|
||||
return {
|
||||
approvalState,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { RefObject } from 'react'
|
||||
|
||||
export function isAnimating(node: HTMLElement) {
|
||||
export function isAnimating(node: Animatable | Document) {
|
||||
return (node.getAnimations().length ?? 0) > 0
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user