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:
Zach Pomerantz 2022-03-23 14:12:58 -04:00 committed by GitHub
parent 334e137fb3
commit a76ece6ce3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 111 additions and 64 deletions

@ -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
}