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 { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceElementName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { WalletConnect } from '@web3-react/walletconnect'
import Column, { AutoColumn } from 'components/Column'
import Modal from 'components/Modal'
import { RowBetween } from 'components/Row'
import { uniwalletConnectConnection } from 'connection'
import { ActivationStatus, useActivationState } from 'connection/activate'
import { ConnectionType } from 'connection/types'
import { UniwalletConnect } from 'connection/WalletConnect'
import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useState } from 'react'
import { useModalIsOpen, useToggleUniwalletModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import { useEffect, useState } from 'react'
import styled, { useTheme } from 'styled-components/macro'
import { CloseIcon, ThemedText } from 'theme'
......@@ -39,44 +38,37 @@ const Divider = styled.div`
`
export default function UniwalletModal() {
const open = useModalIsOpen(ApplicationModal.UNIWALLET_CONNECT)
const toggle = useToggleUniwalletModal()
const { activationState, cancelActivation } = useActivationState()
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(() => {
;(uniwalletConnectConnection.connector as WalletConnect).events.addListener(
UniwalletConnect.UNI_URI_AVAILABLE,
(uri) => {
uri && setUri(uri)
toggle()
}
)
}, [toggle])
}, [])
const { account } = useWeb3React()
useEffect(() => {
if (open) {
sendAnalyticsEvent('Uniswap wallet modal opened', { userConnected: !!account })
if (account) {
toggle()
}
}
}, [account, open, toggle])
const onClose = useCallback(() => {
uniwalletConnectConnection.connector.deactivate?.()
toggle()
}, [toggle])
if (open) sendAnalyticsEvent('Uniswap wallet modal opened')
}, [open])
const theme = useTheme()
return (
<Modal isOpen={open} onDismiss={onClose}>
<Modal isOpen={open} onDismiss={cancelActivation}>
<UniwalletConnectWrapper>
<HeaderRow>
<ThemedText.SubHeader>
<Trans>Scan with Uniswap Wallet</Trans>
</ThemedText.SubHeader>
<CloseIcon onClick={onClose} />
<CloseIcon onClick={cancelActivation} />
</HeaderRow>
<QRCodeWrapper>
{uri && (
......
......@@ -27,6 +27,11 @@ export function useToggleAccountDrawer() {
}, [updateAccountDrawerOpen])
}
export function useCloseAccountDrawer() {
const updateAccountDrawerOpen = useUpdateAtom(accountDrawerOpenAtom)
return useCallback(() => updateAccountDrawerOpen(false), [updateAccountDrawerOpen])
}
export function useAccountDrawer(): [boolean, () => void] {
const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom)
return [accountDrawerOpen, useToggleAccountDrawer()]
......
import { useWeb3React } from '@web3-react/core'
import { Unicon } from 'components/Unicon'
import { Connection, ConnectionType } from 'connection'
import { Connection, ConnectionType } from 'connection/types'
import useENSAvatar from 'hooks/useENSAvatar'
import styled from 'styled-components/macro'
import { useIsDarkMode } from 'theme/components/ThemeToggle'
......
import { t } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import { MouseoverTooltip } from 'components/Tooltip'
import { ConnectionType } from 'connection'
import { useGetConnection } from 'connection'
import { ConnectionType } from 'connection/types'
import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId, UniWalletSupportedChains } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
......
import { Trans } from '@lingui/macro'
import { useCloseAccountDrawer } from 'components/AccountDrawer'
import { ButtonEmpty, ButtonPrimary } from 'components/Button'
import { ActivationStatus, useActivationState } from 'connection/activate'
import { AlertTriangle } from 'react-feather'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
......@@ -20,13 +22,15 @@ const AlertTriangleIcon = styled(AlertTriangle)`
color: ${({ theme }) => theme.accentCritical};
`
export default function ConnectionErrorView({
retryActivation,
openOptions,
}: {
retryActivation: () => void
openOptions: () => void
}) {
// TODO(cartcrom): move this to a top level modal, rather than inline in the drawer
export default function ConnectionErrorView() {
const { activationState, tryActivation, cancelActivation } = useActivationState()
const closeDrawer = useCloseAccountDrawer()
if (activationState.status !== ActivationStatus.ERROR) return null
const retry = () => tryActivation(activationState.connection, closeDrawer)
return (
<Wrapper>
<AlertTriangleIcon />
......@@ -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.
</Trans>
</ThemedText.BodyPrimary>
<ButtonPrimary $borderRadius="16px" onClick={retryActivation}>
<ButtonPrimary $borderRadius="16px" onClick={retry}>
<Trans>Try Again</Trans>
</ButtonPrimary>
<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>
</ThemedText.Link>
</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 { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
import { useCloseAccountDrawer } from 'components/AccountDrawer'
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 { useIsDarkMode } from 'theme/components/ThemeToggle'
import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles'
......@@ -14,27 +16,26 @@ const OptionCardLeft = styled.div`
align-items: center;
`
const OptionCardClickable = styled.button<{ isActive?: boolean; clickable?: boolean }>`
const OptionCardClickable = styled.button<{ selected: boolean }>`
background-color: ${({ theme }) => theme.backgroundModule};
border: none;
width: 100% !important;
border-color: ${({ theme, isActive }) => (isActive ? theme.accentActive : 'transparent')};
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 2rem;
padding: 1rem;
padding: 18px;
margin-top: 0;
transition: ${({ theme }) => theme.transition.duration.fast};
opacity: ${({ disabled }) => (disabled ? '0.5' : '1')};
opacity: ${({ disabled, selected }) => (disabled && !selected ? '0.5' : '1')};
&:hover {
cursor: ${({ clickable }) => clickable && 'pointer'};
background-color: ${({ theme, clickable }) => clickable && theme.hoverState};
cursor: ${({ disabled }) => !disabled && 'pointer'};
background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
}
&:focus {
background-color: ${({ theme, clickable }) => clickable && theme.hoverState};
background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
}
`
......@@ -62,15 +63,16 @@ const IconWrapper = styled.div`
`};
`
type OptionProps = {
connection: Connection
activate: () => void
pendingConnectionType?: ConnectionType
}
export default function Option({ connection, pendingConnectionType, activate }: OptionProps) {
const isPending = pendingConnectionType === connection.type
export default function Option({ connection }: { connection: Connection }) {
const { activationState, tryActivation } = useActivationState()
const closeDrawer = useCloseAccountDrawer()
const activate = () => tryActivation(connection, closeDrawer)
const isSomeOptionPending = activationState.status === ActivationStatus.PENDING
const isCurrentOptionPending = isSomeOptionPending && activationState.connection.type === connection.type
const isDarkMode = useIsDarkMode()
const content = (
return (
<TraceEvent
events={[BrowserEvent.onClick]}
name={InterfaceEventName.WALLET_SELECTED}
......@@ -78,10 +80,10 @@ export default function Option({ connection, pendingConnectionType, activate }:
element={InterfaceElementName.WALLET_TYPE_OPTION}
>
<OptionCardClickable
onClick={!pendingConnectionType ? activate : undefined}
clickable={!pendingConnectionType}
disabled={Boolean(!isPending && !!pendingConnectionType)}
data-testid="wallet-modal-option"
onClick={activate}
disabled={isSomeOptionPending}
selected={isCurrentOptionPending}
data-testid={`wallet-option-${connection.type}`}
>
<OptionCardLeft>
<IconWrapper>
......@@ -90,10 +92,8 @@ export default function Option({ connection, pendingConnectionType, activate }:
<HeaderText>{connection.getName()}</HeaderText>
{connection.isNew && <NewBadge />}
</OptionCardLeft>
{isPending && <Loader />}
{isCurrentOptionPending && <Loader />}
</OptionCardClickable>
</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 { useAccountDrawer } from 'components/AccountDrawer'
import IconButton from 'components/AccountDrawer/IconButton'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column'
import { AutoRow } from 'components/Row'
import { Connection, ConnectionType, getConnections, networkConnection } from 'connection'
import { ErrorCode } from 'connection/utils'
import { getConnections, networkConnection } from 'connection'
import { ActivationStatus, useActivationState } from 'connection/activate'
import { isSupportedChain } from 'constants/chains'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect } from 'react'
import { Settings } from 'react-feather'
import { useAppDispatch } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { flexColumnNoWrap } from 'theme/styles'
......@@ -43,35 +37,12 @@ const PrivacyPolicyWrapper = styled.div`
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 }) {
const dispatch = useAppDispatch()
const { connector, chainId } = useWeb3React()
const [drawerOpen, toggleWalletDrawer] = useAccountDrawer()
const [pendingConnection, setPendingConnection] = useState<Connection | undefined>()
const [pendingError, setPendingError] = useState<any>()
const connections = getConnections()
useEffect(() => {
// Clean up errors when the dropdown closes
return () => setPendingError(undefined)
}, [setPendingError])
const openOptions = useCallback(() => {
if (pendingConnection) {
setPendingError(undefined)
setPendingConnection(undefined)
}
}, [pendingConnection, setPendingError])
const { activationState } = useActivationState()
// Keep the network connector in sync with any active user connector to prevent chain-switching on wallet disconnection.
useEffect(() => {
......@@ -80,71 +51,22 @@ export default function WalletModal({ openSettings }: { openSettings: () => void
}
}, [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 (
<Wrapper data-testid="wallet-modal">
<AutoRow justify="space-between" width="100%" marginBottom="16px">
<ThemedText.SubHeader>Connect a wallet</ThemedText.SubHeader>
<IconButton Icon={Settings} onClick={openSettings} data-testid="wallet-settings" />
</AutoRow>
{pendingError ? (
pendingConnection && (
<ConnectionErrorView openOptions={openOptions} retryActivation={() => tryActivation(pendingConnection)} />
)
{activationState.status === ActivationStatus.ERROR ? (
<ConnectionErrorView />
) : (
<AutoColumn gap="16px">
<OptionGrid data-testid="option-grid">
{connections.map((connection) =>
connection.shouldDisplay() ? (
<Option
key={connection.getName()}
connection={connection}
activate={() => tryActivation(connection)}
pendingConnectionType={pendingConnection?.type}
/>
) : null
)}
{connections
.filter((connection) => connection.shouldDisplay())
.map((connection) => (
<Option key={connection.getName()} connection={connection} />
))}
</OptionGrid>
<PrivacyPolicyWrapper>
<PrivacyPolicyNotice />
......
......@@ -4,7 +4,8 @@ import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-e
import { initializeConnector, MockEIP1193Provider } from '@web3-react/core'
import { EIP1193 } from '@web3-react/eip1193'
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 useOrderedConnections from 'hooks/useOrderedConnections'
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_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 { ConnectionType } from './types'
const UserAgentMock = jest.requireMock('utils/userAgent')
jest.mock('utils/userAgent', () => ({
isMobile: false,
......
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 { MetaMask } from '@web3-react/metamask'
import { Network } from '@web3-react/network'
......@@ -18,29 +18,10 @@ import { isMobile, isNonIOSPhone } from 'utils/userAgent'
import { RPC_URLS } from '../constants/networks'
import { RPC_PROVIDERS } from '../constants/providers'
import { Connection, ConnectionType } from './types'
import { getIsCoinbaseWallet, getIsInjected, getIsMetaMaskWallet } from './utils'
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) {
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)
// When using Brave browser, `isMetaMask` is set to true when using the built-in wallet
......@@ -25,3 +27,12 @@ export enum ErrorCode {
WC_MODAL_CLOSED = 'Error: User closed modal',
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 { Connection, gnosisSafeConnection, networkConnection } from 'connection'
import { gnosisSafeConnection, networkConnection } from 'connection'
import { useGetConnection } from 'connection'
import { Connection } from 'connection/types'
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer'
......
import { ConnectionType } from 'connection'
import { useGetConnection } from 'connection'
import { ConnectionType } from 'connection/types'
import { useMemo } from 'react'
import { useAppSelector } from 'state/hooks'
......
......@@ -98,10 +98,6 @@ export function useOpenModal(modal: ApplicationModal): () => void {
return useCallback(() => dispatch(setOpenModal(modal)), [dispatch, modal])
}
export function useToggleUniwalletModal(): () => void {
return useToggleModal(ApplicationModal.UNIWALLET_CONNECT)
}
export function useToggleSettingsMenu(): () => void {
return useToggleModal(ApplicationModal.SETTINGS)
}
......
......@@ -13,7 +13,6 @@ export type PopupContent =
}
export enum ApplicationModal {
UNIWALLET_CONNECT,
ADDRESS_CLAIM,
BLOCKED_ACCOUNT,
CLAIM_POPUP,
......
import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection'
import { ConnectionType } from 'connection/types'
interface ConnectionState {
errorByConnectionType: Record<ConnectionType, string | undefined>
......
import { createSlice } from '@reduxjs/toolkit'
import { ConnectionType } from 'connection'
import { ConnectionType } from 'connection/types'
import { SupportedLocale } from 'constants/locales'
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