Commit d0a10fcf authored by cartcrom's avatar cartcrom Committed by GitHub

refactor: activation hook w/ global state (#6413)

* feat: moved tryActivation to global hook/state

* test: activation hook

* fix: merge conflicts

* fix: update file path for render utils in activate.test.ts

* fix: add await for connector deactivation

* fix: pr comment fixes

* fix: update tests

* refactor: use stronger activation state type

* refactor: use global state instead of props in ConnectionErrorView

* fix: re-add uri availability check

* fix: lint

* fix: nits

* fix: css regression

* fix: update test enum usage

* fix: use native disabled attribute

* test: add snapshot tests for different Option states

* fix: zach's PR comments

* test: update snapshots/unit tests

* style: pending boolean names

* fix: updated test import

* docs: added comment explaining analytics difference in wallet connection

* test: assert console.debug calls and fix act() issues

* test: drawer close

* test: import specific drawer fn instead of whole module
parent 7a1a476e
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent } from '@uniswap/analytics' import { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceElementName } from '@uniswap/analytics-events' import { InterfaceElementName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { WalletConnect } from '@web3-react/walletconnect' import { WalletConnect } from '@web3-react/walletconnect'
import Column, { AutoColumn } from 'components/Column' import Column, { AutoColumn } from 'components/Column'
import Modal from 'components/Modal' import Modal from 'components/Modal'
import { RowBetween } from 'components/Row' import { RowBetween } from 'components/Row'
import { uniwalletConnectConnection } from 'connection' import { uniwalletConnectConnection } from 'connection'
import { ActivationStatus, useActivationState } from 'connection/activate'
import { ConnectionType } from 'connection/types'
import { UniwalletConnect } from 'connection/WalletConnect' import { UniwalletConnect } from 'connection/WalletConnect'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useModalIsOpen, useToggleUniwalletModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { CloseIcon, ThemedText } from 'theme' import { CloseIcon, ThemedText } from 'theme'
...@@ -39,44 +38,37 @@ const Divider = styled.div` ...@@ -39,44 +38,37 @@ const Divider = styled.div`
` `
export default function UniwalletModal() { export default function UniwalletModal() {
const open = useModalIsOpen(ApplicationModal.UNIWALLET_CONNECT) const { activationState, cancelActivation } = useActivationState()
const toggle = useToggleUniwalletModal()
const [uri, setUri] = useState<string>() const [uri, setUri] = useState<string>()
// Displays the modal if a Uniswap Wallet Connection is pending & qrcode URI is available
const open =
activationState.status === ActivationStatus.PENDING &&
activationState.connection.type === ConnectionType.UNIWALLET &&
!!uri
useEffect(() => { useEffect(() => {
;(uniwalletConnectConnection.connector as WalletConnect).events.addListener( ;(uniwalletConnectConnection.connector as WalletConnect).events.addListener(
UniwalletConnect.UNI_URI_AVAILABLE, UniwalletConnect.UNI_URI_AVAILABLE,
(uri) => { (uri) => {
uri && setUri(uri) uri && setUri(uri)
toggle()
} }
) )
}, [toggle]) }, [])
const { account } = useWeb3React()
useEffect(() => { useEffect(() => {
if (open) { if (open) sendAnalyticsEvent('Uniswap wallet modal opened')
sendAnalyticsEvent('Uniswap wallet modal opened', { userConnected: !!account }) }, [open])
if (account) {
toggle()
}
}
}, [account, open, toggle])
const onClose = useCallback(() => {
uniwalletConnectConnection.connector.deactivate?.()
toggle()
}, [toggle])
const theme = useTheme() const theme = useTheme()
return ( return (
<Modal isOpen={open} onDismiss={onClose}> <Modal isOpen={open} onDismiss={cancelActivation}>
<UniwalletConnectWrapper> <UniwalletConnectWrapper>
<HeaderRow> <HeaderRow>
<ThemedText.SubHeader> <ThemedText.SubHeader>
<Trans>Scan with Uniswap Wallet</Trans> <Trans>Scan with Uniswap Wallet</Trans>
</ThemedText.SubHeader> </ThemedText.SubHeader>
<CloseIcon onClick={onClose} /> <CloseIcon onClick={cancelActivation} />
</HeaderRow> </HeaderRow>
<QRCodeWrapper> <QRCodeWrapper>
{uri && ( {uri && (
......
...@@ -27,6 +27,11 @@ export function useToggleAccountDrawer() { ...@@ -27,6 +27,11 @@ export function useToggleAccountDrawer() {
}, [updateAccountDrawerOpen]) }, [updateAccountDrawerOpen])
} }
export function useCloseAccountDrawer() {
const updateAccountDrawerOpen = useUpdateAtom(accountDrawerOpenAtom)
return useCallback(() => updateAccountDrawerOpen(false), [updateAccountDrawerOpen])
}
export function useAccountDrawer(): [boolean, () => void] { export function useAccountDrawer(): [boolean, () => void] {
const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom) const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom)
return [accountDrawerOpen, useToggleAccountDrawer()] return [accountDrawerOpen, useToggleAccountDrawer()]
......
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Unicon } from 'components/Unicon' import { Unicon } from 'components/Unicon'
import { Connection, ConnectionType } from 'connection' import { Connection, ConnectionType } from 'connection/types'
import useENSAvatar from 'hooks/useENSAvatar' import useENSAvatar from 'hooks/useENSAvatar'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { useIsDarkMode } from 'theme/components/ThemeToggle' import { useIsDarkMode } from 'theme/components/ThemeToggle'
......
import { t } from '@lingui/macro' import { t } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { MouseoverTooltip } from 'components/Tooltip' import { MouseoverTooltip } from 'components/Tooltip'
import { ConnectionType } from 'connection'
import { useGetConnection } from 'connection' import { useGetConnection } from 'connection'
import { ConnectionType } from 'connection/types'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId, UniWalletSupportedChains } from 'constants/chains' import { SupportedChainId, UniWalletSupportedChains } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useOnClickOutside } from 'hooks/useOnClickOutside'
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { useCloseAccountDrawer } from 'components/AccountDrawer'
import { ButtonEmpty, ButtonPrimary } from 'components/Button' import { ButtonEmpty, ButtonPrimary } from 'components/Button'
import { ActivationStatus, useActivationState } from 'connection/activate'
import { AlertTriangle } from 'react-feather' import { AlertTriangle } from 'react-feather'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { ThemedText } from 'theme' import { ThemedText } from 'theme'
...@@ -20,13 +22,15 @@ const AlertTriangleIcon = styled(AlertTriangle)` ...@@ -20,13 +22,15 @@ const AlertTriangleIcon = styled(AlertTriangle)`
color: ${({ theme }) => theme.accentCritical}; color: ${({ theme }) => theme.accentCritical};
` `
export default function ConnectionErrorView({ // TODO(cartcrom): move this to a top level modal, rather than inline in the drawer
retryActivation, export default function ConnectionErrorView() {
openOptions, const { activationState, tryActivation, cancelActivation } = useActivationState()
}: { const closeDrawer = useCloseAccountDrawer()
retryActivation: () => void
openOptions: () => void if (activationState.status !== ActivationStatus.ERROR) return null
}) {
const retry = () => tryActivation(activationState.connection, closeDrawer)
return ( return (
<Wrapper> <Wrapper>
<AlertTriangleIcon /> <AlertTriangleIcon />
...@@ -38,11 +42,11 @@ export default function ConnectionErrorView({ ...@@ -38,11 +42,11 @@ export default function ConnectionErrorView({
The connection attempt failed. Please click try again and follow the steps to connect in your wallet. The connection attempt failed. Please click try again and follow the steps to connect in your wallet.
</Trans> </Trans>
</ThemedText.BodyPrimary> </ThemedText.BodyPrimary>
<ButtonPrimary $borderRadius="16px" onClick={retryActivation}> <ButtonPrimary $borderRadius="16px" onClick={retry}>
<Trans>Try Again</Trans> <Trans>Try Again</Trans>
</ButtonPrimary> </ButtonPrimary>
<ButtonEmpty width="fit-content" padding="0" marginTop={20}> <ButtonEmpty width="fit-content" padding="0" marginTop={20}>
<ThemedText.Link onClick={openOptions} marginBottom={12}> <ThemedText.Link onClick={cancelActivation} marginBottom={12}>
<Trans>Back to wallet selection</Trans> <Trans>Back to wallet selection</Trans>
</ThemedText.Link> </ThemedText.Link>
</ButtonEmpty> </ButtonEmpty>
......
import { Connector } from '@web3-react/types'
import UNIWALLET_ICON from 'assets/images/uniwallet.png'
import { useCloseAccountDrawer } from 'components/AccountDrawer'
import { Connection, ConnectionType } from 'connection/types'
import { mocked } from 'test-utils/mocked'
import { createDeferredPromise } from 'test-utils/promise'
import { act, render } from 'test-utils/render'
import Option from './Option'
const mockCloseDrawer = jest.fn()
jest.mock('components/AccountDrawer')
beforeEach(() => {
jest.spyOn(console, 'debug').mockReturnValue()
mocked(useCloseAccountDrawer).mockReturnValue(mockCloseDrawer)
})
const mockConnection1: Connection = {
getName: () => 'Mock Connection 1',
connector: {
activate: jest.fn(),
deactivate: jest.fn(),
} as unknown as Connector,
getIcon: () => UNIWALLET_ICON,
type: ConnectionType.UNIWALLET,
} as unknown as Connection
const mockConnection2: Connection = {
getName: () => 'Mock Connection 2',
connector: {
activate: jest.fn(),
deactivate: jest.fn(),
} as unknown as Connector,
getIcon: () => UNIWALLET_ICON,
type: ConnectionType.INJECTED,
} as unknown as Connection
describe('Wallet Option', () => {
it('renders default state', () => {
const component = render(<Option connection={mockConnection1} />)
const option = component.getByTestId('wallet-option-UNIWALLET')
expect(option).toBeEnabled()
expect(option).toHaveProperty('selected', false)
expect(option).toMatchSnapshot()
})
it('connect when clicked', async () => {
const activationResponse = createDeferredPromise()
mocked(mockConnection1.connector.activate).mockReturnValue(activationResponse.promise)
const component = render(
<>
<Option connection={mockConnection1} />
<Option connection={mockConnection2} />
</>
)
const option1 = component.getByTestId('wallet-option-UNIWALLET')
const option2 = component.getByTestId('wallet-option-INJECTED')
expect(option1).toBeEnabled()
expect(option1).toHaveProperty('selected', false)
expect(option2).toBeEnabled()
expect(option2).toHaveProperty('selected', false)
await act(() => option1.click())
expect(option1).toBeDisabled()
expect(option1).toHaveProperty('selected', true)
expect(option2).toBeDisabled()
expect(option2).toHaveProperty('selected', false)
expect(mockCloseDrawer).toHaveBeenCalledTimes(0)
await act(async () => activationResponse.resolve())
expect(mockCloseDrawer).toHaveBeenCalledTimes(1)
expect(option1).toBeEnabled()
expect(option1).toHaveProperty('selected', false)
expect(option2).toBeEnabled()
expect(option2).toHaveProperty('selected', false)
})
})
import { TraceEvent } from '@uniswap/analytics' import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events' import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
import { useCloseAccountDrawer } from 'components/AccountDrawer'
import Loader from 'components/Icons/LoadingSpinner' import Loader from 'components/Icons/LoadingSpinner'
import { Connection, ConnectionType } from 'connection' import { ActivationStatus, useActivationState } from 'connection/activate'
import { Connection } from 'connection/types'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { useIsDarkMode } from 'theme/components/ThemeToggle' import { useIsDarkMode } from 'theme/components/ThemeToggle'
import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles' import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles'
...@@ -14,27 +16,26 @@ const OptionCardLeft = styled.div` ...@@ -14,27 +16,26 @@ const OptionCardLeft = styled.div`
align-items: center; align-items: center;
` `
const OptionCardClickable = styled.button<{ isActive?: boolean; clickable?: boolean }>` const OptionCardClickable = styled.button<{ selected: boolean }>`
background-color: ${({ theme }) => theme.backgroundModule}; background-color: ${({ theme }) => theme.backgroundModule};
border: none;
width: 100% !important; width: 100% !important;
border-color: ${({ theme, isActive }) => (isActive ? theme.accentActive : 'transparent')};
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 2rem; padding: 18px;
padding: 1rem;
margin-top: 0;
transition: ${({ theme }) => theme.transition.duration.fast}; transition: ${({ theme }) => theme.transition.duration.fast};
opacity: ${({ disabled }) => (disabled ? '0.5' : '1')}; opacity: ${({ disabled, selected }) => (disabled && !selected ? '0.5' : '1')};
&:hover { &:hover {
cursor: ${({ clickable }) => clickable && 'pointer'}; cursor: ${({ disabled }) => !disabled && 'pointer'};
background-color: ${({ theme, clickable }) => clickable && theme.hoverState}; background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
} }
&:focus { &:focus {
background-color: ${({ theme, clickable }) => clickable && theme.hoverState}; background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
} }
` `
...@@ -62,15 +63,16 @@ const IconWrapper = styled.div` ...@@ -62,15 +63,16 @@ const IconWrapper = styled.div`
`}; `};
` `
type OptionProps = { export default function Option({ connection }: { connection: Connection }) {
connection: Connection const { activationState, tryActivation } = useActivationState()
activate: () => void const closeDrawer = useCloseAccountDrawer()
pendingConnectionType?: ConnectionType const activate = () => tryActivation(connection, closeDrawer)
}
export default function Option({ connection, pendingConnectionType, activate }: OptionProps) { const isSomeOptionPending = activationState.status === ActivationStatus.PENDING
const isPending = pendingConnectionType === connection.type const isCurrentOptionPending = isSomeOptionPending && activationState.connection.type === connection.type
const isDarkMode = useIsDarkMode() const isDarkMode = useIsDarkMode()
const content = (
return (
<TraceEvent <TraceEvent
events={[BrowserEvent.onClick]} events={[BrowserEvent.onClick]}
name={InterfaceEventName.WALLET_SELECTED} name={InterfaceEventName.WALLET_SELECTED}
...@@ -78,10 +80,10 @@ export default function Option({ connection, pendingConnectionType, activate }: ...@@ -78,10 +80,10 @@ export default function Option({ connection, pendingConnectionType, activate }:
element={InterfaceElementName.WALLET_TYPE_OPTION} element={InterfaceElementName.WALLET_TYPE_OPTION}
> >
<OptionCardClickable <OptionCardClickable
onClick={!pendingConnectionType ? activate : undefined} onClick={activate}
clickable={!pendingConnectionType} disabled={isSomeOptionPending}
disabled={Boolean(!isPending && !!pendingConnectionType)} selected={isCurrentOptionPending}
data-testid="wallet-modal-option" data-testid={`wallet-option-${connection.type}`}
> >
<OptionCardLeft> <OptionCardLeft>
<IconWrapper> <IconWrapper>
...@@ -90,10 +92,8 @@ export default function Option({ connection, pendingConnectionType, activate }: ...@@ -90,10 +92,8 @@ export default function Option({ connection, pendingConnectionType, activate }:
<HeaderText>{connection.getName()}</HeaderText> <HeaderText>{connection.getName()}</HeaderText>
{connection.isNew && <NewBadge />} {connection.isNew && <NewBadge />}
</OptionCardLeft> </OptionCardLeft>
{isPending && <Loader />} {isCurrentOptionPending && <Loader />}
</OptionCardClickable> </OptionCardClickable>
</TraceEvent> </TraceEvent>
) )
return content
} }
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Wallet Option renders default state 1`] = `
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-flow: column nowrap;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c0 {
background-color: #F5F6FC;
border: none;
width: 100% !important;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
padding: 18px;
-webkit-transition: 125ms;
transition: 125ms;
opacity: 1;
}
.c0:hover {
cursor: pointer;
background-color: #ADBCFF3d;
}
.c0:focus {
background-color: #ADBCFF3d;
}
.c3 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-flow: row nowrap;
-ms-flex-flow: row nowrap;
flex-flow: row nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
color: #0D111C;
font-size: 16px;
font-weight: 600;
padding: 0 8px;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-flow: column nowrap;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.c2 > img,
.c2 span {
height: 40px;
width: 40px;
}
@media (max-width:960px) {
.c2 {
-webkit-align-items: flex-end;
-webkit-box-align: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
}
}
<button
class="c0"
data-testid="wallet-option-UNIWALLET"
>
<div
class="c1"
>
<div
class="c2"
>
<img
alt="Icon"
src="uniwallet.png"
/>
</div>
<div
class="c3"
>
Mock Connection 1
</div>
</div>
</button>
`;
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import IconButton from 'components/AccountDrawer/IconButton' import IconButton from 'components/AccountDrawer/IconButton'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column' import { AutoColumn } from 'components/Column'
import { AutoRow } from 'components/Row' import { AutoRow } from 'components/Row'
import { Connection, ConnectionType, getConnections, networkConnection } from 'connection' import { getConnections, networkConnection } from 'connection'
import { ErrorCode } from 'connection/utils' import { ActivationStatus, useActivationState } from 'connection/activate'
import { isSupportedChain } from 'constants/chains' import { isSupportedChain } from 'constants/chains'
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect } from 'react'
import { Settings } from 'react-feather' import { Settings } from 'react-feather'
import { useAppDispatch } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { ThemedText } from 'theme' import { ThemedText } from 'theme'
import { flexColumnNoWrap } from 'theme/styles' import { flexColumnNoWrap } from 'theme/styles'
...@@ -43,35 +37,12 @@ const PrivacyPolicyWrapper = styled.div` ...@@ -43,35 +37,12 @@ const PrivacyPolicyWrapper = styled.div`
padding: 0 4px; padding: 0 4px;
` `
function didUserReject(connection: Connection, error: any): boolean {
return (
error?.code === ErrorCode.USER_REJECTED_REQUEST ||
(connection.type === ConnectionType.WALLET_CONNECT && error?.toString?.() === ErrorCode.WC_MODAL_CLOSED) ||
(connection.type === ConnectionType.COINBASE_WALLET && error?.toString?.() === ErrorCode.CB_REJECTED_REQUEST)
)
}
export default function WalletModal({ openSettings }: { openSettings: () => void }) { export default function WalletModal({ openSettings }: { openSettings: () => void }) {
const dispatch = useAppDispatch()
const { connector, chainId } = useWeb3React() const { connector, chainId } = useWeb3React()
const [drawerOpen, toggleWalletDrawer] = useAccountDrawer()
const [pendingConnection, setPendingConnection] = useState<Connection | undefined>()
const [pendingError, setPendingError] = useState<any>()
const connections = getConnections() const connections = getConnections()
useEffect(() => { const { activationState } = useActivationState()
// Clean up errors when the dropdown closes
return () => setPendingError(undefined)
}, [setPendingError])
const openOptions = useCallback(() => {
if (pendingConnection) {
setPendingError(undefined)
setPendingConnection(undefined)
}
}, [pendingConnection, setPendingError])
// Keep the network connector in sync with any active user connector to prevent chain-switching on wallet disconnection. // Keep the network connector in sync with any active user connector to prevent chain-switching on wallet disconnection.
useEffect(() => { useEffect(() => {
...@@ -80,71 +51,22 @@ export default function WalletModal({ openSettings }: { openSettings: () => void ...@@ -80,71 +51,22 @@ export default function WalletModal({ openSettings }: { openSettings: () => void
} }
}, [chainId, connector]) }, [chainId, connector])
// Used to track the state of the drawer in async function
const drawerOpenRef = useRef(drawerOpen)
drawerOpenRef.current = drawerOpen
const tryActivation = useCallback(
async (connection: Connection) => {
// Skips wallet connection if the connection should override the default behavior, i.e. install metamask or launch coinbase app
if (connection.overrideActivate?.()) return
// log selected wallet
sendEvent({
category: 'Wallet',
action: 'Change Wallet',
label: connection.type,
})
try {
setPendingConnection(connection)
setPendingError(undefined)
await connection.connector.activate()
console.debug(`connection activated: ${connection.getName()}`)
dispatch(updateSelectedWallet({ wallet: connection.type }))
if (drawerOpenRef.current) toggleWalletDrawer()
} catch (error) {
console.debug(`web3-react connection error: ${JSON.stringify(error)}`)
// TODO(WEB-3162): re-add special treatment for already-pending injected errors
if (didUserReject(connection, error)) {
setPendingConnection(undefined)
} else {
setPendingError(error)
sendAnalyticsEvent(InterfaceEventName.WALLET_CONNECT_TXN_COMPLETED, {
result: WalletConnectionResult.FAILED,
wallet_type: connection.getName(),
})
}
}
},
[dispatch, setPendingError, toggleWalletDrawer]
)
return ( return (
<Wrapper data-testid="wallet-modal"> <Wrapper data-testid="wallet-modal">
<AutoRow justify="space-between" width="100%" marginBottom="16px"> <AutoRow justify="space-between" width="100%" marginBottom="16px">
<ThemedText.SubHeader>Connect a wallet</ThemedText.SubHeader> <ThemedText.SubHeader>Connect a wallet</ThemedText.SubHeader>
<IconButton Icon={Settings} onClick={openSettings} data-testid="wallet-settings" /> <IconButton Icon={Settings} onClick={openSettings} data-testid="wallet-settings" />
</AutoRow> </AutoRow>
{pendingError ? ( {activationState.status === ActivationStatus.ERROR ? (
pendingConnection && ( <ConnectionErrorView />
<ConnectionErrorView openOptions={openOptions} retryActivation={() => tryActivation(pendingConnection)} />
)
) : ( ) : (
<AutoColumn gap="16px"> <AutoColumn gap="16px">
<OptionGrid data-testid="option-grid"> <OptionGrid data-testid="option-grid">
{connections.map((connection) => {connections
connection.shouldDisplay() ? ( .filter((connection) => connection.shouldDisplay())
<Option .map((connection) => (
key={connection.getName()} <Option key={connection.getName()} connection={connection} />
connection={connection} ))}
activate={() => tryActivation(connection)}
pendingConnectionType={pendingConnection?.type}
/>
) : null
)}
</OptionGrid> </OptionGrid>
<PrivacyPolicyWrapper> <PrivacyPolicyWrapper>
<PrivacyPolicyNotice /> <PrivacyPolicyNotice />
......
...@@ -4,7 +4,8 @@ import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-e ...@@ -4,7 +4,8 @@ import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-e
import { initializeConnector, MockEIP1193Provider } from '@web3-react/core' import { initializeConnector, MockEIP1193Provider } from '@web3-react/core'
import { EIP1193 } from '@web3-react/eip1193' import { EIP1193 } from '@web3-react/eip1193'
import { Provider as EIP1193Provider } from '@web3-react/types' import { Provider as EIP1193Provider } from '@web3-react/types'
import { Connection, ConnectionType, useGetConnection } from 'connection' import { useGetConnection } from 'connection'
import { Connection, ConnectionType } from 'connection/types'
import useEagerlyConnect from 'hooks/useEagerlyConnect' import useEagerlyConnect from 'hooks/useEagerlyConnect'
import useOrderedConnections from 'hooks/useOrderedConnections' import useOrderedConnections from 'hooks/useOrderedConnections'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
......
import { Web3ReactHooks } from '@web3-react/core'
import { Connector } from '@web3-react/types'
import { createDeferredPromise } from 'test-utils/promise'
import { act, renderHook } from '../test-utils/render'
import { ActivationStatus, useActivationState } from './activate'
import { Connection, ConnectionType } from './types'
import { ErrorCode } from './utils'
class MockConnector extends Connector {
activate: () => void
deactivate: () => void
resetState = jest.fn()
constructor(activate: () => void, deactivate?: () => void) {
const actions = {
startActivation: jest.fn(),
update: jest.fn(),
resetState: jest.fn(),
}
super(actions)
this.activate = activate ?? jest.fn()
this.deactivate = deactivate ?? jest.fn()
}
}
function createMockConnection(
activate: () => void,
deactivate?: () => void,
type = ConnectionType.INJECTED
): Connection {
return {
getName: () => 'Test Connection',
hooks: {} as unknown as Web3ReactHooks,
type,
shouldDisplay: () => true,
connector: new MockConnector(activate, deactivate),
}
}
beforeEach(() => {
jest.spyOn(console, 'error').mockReturnValue()
jest.spyOn(console, 'debug').mockReturnValue()
})
it('Should initialize with proper IDLE state', async () => {
const result = renderHook(useActivationState).result
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
})
it('Should call activate function on a connection', async () => {
const activationResponse = createDeferredPromise()
const mockConnection = createMockConnection(jest.fn().mockImplementation(() => activationResponse.promise))
const result = renderHook(useActivationState).result
const onSuccess = jest.fn()
let activationCall: Promise<void> = new Promise(jest.fn())
act(() => {
activationCall = result.current.tryActivation(mockConnection, onSuccess)
})
expect(result.current.activationState).toEqual({ status: ActivationStatus.PENDING, connection: mockConnection })
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(console.debug).toHaveBeenLastCalledWith(`Connection activating: ${mockConnection.getName()}`)
expect(onSuccess).toHaveBeenCalledTimes(0)
await act(async () => {
activationResponse.resolve()
})
await activationCall
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(console.debug).toHaveBeenLastCalledWith(`Connection activated: ${mockConnection.getName()}`)
expect(console.debug).toHaveBeenCalledTimes(2)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('Should properly deactivate pending connection attempts', async () => {
const mockConnection = createMockConnection(
jest.fn().mockImplementation(() => new Promise(jest.fn())),
jest.fn().mockImplementation(() => Promise.resolve())
)
const result = renderHook(useActivationState).result
const onSuccess = jest.fn()
act(() => {
result.current.tryActivation(mockConnection, onSuccess)
})
expect(result.current.activationState).toEqual({ status: ActivationStatus.PENDING, connection: mockConnection })
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
await act(() => result.current.cancelActivation())
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(mockConnection.connector.deactivate).toHaveBeenCalledTimes(1)
expect(console.debug).not.toHaveBeenLastCalledWith(`Connection activated: ${mockConnection.getName()}`)
expect(onSuccess).toHaveBeenCalledTimes(0)
})
it('Should properly display error state', async () => {
const activationResponse = createDeferredPromise()
const mockConnection = createMockConnection(
jest.fn().mockImplementation(() => activationResponse.promise),
jest.fn().mockImplementation(() => Promise.resolve())
)
const result = renderHook(useActivationState).result
const onSuccess = jest.fn()
act(() => {
result.current.tryActivation(mockConnection, onSuccess)
})
expect(result.current.activationState).toEqual({ status: ActivationStatus.PENDING, connection: mockConnection })
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
await act(async () => {
activationResponse.reject('Failed to connect')
})
expect(result.current.activationState).toEqual({
status: ActivationStatus.ERROR,
connection: mockConnection,
error: 'Failed to connect',
})
expect(console.debug).toHaveBeenLastCalledWith(`Connection failed: ${mockConnection.getName()}`)
expect(console.debug).toHaveBeenCalledTimes(2)
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(0)
})
it('Should successfully retry a failed activation', async () => {
const mockConnection = createMockConnection(
jest
.fn()
.mockImplementationOnce(() => Promise.reject('Failed to connect'))
.mockImplementationOnce(() => Promise.resolve())
)
const result = renderHook(useActivationState).result
const onSuccess = jest.fn()
await act(() => result.current.tryActivation(mockConnection, onSuccess))
expect(result.current.activationState).toEqual({
status: ActivationStatus.ERROR,
connection: mockConnection,
error: 'Failed to connect',
})
expect(console.debug).toHaveBeenLastCalledWith(`Connection failed: ${mockConnection.getName()}`)
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(0)
await act(() => result.current.tryActivation(mockConnection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(mockConnection.connector.activate).toHaveBeenCalledTimes(2)
expect(console.debug).toHaveBeenLastCalledWith(`Connection activated: ${mockConnection.getName()}`)
expect(console.debug).toHaveBeenCalledTimes(4)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
describe('Should gracefully handle intentional user-rejection errors', () => {
it('handles Injected user-rejection error', async () => {
const result = renderHook(useActivationState).result
const injectedConection = createMockConnection(
jest
.fn()
.mockImplementationOnce(() => Promise.reject({ code: ErrorCode.USER_REJECTED_REQUEST }))
.mockImplementationOnce(() => Promise.resolve),
jest.fn(),
ConnectionType.INJECTED
)
const onSuccess = jest.fn()
await act(() => result.current.tryActivation(injectedConection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(injectedConection.connector.activate).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(0)
await act(() => result.current.tryActivation(injectedConection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(injectedConection.connector.activate).toHaveBeenCalledTimes(2)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('handles Coinbase user-rejection error', async () => {
const result = renderHook(useActivationState).result
const coinbaseConnection = createMockConnection(
jest
.fn()
.mockImplementationOnce(() => Promise.reject(ErrorCode.CB_REJECTED_REQUEST))
.mockImplementationOnce(() => Promise.resolve),
jest.fn(),
ConnectionType.COINBASE_WALLET
)
const onSuccess = jest.fn()
await act(() => result.current.tryActivation(coinbaseConnection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(coinbaseConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(0)
await act(() => result.current.tryActivation(coinbaseConnection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(coinbaseConnection.connector.activate).toHaveBeenCalledTimes(2)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('handles WalletConect Modal close error', async () => {
const result = renderHook(useActivationState).result
const wcConnection = createMockConnection(
jest
.fn()
.mockImplementationOnce(() => Promise.reject(ErrorCode.WC_MODAL_CLOSED))
.mockImplementationOnce(() => Promise.resolve),
jest.fn(),
ConnectionType.WALLET_CONNECT
)
const onSuccess = jest.fn()
await act(() => result.current.tryActivation(wcConnection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(wcConnection.connector.activate).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(0)
await act(() => result.current.tryActivation(wcConnection, onSuccess))
expect(result.current.activationState).toEqual({ status: ActivationStatus.IDLE })
expect(wcConnection.connector.activate).toHaveBeenCalledTimes(2)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-events'
import { Connection } from 'connection/types'
import { atom } from 'jotai'
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import { useCallback } from 'react'
import { useAppDispatch } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer'
import { didUserReject } from './utils'
export enum ActivationStatus {
PENDING,
ERROR,
IDLE,
}
type ActivationPendingState = { status: ActivationStatus.PENDING; connection: Connection }
type ActivationErrorState = { status: ActivationStatus.ERROR; connection: Connection; error: any }
const IDLE_ACTIVATION_STATE = { status: ActivationStatus.IDLE } as const
type ActivationState = ActivationPendingState | ActivationErrorState | typeof IDLE_ACTIVATION_STATE
const activationStateAtom = atom<ActivationState>(IDLE_ACTIVATION_STATE)
function useTryActivation() {
const dispatch = useAppDispatch()
const setActivationState = useUpdateAtom(activationStateAtom)
return useCallback(
async (connection: Connection, onSuccess: () => void) => {
/*
* Skips wallet connection if the connection should override the default
* behavior, i.e. install MetaMask or launch Coinbase app
*/
if (connection.overrideActivate?.()) return
try {
setActivationState({ status: ActivationStatus.PENDING, connection })
console.debug(`Connection activating: ${connection.getName()}`)
await connection.connector.activate()
console.debug(`Connection activated: ${connection.getName()}`)
dispatch(updateSelectedWallet({ wallet: connection.type }))
// Clears pending connection state
setActivationState(IDLE_ACTIVATION_STATE)
onSuccess()
} catch (error) {
// TODO(WEB-3162): re-add special treatment for already-pending injected errors & move debug to after didUserReject() check
console.debug(`Connection failed: ${connection.getName()}`)
console.error(error)
// Gracefully handles errors from the user rejecting a connection attempt
if (didUserReject(connection, error)) {
setActivationState(IDLE_ACTIVATION_STATE)
return
}
// Failed Connection events are logged here, while successful ones are logged by Web3Provider
sendAnalyticsEvent(InterfaceEventName.WALLET_CONNECT_TXN_COMPLETED, {
result: WalletConnectionResult.FAILED,
wallet_type: connection.getName(),
})
setActivationState({ status: ActivationStatus.ERROR, connection, error })
}
},
[dispatch, setActivationState]
)
}
function useCancelActivation() {
const setActivationState = useUpdateAtom(activationStateAtom)
return useCallback(
() =>
setActivationState((activationState) => {
if (activationState.status !== ActivationStatus.IDLE) activationState.connection.connector.deactivate?.()
return IDLE_ACTIVATION_STATE
}),
[setActivationState]
)
}
export function useActivationState() {
const activationState = useAtomValue(activationStateAtom)
const tryActivation = useTryActivation()
const cancelActivation = useCancelActivation()
return { activationState, tryActivation, cancelActivation }
}
import INJECTED_DARK_ICON from 'assets/svg/browser-wallet-dark.svg' import INJECTED_DARK_ICON from 'assets/svg/browser-wallet-dark.svg'
import INJECTED_LIGHT_ICON from 'assets/svg/browser-wallet-light.svg' import INJECTED_LIGHT_ICON from 'assets/svg/browser-wallet-light.svg'
import { ConnectionType, getConnections, useGetConnection } from 'connection' import { getConnections, useGetConnection } from 'connection'
import { renderHook } from 'test-utils/render' import { renderHook } from 'test-utils/render'
import { ConnectionType } from './types'
const UserAgentMock = jest.requireMock('utils/userAgent') const UserAgentMock = jest.requireMock('utils/userAgent')
jest.mock('utils/userAgent', () => ({ jest.mock('utils/userAgent', () => ({
isMobile: false, isMobile: false,
......
import { CoinbaseWallet } from '@web3-react/coinbase-wallet' import { CoinbaseWallet } from '@web3-react/coinbase-wallet'
import { initializeConnector, Web3ReactHooks } from '@web3-react/core' import { initializeConnector } from '@web3-react/core'
import { GnosisSafe } from '@web3-react/gnosis-safe' import { GnosisSafe } from '@web3-react/gnosis-safe'
import { MetaMask } from '@web3-react/metamask' import { MetaMask } from '@web3-react/metamask'
import { Network } from '@web3-react/network' import { Network } from '@web3-react/network'
...@@ -18,29 +18,10 @@ import { isMobile, isNonIOSPhone } from 'utils/userAgent' ...@@ -18,29 +18,10 @@ import { isMobile, isNonIOSPhone } from 'utils/userAgent'
import { RPC_URLS } from '../constants/networks' import { RPC_URLS } from '../constants/networks'
import { RPC_PROVIDERS } from '../constants/providers' import { RPC_PROVIDERS } from '../constants/providers'
import { Connection, ConnectionType } from './types'
import { getIsCoinbaseWallet, getIsInjected, getIsMetaMaskWallet } from './utils' import { getIsCoinbaseWallet, getIsInjected, getIsMetaMaskWallet } from './utils'
import { UniwalletConnect, WalletConnectPopup } from './WalletConnect' import { UniwalletConnect, WalletConnectPopup } from './WalletConnect'
export enum ConnectionType {
UNIWALLET = 'UNIWALLET',
INJECTED = 'INJECTED',
COINBASE_WALLET = 'COINBASE_WALLET',
WALLET_CONNECT = 'WALLET_CONNECT',
NETWORK = 'NETWORK',
GNOSIS_SAFE = 'GNOSIS_SAFE',
}
export interface Connection {
getName(): string
connector: Connector
hooks: Web3ReactHooks
type: ConnectionType
getIcon?(isDarkMode: boolean): string
shouldDisplay(): boolean
overrideActivate?: () => boolean
isNew?: boolean
}
function onError(error: Error) { function onError(error: Error) {
console.debug(`web3-react error: ${error}`) console.debug(`web3-react error: ${error}`)
} }
......
import { Web3ReactHooks } from '@web3-react/core'
import { Connector } from '@web3-react/types'
export enum ConnectionType {
UNIWALLET = 'UNIWALLET',
INJECTED = 'INJECTED',
COINBASE_WALLET = 'COINBASE_WALLET',
WALLET_CONNECT = 'WALLET_CONNECT',
NETWORK = 'NETWORK',
GNOSIS_SAFE = 'GNOSIS_SAFE',
}
export interface Connection {
getName(): string
connector: Connector
hooks: Web3ReactHooks
type: ConnectionType
getIcon?(isDarkMode: boolean): string
shouldDisplay(): boolean
overrideActivate?: () => boolean
isNew?: boolean
}
import { Connection, ConnectionType } from 'connection/types'
export const getIsInjected = () => Boolean(window.ethereum) export const getIsInjected = () => Boolean(window.ethereum)
// When using Brave browser, `isMetaMask` is set to true when using the built-in wallet // When using Brave browser, `isMetaMask` is set to true when using the built-in wallet
...@@ -25,3 +27,12 @@ export enum ErrorCode { ...@@ -25,3 +27,12 @@ export enum ErrorCode {
WC_MODAL_CLOSED = 'Error: User closed modal', WC_MODAL_CLOSED = 'Error: User closed modal',
CB_REJECTED_REQUEST = 'Error: User denied account authorization', CB_REJECTED_REQUEST = 'Error: User denied account authorization',
} }
// TODO(WEB-3279): merge this function with existing didUserReject for Swap errors
export function didUserReject(connection: Connection, error: any): boolean {
return (
error?.code === ErrorCode.USER_REJECTED_REQUEST ||
(connection.type === ConnectionType.WALLET_CONNECT && error?.toString?.() === ErrorCode.WC_MODAL_CLOSED) ||
(connection.type === ConnectionType.COINBASE_WALLET && error?.toString?.() === ErrorCode.CB_REJECTED_REQUEST)
)
}
import { Connector } from '@web3-react/types' import { Connector } from '@web3-react/types'
import { Connection, gnosisSafeConnection, networkConnection } from 'connection' import { gnosisSafeConnection, networkConnection } from 'connection'
import { useGetConnection } from 'connection' import { useGetConnection } from 'connection'
import { Connection } from 'connection/types'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks' import { useAppDispatch, useAppSelector } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer' import { updateSelectedWallet } from 'state/user/reducer'
......
import { ConnectionType } from 'connection'
import { useGetConnection } from 'connection' import { useGetConnection } from 'connection'
import { ConnectionType } from 'connection/types'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useAppSelector } from 'state/hooks' import { useAppSelector } from 'state/hooks'
......
...@@ -98,10 +98,6 @@ export function useOpenModal(modal: ApplicationModal): () => void { ...@@ -98,10 +98,6 @@ export function useOpenModal(modal: ApplicationModal): () => void {
return useCallback(() => dispatch(setOpenModal(modal)), [dispatch, modal]) return useCallback(() => dispatch(setOpenModal(modal)), [dispatch, modal])
} }
export function useToggleUniwalletModal(): () => void {
return useToggleModal(ApplicationModal.UNIWALLET_CONNECT)
}
export function useToggleSettingsMenu(): () => void { export function useToggleSettingsMenu(): () => void {
return useToggleModal(ApplicationModal.SETTINGS) return useToggleModal(ApplicationModal.SETTINGS)
} }
......
...@@ -13,7 +13,6 @@ export type PopupContent = ...@@ -13,7 +13,6 @@ export type PopupContent =
} }
export enum ApplicationModal { export enum ApplicationModal {
UNIWALLET_CONNECT,
ADDRESS_CLAIM, ADDRESS_CLAIM,
BLOCKED_ACCOUNT, BLOCKED_ACCOUNT,
CLAIM_POPUP, CLAIM_POPUP,
......
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection' import { ConnectionType } from 'connection/types'
interface ConnectionState { interface ConnectionState {
errorByConnectionType: Record<ConnectionType, string | undefined> errorByConnectionType: Record<ConnectionType, string | undefined>
......
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection' import { ConnectionType } from 'connection/types'
import { SupportedLocale } from 'constants/locales' import { SupportedLocale } from 'constants/locales'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc' import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
......
type DeferredPromise<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}
export function createDeferredPromise<T = void>() {
const deferedPromise = {} as DeferredPromise<T>
const promise = new Promise<T>((resolve, reject) => {
deferedPromise.reject = reject
deferedPromise.resolve = resolve
})
deferedPromise.promise = promise
return deferedPromise
}
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