From e77fa3f354bfdd623f2920da62862d072076d613 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 20 Oct 2023 14:45:24 +0200 Subject: [PATCH] feat: unsupported chain disconnection feedback (#2664) * feat: unsupported chain disconnection feedback * refactor: create useDeferredListener hook --- .../walletconnect/ConnectionBanner/index.tsx | 48 +++++++++++++ .../ConnectionBanner/styles.module.css | 21 ++++++ .../walletconnect/HeaderWidget/index.tsx | 72 +++++++++++-------- .../walletconnect/SuccessBanner/index.tsx | 24 ------- .../SuccessBanner/styles.module.css | 7 -- src/hooks/useDefferedListener.ts | 36 ++++++++++ .../walletconnect/WalletConnectWallet.ts | 6 +- src/services/walletconnect/utils.ts | 27 +++++-- 8 files changed, 172 insertions(+), 69 deletions(-) create mode 100644 src/components/walletconnect/ConnectionBanner/index.tsx create mode 100644 src/components/walletconnect/ConnectionBanner/styles.module.css delete mode 100644 src/components/walletconnect/SuccessBanner/index.tsx delete mode 100644 src/components/walletconnect/SuccessBanner/styles.module.css create mode 100644 src/hooks/useDefferedListener.ts diff --git a/src/components/walletconnect/ConnectionBanner/index.tsx b/src/components/walletconnect/ConnectionBanner/index.tsx new file mode 100644 index 0000000000..101c356e4d --- /dev/null +++ b/src/components/walletconnect/ConnectionBanner/index.tsx @@ -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 ( +
+
+ + + +
+ + {isDelete + ? `${name} was disconnected as it does not support ${chain?.chainName || 'this network'}.` + : `${name} successfully connected!`} + +
+ ) +} diff --git a/src/components/walletconnect/ConnectionBanner/styles.module.css b/src/components/walletconnect/ConnectionBanner/styles.module.css new file mode 100644 index 0000000000..48ecf3ba5e --- /dev/null +++ b/src/components/walletconnect/ConnectionBanner/styles.module.css @@ -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); +} diff --git a/src/components/walletconnect/HeaderWidget/index.tsx b/src/components/walletconnect/HeaderWidget/index.tsx index f7590a4252..1158b03e78 100644 --- a/src/components/walletconnect/HeaderWidget/index.tsx +++ b/src/components/walletconnect/HeaderWidget/index.tsx @@ -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' @@ -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() @@ -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(null) const sessions = useWalletConnectSessions() const [uri, clearUri] = usePrepopulatedUri() - const [metadata, setMetadata] = useState() const onOpenSessionManager = useCallback(() => setOpen(true), [setOpen]) @@ -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 @@ -87,8 +97,8 @@ const WalletConnectHeaderWidget = (): ReactElement => { - - {metadata && } + + ) diff --git a/src/components/walletconnect/SuccessBanner/index.tsx b/src/components/walletconnect/SuccessBanner/index.tsx deleted file mode 100644 index 1953545925..0000000000 --- a/src/components/walletconnect/SuccessBanner/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { SvgIcon, Typography } from '@mui/material' -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 css from './styles.module.css' - -export const SuccessBanner = ({ metadata }: { metadata: CoreTypes.Metadata }): ReactElement => { - return ( -
-
- - - -
- - {metadata.name} successfully connected! - -
- ) -} diff --git a/src/components/walletconnect/SuccessBanner/styles.module.css b/src/components/walletconnect/SuccessBanner/styles.module.css deleted file mode 100644 index 4fc7171268..0000000000 --- a/src/components/walletconnect/SuccessBanner/styles.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.container { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding-top: var(--space-2); -} diff --git a/src/hooks/useDefferedListener.ts b/src/hooks/useDefferedListener.ts new file mode 100644 index 0000000000..388bf99821 --- /dev/null +++ b/src/hooks/useDefferedListener.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react' +import type { Dispatch, SetStateAction } from 'react' + +export const useDeferredListener = ({ + listener, + cb, + ms, +}: { + listener?: (handler: (e: T) => void) => () => void + cb?: () => void + ms: number +}): [T | undefined, Dispatch>] => { + const [value, setValue] = useState() + + 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] +} diff --git a/src/services/walletconnect/WalletConnectWallet.ts b/src/services/walletconnect/WalletConnectWallet.ts index 41a9a25138..9bcf0b18f3 100644 --- a/src/services/walletconnect/WalletConnectWallet.ts +++ b/src/services/walletconnect/WalletConnectWallet.ts @@ -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) @@ -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) } } diff --git a/src/services/walletconnect/utils.ts b/src/services/walletconnect/utils.ts index 681b8b78a8..c7e825eec7 100644 --- a/src/services/walletconnect/utils.ts +++ b/src/services/walletconnect/utils.ts @@ -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' @@ -15,16 +15,33 @@ export const stripEip155Prefix = (eip155Address: string): string => { return eip155Address.split(':').pop() ?? '' } -export const getSupportedChainIds = (configs: Array, params: ProposalTypes.Struct): Array => { - const { requiredNamespaces, optionalNamespaces } = params - +export const getSupportedEip155ChainIds = ( + requiredNamespaces: ProposalTypes.RequiredNamespaces, + optionalNamespaces: ProposalTypes.OptionalNamespaces, +): Array => { const requiredChains = requiredNamespaces[EIP155]?.chains ?? [] const optionalChains = optionalNamespaces[EIP155]?.chains ?? [] + return requiredChains.concat(optionalChains) +} + +export const getSupportedChainIds = ( + configs: Array, + { requiredNamespaces, optionalNamespaces }: ProposalTypes.Struct, +): Array => { + 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) +}