feat(chain-connectivity): warn when the user is not receiving blocks (#2123)

* logic for tracking time since last block

* add polling times to chain info

* pr feedback

* add gui for chain connectivity warning

* add arb support

* update title of warning to indicate internet connectivity issues may be the problem

* pr review

* clean up useBlockWarningTimer

* softer language on mainnet

* only show warning if user has the window visible
This commit is contained in:
Jordan Frankfurt 2021-09-15 22:20:28 -04:00 committed by GitHub
parent c2093ce040
commit bcf64bcb11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 169 additions and 25 deletions

@ -0,0 +1,76 @@
import { Trans } from '@lingui/macro'
import { CHAIN_INFO, L2ChainInfo, SupportedChainId } from 'constants/chains'
import { useActiveWeb3React } from 'hooks/web3'
import { AlertOctagon } from 'react-feather'
import styled from 'styled-components/macro'
import { ExternalLink, MEDIA_WIDTHS } from 'theme'
const BodyRow = styled.div`
color: ${({ theme }) => theme.black};
font-size: 12px;
`
const CautionIcon = styled(AlertOctagon)`
color: ${({ theme }) => theme.black};
`
const Link = styled(ExternalLink)`
color: ${({ theme }) => theme.black};
text-decoration: underline;
`
const TitleRow = styled.div`
align-items: center;
display: flex;
justify-content: flex-start;
margin-bottom: 8px;
`
const TitleText = styled.div`
color: black;
font-weight: 600;
font-size: 16px;
line-height: 20px;
margin: 0px 12px;
`
const Wrapper = styled.div`
background-color: ${({ theme }) => theme.yellow3};
border-radius: 12px;
bottom: 60px;
display: none;
max-width: 348px;
padding: 16px 20px;
position: absolute;
right: 16px;
@media screen and (min-width: ${MEDIA_WIDTHS.upToMedium}px) {
display: block;
}
`
export function ChainConnectivityWarning() {
const { chainId } = useActiveWeb3React()
const info = CHAIN_INFO[chainId ?? SupportedChainId.MAINNET]
const label = info?.label
return (
<Wrapper>
<TitleRow>
<CautionIcon />
<TitleText>
<Trans>Network Warning</Trans>
</TitleText>
</TitleRow>
<BodyRow>
{chainId === SupportedChainId.MAINNET ? (
<Trans>You may have lost your network connection.</Trans>
) : (
<Trans>{label} may be down right now, or you may have lost your network connection.</Trans>
)}{' '}
{(info as L2ChainInfo).statusPage !== undefined && (
<span>
<Trans>Check network status</Trans>{' '}
<Link href={(info as L2ChainInfo).statusPage || ''}>
<Trans>here.</Trans>
</Link>
</span>
)}
</BodyRow>
</Wrapper>
)
}

@ -1,19 +1,21 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAppSelector } from 'state/hooks'
import styled, { keyframes } from 'styled-components/macro' import styled, { keyframes } from 'styled-components/macro'
import { useActiveWeb3React } from '../../hooks/web3' import { useActiveWeb3React } from '../../hooks/web3'
import { useBlockNumber } from '../../state/application/hooks' import { useBlockNumber } from '../../state/application/hooks'
import { ExternalLink, TYPE } from '../../theme' import { ExternalLink, TYPE } from '../../theme'
import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink' import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink'
import { ChainConnectivityWarning } from './ChainConnectivityWarning'
const StyledPolling = styled.div` const StyledPolling = styled.div<{ warning: boolean }>`
position: fixed; position: fixed;
display: flex; display: flex;
align-items: center; align-items: center;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 1rem; padding: 1rem;
color: ${({ theme }) => theme.green1}; color: ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
transition: 250ms ease color;
${({ theme }) => theme.mediaWidth.upToMedium` ${({ theme }) => theme.mediaWidth.upToMedium`
display: none; display: none;
@ -26,14 +28,15 @@ const StyledPollingNumber = styled(TYPE.small)<{ breathe: boolean; hovering: boo
opacity: 1; opacity: 1;
} }
` `
const StyledPollingDot = styled.div` const StyledPollingDot = styled.div<{ warning: boolean }>`
width: 8px; width: 8px;
height: 8px; height: 8px;
min-height: 8px; min-height: 8px;
min-width: 8px; min-width: 8px;
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
background-color: ${({ theme }) => theme.green1}; background-color: ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
transition: 250ms ease background-color;
` `
const rotate360 = keyframes` const rotate360 = keyframes`
@ -45,19 +48,20 @@ const rotate360 = keyframes`
} }
` `
const Spinner = styled.div` const Spinner = styled.div<{ warning: boolean }>`
animation: ${rotate360} 1s cubic-bezier(0.83, 0, 0.17, 1) infinite; animation: ${rotate360} 1s cubic-bezier(0.83, 0, 0.17, 1) infinite;
transform: translateZ(0); transform: translateZ(0);
border-top: 1px solid transparent; border-top: 1px solid transparent;
border-right: 1px solid transparent; border-right: 1px solid transparent;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
border-left: 2px solid ${({ theme }) => theme.green1}; border-left: 2px solid ${({ theme, warning }) => (warning ? theme.yellow3 : theme.green1)};
background: transparent; background: transparent;
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
transition: 250ms ease border-color;
left: -3px; left: -3px;
top: -3px; top: -3px;
@ -65,11 +69,10 @@ const Spinner = styled.div`
export default function Polling() { export default function Polling() {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const blockNumber = useBlockNumber() const blockNumber = useBlockNumber()
const [isMounting, setIsMounting] = useState(false) const [isMounting, setIsMounting] = useState(false)
const [isHover, setIsHover] = useState(false) const [isHover, setIsHover] = useState(false)
const chainConnectivityWarning = useAppSelector((state) => state.application.chainConnectivityWarning)
useEffect( useEffect(
() => { () => {
@ -90,15 +93,24 @@ export default function Polling() {
) )
return ( return (
<ExternalLink <>
href={chainId && blockNumber ? getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK) : ''} <ExternalLink
> href={chainId && blockNumber ? getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK) : ''}
<StyledPolling onMouseEnter={() => setIsHover(true)} onMouseLeave={() => setIsHover(false)}> >
<StyledPollingNumber breathe={isMounting} hovering={isHover}> <StyledPolling
{blockNumber}&ensp; onMouseEnter={() => setIsHover(true)}
</StyledPollingNumber> onMouseLeave={() => setIsHover(false)}
<StyledPollingDot>{isMounting && <Spinner />}</StyledPollingDot> warning={chainConnectivityWarning}
</StyledPolling> >
</ExternalLink> <StyledPollingNumber breathe={isMounting} hovering={isHover}>
{blockNumber}&ensp;
</StyledPollingNumber>
<StyledPollingDot warning={chainConnectivityWarning}>
{isMounting && <Spinner warning={chainConnectivityWarning} />}
</StyledPollingDot>{' '}
</StyledPolling>
</ExternalLink>
{chainConnectivityWarning && <ChainConnectivityWarning />}
</>
) )
} }

@ -1,5 +1,6 @@
import arbitrumLogoUrl from 'assets/svg/arbitrum_logo.svg' import arbitrumLogoUrl from 'assets/svg/arbitrum_logo.svg'
import optimismLogoUrl from 'assets/svg/optimism_logo.svg' import optimismLogoUrl from 'assets/svg/optimism_logo.svg'
import ms from 'ms.macro'
export enum SupportedChainId { export enum SupportedChainId {
MAINNET = 1, MAINNET = 1,
@ -47,6 +48,7 @@ export const L2_CHAIN_IDS = [
export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number] export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number]
interface L1ChainInfo { interface L1ChainInfo {
readonly blockWaitMsBeforeWarning?: number
readonly docs: string readonly docs: string
readonly explorer: string readonly explorer: string
readonly infoLink: string readonly infoLink: string
@ -55,6 +57,7 @@ interface L1ChainInfo {
export interface L2ChainInfo extends L1ChainInfo { export interface L2ChainInfo extends L1ChainInfo {
readonly bridge: string readonly bridge: string
readonly logoUrl: string readonly logoUrl: string
readonly statusPage?: string
} }
type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & { type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & {
@ -64,6 +67,7 @@ type ChainInfo = { readonly [chainId: number]: L1ChainInfo | L2ChainInfo } & {
export const CHAIN_INFO: ChainInfo = { export const CHAIN_INFO: ChainInfo = {
[SupportedChainId.ARBITRUM_ONE]: { [SupportedChainId.ARBITRUM_ONE]: {
blockWaitMsBeforeWarning: ms`10m`,
bridge: 'https://bridge.arbitrum.io/', bridge: 'https://bridge.arbitrum.io/',
docs: 'https://offchainlabs.com/', docs: 'https://offchainlabs.com/',
explorer: 'https://arbiscan.io/', explorer: 'https://arbiscan.io/',
@ -72,6 +76,7 @@ export const CHAIN_INFO: ChainInfo = {
logoUrl: arbitrumLogoUrl, logoUrl: arbitrumLogoUrl,
}, },
[SupportedChainId.ARBITRUM_RINKEBY]: { [SupportedChainId.ARBITRUM_RINKEBY]: {
blockWaitMsBeforeWarning: ms`10m`,
bridge: 'https://bridge.arbitrum.io/', bridge: 'https://bridge.arbitrum.io/',
docs: 'https://offchainlabs.com/', docs: 'https://offchainlabs.com/',
explorer: 'https://rinkeby-explorer.arbitrum.io/', explorer: 'https://rinkeby-explorer.arbitrum.io/',
@ -110,19 +115,23 @@ export const CHAIN_INFO: ChainInfo = {
label: 'Görli', label: 'Görli',
}, },
[SupportedChainId.OPTIMISM]: { [SupportedChainId.OPTIMISM]: {
blockWaitMsBeforeWarning: ms`10m`,
bridge: 'https://gateway.optimism.io/', bridge: 'https://gateway.optimism.io/',
docs: 'https://optimism.io/', docs: 'https://optimism.io/',
explorer: 'https://optimistic.etherscan.io/', explorer: 'https://optimistic.etherscan.io/',
infoLink: 'https://info.uniswap.org/#/optimism/', infoLink: 'https://info.uniswap.org/#/optimism/',
label: 'Optimism', label: 'Optimism',
logoUrl: optimismLogoUrl, logoUrl: optimismLogoUrl,
statusPage: 'https://optimism.io/status',
}, },
[SupportedChainId.OPTIMISTIC_KOVAN]: { [SupportedChainId.OPTIMISTIC_KOVAN]: {
blockWaitMsBeforeWarning: ms`10m`,
bridge: 'https://gateway.optimism.io/', bridge: 'https://gateway.optimism.io/',
docs: 'https://optimism.io/', docs: 'https://optimism.io/',
explorer: 'https://optimistic.etherscan.io/', explorer: 'https://optimistic.etherscan.io/',
infoLink: 'https://info.uniswap.org/#/optimism', infoLink: 'https://info.uniswap.org/#/optimism',
label: 'Optimistic Kovan', label: 'Optimistic Kovan',
logoUrl: optimismLogoUrl, logoUrl: optimismLogoUrl,
statusPage: 'https://optimism.io/status',
}, },
} }

@ -27,3 +27,4 @@ export const setOpenModal = createAction<ApplicationModal | null>('application/s
export const addPopup = export const addPopup =
createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('application/addPopup') createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('application/addPopup')
export const removePopup = createAction<{ key: string }>('application/removePopup') export const removePopup = createAction<{ key: string }>('application/removePopup')
export const setChainConnectivityWarning = createAction<{ warn: boolean }>('application/setChainConnectivityWarning')

@ -8,6 +8,7 @@ import {
ApplicationModal, ApplicationModal,
setOpenModal, setOpenModal,
updateChainId, updateChainId,
setChainConnectivityWarning,
} from './actions' } from './actions'
type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }> type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }>
@ -15,6 +16,7 @@ type PopupList = Array<{ key: string; show: boolean; content: PopupContent; remo
export interface ApplicationState { export interface ApplicationState {
// used by RTK-Query to build dynamic subgraph urls // used by RTK-Query to build dynamic subgraph urls
readonly chainId: number | null readonly chainId: number | null
readonly chainConnectivityWarning: boolean
readonly blockNumber: { readonly [chainId: number]: number } readonly blockNumber: { readonly [chainId: number]: number }
readonly popupList: PopupList readonly popupList: PopupList
readonly openModal: ApplicationModal | null readonly openModal: ApplicationModal | null
@ -22,6 +24,7 @@ export interface ApplicationState {
const initialState: ApplicationState = { const initialState: ApplicationState = {
chainId: null, chainId: null,
chainConnectivityWarning: false,
blockNumber: {}, blockNumber: {},
popupList: [], popupList: [],
openModal: null, openModal: null,
@ -61,4 +64,7 @@ export default createReducer(initialState, (builder) =>
} }
}) })
}) })
.addCase(setChainConnectivityWarning, (state, { payload: { warn } }) => {
state.chainConnectivityWarning = warn
})
) )

@ -1,11 +1,14 @@
import { useCallback, useEffect, useState } from 'react' import { CHAIN_INFO } from 'constants/chains'
import useDebounce from 'hooks/useDebounce'
import useIsWindowVisible from 'hooks/useIsWindowVisible'
import { useActiveWeb3React } from 'hooks/web3'
import ms from 'ms.macro'
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CHAIN_TAG } from 'state/data/enhanced' import { api, CHAIN_TAG } from 'state/data/enhanced'
import { useAppDispatch, useAppSelector } from 'state/hooks' import { useAppDispatch, useAppSelector } from 'state/hooks'
import { supportedChainId } from 'utils/supportedChainId' import { supportedChainId } from 'utils/supportedChainId'
import useDebounce from '../../hooks/useDebounce' import { setChainConnectivityWarning, updateBlockNumber, updateChainId } from './actions'
import useIsWindowVisible from '../../hooks/useIsWindowVisible' import { useBlockNumber } from './hooks'
import { useActiveWeb3React } from '../../hooks/web3'
import { updateBlockNumber, updateChainId } from './actions'
function useQueryCacheInvalidator() { function useQueryCacheInvalidator() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -20,10 +23,46 @@ function useQueryCacheInvalidator() {
}, [chainId, dispatch]) }, [chainId, dispatch])
} }
const NETWORK_HEALTH_CHECK_MS = ms`15s`
const DEFAULT_MS_BEFORE_WARNING = ms`10m`
function useBlockWarningTimer() {
const { chainId } = useActiveWeb3React()
const dispatch = useAppDispatch()
const chainConnectivityWarningActive = useAppSelector((state) => state.application.chainConnectivityWarning)
const timeout = useRef<NodeJS.Timeout>()
const isWindowVisible = useIsWindowVisible()
const [msSinceLastBlock, setMsSinceLastBlock] = useState(0)
const currentBlock = useBlockNumber()
useEffect(() => {
setMsSinceLastBlock(0)
}, [currentBlock])
useEffect(() => {
const waitMsBeforeWarning =
(chainId ? CHAIN_INFO[chainId]?.blockWaitMsBeforeWarning : DEFAULT_MS_BEFORE_WARNING) ?? DEFAULT_MS_BEFORE_WARNING
timeout.current = setTimeout(() => {
setMsSinceLastBlock(NETWORK_HEALTH_CHECK_MS + msSinceLastBlock)
if (msSinceLastBlock > waitMsBeforeWarning && isWindowVisible) {
dispatch(setChainConnectivityWarning({ warn: true }))
} else if (chainConnectivityWarningActive) {
dispatch(setChainConnectivityWarning({ warn: false }))
}
}, NETWORK_HEALTH_CHECK_MS)
return function cleanup() {
if (timeout.current) {
clearTimeout(timeout.current)
}
}
}, [chainId, chainConnectivityWarningActive, dispatch, isWindowVisible, msSinceLastBlock, setMsSinceLastBlock])
}
export default function Updater(): null { export default function Updater(): null {
const { library, chainId } = useActiveWeb3React() const { library, chainId } = useActiveWeb3React()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const windowVisible = useIsWindowVisible() const windowVisible = useIsWindowVisible()
const [state, setState] = useState<{ chainId: number | undefined; blockNumber: number | null }>({ const [state, setState] = useState<{ chainId: number | undefined; blockNumber: number | null }>({
@ -31,6 +70,7 @@ export default function Updater(): null {
blockNumber: null, blockNumber: null,
}) })
useBlockWarningTimer()
useQueryCacheInvalidator() useQueryCacheInvalidator()
const blockNumberCallback = useCallback( const blockNumberCallback = useCallback(