Skip to content

Commit

Permalink
feat: unsupported chain disconnection feedback (#2664)
Browse files Browse the repository at this point in the history
* feat: unsupported chain disconnection feedback

* refactor: create useDeferredListener hook
  • Loading branch information
iamacook authored Oct 20, 2023
1 parent 56bf7d8 commit e77fa3f
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 69 deletions.
48 changes: 48 additions & 0 deletions src/components/walletconnect/ConnectionBanner/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SvgIcon, Typography } from '@mui/material'
import classNames from 'classnames'
import type { ReactElement } from 'react'
import type { CoreTypes } from '@walletconnect/types'

import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
import SafeLogo from '@/public/images/logo-no-text.svg'
import ConnectionDots from '@/public/images/common/connection-dots.svg'
import { useCurrentChain } from '@/hooks/useChains'

import css from './styles.module.css'

export const ConnectionBanner = ({
metadata,
isDelete = false,
}: {
metadata?: CoreTypes.Metadata
isDelete?: boolean
}): ReactElement | null => {
const chain = useCurrentChain()

if (!metadata) {
return null
}

const name = metadata.name || 'dApp'
const icon = metadata.icons[0] || ''

return (
<div className={css.container}>
<div>
<SafeLogo alt="Safe logo" width="28px" height="28px" />
<SvgIcon
component={ConnectionDots}
inheritViewBox
sx={{ mx: 2 }}
className={classNames({ [css.errorDots]: isDelete })}
/>
<SafeAppIconCard src={icon} width={28} height={28} alt={`${name} logo`} />
</div>
<Typography variant="h5" mt={3}>
{isDelete
? `${name} was disconnected as it does not support ${chain?.chainName || 'this network'}.`
: `${name} successfully connected!`}
</Typography>
</div>
)
}
21 changes: 21 additions & 0 deletions src/components/walletconnect/ConnectionBanner/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-top: var(--space-2);
}

.errorDots circle:first-of-type,
.errorDots circle:last-of-type {
fill: var(--color-error-dark);
}

.errorDots circle:nth-of-type(2),
.errorDots circle:nth-of-type(5) {
fill: var(--color-error-main);
}
.errorDots circle:nth-of-type(3),
.errorDots circle:nth-of-type(4) {
fill: var(--color-error-light);
}
72 changes: 41 additions & 31 deletions src/components/walletconnect/HeaderWidget/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { CoreTypes, SessionTypes } from '@walletconnect/types'
import { useCallback, useContext, useEffect, useRef } from 'react'
import type { ReactElement } from 'react'

import { WalletConnectContext } from '@/services/walletconnect/WalletConnectContext'
Expand All @@ -9,7 +8,10 @@ import { useWalletConnectSearchParamUri } from '@/services/walletconnect/useWall
import Icon from './Icon'
import SessionManager from '../SessionManager'
import Popup from '../Popup'
import { SuccessBanner } from '../SuccessBanner'
import { ConnectionBanner } from '../ConnectionBanner'
import useSafeInfo from '@/hooks/useSafeInfo'
import { isUnsupportedChain } from '@/services/walletconnect/utils'
import { useDeferredListener } from '@/hooks/useDefferedListener'

const usePrepopulatedUri = (): [string, () => void] => {
const [searchParamWcUri, setSearchParamWcUri] = useWalletConnectSearchParamUri()
Expand All @@ -25,12 +27,33 @@ const usePrepopulatedUri = (): [string, () => void] => {
return [uri, clearUri]
}

const BANNER_TIMEOUT = 2_000

const useSuccessSession = (onCloseSessionManager: () => void) => {
const { walletConnect } = useContext(WalletConnectContext)

return useDeferredListener({
listener: walletConnect?.onSessionAdd,
cb: onCloseSessionManager,
ms: BANNER_TIMEOUT,
})
}

const useDeleteSession = () => {
const { walletConnect } = useContext(WalletConnectContext)

return useDeferredListener({
listener: walletConnect?.onSessionDelete,
ms: BANNER_TIMEOUT * 2,
})
}

const WalletConnectHeaderWidget = (): ReactElement => {
const { walletConnect, setError, open, setOpen } = useContext(WalletConnectContext)
const { safe } = useSafeInfo()
const iconRef = useRef<HTMLDivElement>(null)
const sessions = useWalletConnectSessions()
const [uri, clearUri] = usePrepopulatedUri()
const [metadata, setMetadata] = useState<CoreTypes.Metadata>()

const onOpenSessionManager = useCallback(() => setOpen(true), [setOpen])

Expand All @@ -40,36 +63,23 @@ const WalletConnectHeaderWidget = (): ReactElement => {
setError(null)
}, [setOpen, clearUri, setError])

const onCloseSuccesBanner = useCallback(() => setMetadata(undefined), [])
const [successSession, setSuccessSession] = useSuccessSession(onCloseSessionManager)
const [deleteSession, setDeleteSession] = useDeleteSession()

const onSuccess = useCallback(
({ peer }: SessionTypes.Struct) => {
onCloseSessionManager()
const session = successSession || deleteSession
const metadata = session?.peer?.metadata
const isUnsupported = deleteSession ? isUnsupportedChain(deleteSession, safe.chainId) : false

// Show success banner
setMetadata(peer.metadata)

setTimeout(() => {
onCloseSuccesBanner()
}, 2_000)
},
[onCloseSessionManager, onCloseSuccesBanner],
)
const onCloseConnectionBanner = useCallback(() => {
setSuccessSession(undefined)
setDeleteSession(undefined)
}, [setSuccessSession, setDeleteSession])

// Clear search param/clipboard state to prevent it being automatically entered again
useEffect(() => {
if (!walletConnect) {
return
if (walletConnect) {
return walletConnect.onSessionReject(clearUri)
}

return walletConnect.onSessionAdd(onSuccess)
}, [onSuccess, walletConnect])

useEffect(() => {
if (!walletConnect) {
return
}

return walletConnect.onSessionReject(clearUri)
}, [clearUri, walletConnect])

// Open the popup when a prepopulated uri is found
Expand All @@ -87,8 +97,8 @@ const WalletConnectHeaderWidget = (): ReactElement => {
<SessionManager sessions={sessions} uri={uri} />
</Popup>

<Popup anchorEl={iconRef.current} open={!!metadata} onClose={onCloseSuccesBanner}>
{metadata && <SuccessBanner metadata={metadata} />}
<Popup anchorEl={iconRef.current} open={!!metadata} onClose={onCloseConnectionBanner}>
<ConnectionBanner metadata={metadata} isDelete={isUnsupported} />
</Popup>
</>
)
Expand Down
24 changes: 0 additions & 24 deletions src/components/walletconnect/SuccessBanner/index.tsx

This file was deleted.

7 changes: 0 additions & 7 deletions src/components/walletconnect/SuccessBanner/styles.module.css

This file was deleted.

36 changes: 36 additions & 0 deletions src/hooks/useDefferedListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react'
import type { Dispatch, SetStateAction } from 'react'

export const useDeferredListener = <T>({
listener,
cb,
ms,
}: {
listener?: (handler: (e: T) => void) => () => void
cb?: () => void
ms: number
}): [T | undefined, Dispatch<SetStateAction<T | undefined>>] => {
const [value, setValue] = useState<T>()

useEffect(() => {
if (!listener) {
return
}

const unsubscribe = listener((newValue) => {
setValue(newValue)
cb?.()
})

const timeout = setTimeout(() => {
setValue(undefined)
}, ms)

return () => {
unsubscribe()
clearTimeout(timeout)
}
}, [cb, listener, ms])

return [value, setValue]
}
6 changes: 4 additions & 2 deletions src/services/walletconnect/WalletConnectWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ class WalletConnectWallet {
/**
* Subscribe to session add
*/
public onSessionAdd(handler: (e: SessionTypes.Struct) => void) {
public onSessionAdd = (handler: (e: SessionTypes.Struct) => void) => {
// @ts-expect-error - custom event payload
this.web3Wallet?.on(SESSION_ADD_EVENT, handler)

Expand All @@ -234,10 +234,12 @@ class WalletConnectWallet {
/**
* Subscribe to session delete
*/
public onSessionDelete(handler: () => void) {
public onSessionDelete = (handler: (session: SessionTypes.Struct) => void) => {
// @ts-expect-error - custom event payload
this.web3Wallet?.on('session_delete', handler)

return () => {
// @ts-expect-error
this.web3Wallet?.off('session_delete', handler)
}
}
Expand Down
27 changes: 22 additions & 5 deletions src/services/walletconnect/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChainInfo } from '@safe-global/safe-apps-sdk'
import type { ProposalTypes } from '@walletconnect/types'
import type { ProposalTypes, SessionTypes } from '@walletconnect/types'

import { EIP155 } from './constants'

Expand All @@ -15,16 +15,33 @@ export const stripEip155Prefix = (eip155Address: string): string => {
return eip155Address.split(':').pop() ?? ''
}

export const getSupportedChainIds = (configs: Array<ChainInfo>, params: ProposalTypes.Struct): Array<string> => {
const { requiredNamespaces, optionalNamespaces } = params

export const getSupportedEip155ChainIds = (
requiredNamespaces: ProposalTypes.RequiredNamespaces,
optionalNamespaces: ProposalTypes.OptionalNamespaces,
): Array<string> => {
const requiredChains = requiredNamespaces[EIP155]?.chains ?? []
const optionalChains = optionalNamespaces[EIP155]?.chains ?? []

return requiredChains.concat(optionalChains)
}

export const getSupportedChainIds = (
configs: Array<ChainInfo>,
{ requiredNamespaces, optionalNamespaces }: ProposalTypes.Struct,
): Array<string> => {
const supportedEip155ChainIds = getSupportedEip155ChainIds(requiredNamespaces, optionalNamespaces)

return configs
.filter((chain) => {
const eipChainId = getEip155ChainId(chain.chainId)
return requiredChains.includes(eipChainId) || optionalChains.includes(eipChainId)
return supportedEip155ChainIds.includes(eipChainId)
})
.map((chain) => chain.chainId)
}

export const isUnsupportedChain = (session: SessionTypes.Struct, chainId: string) => {
const supportedEip155ChainIds = getSupportedEip155ChainIds(session.requiredNamespaces, session.optionalNamespaces)

const eipChainId = getEip155ChainId(chainId)
return !supportedEip155ChainIds.includes(eipChainId)
}

0 comments on commit e77fa3f

Please sign in to comment.