From 1a0691dded1daba5b169da7b0800ebb3260ff209 Mon Sep 17 00:00:00 2001 From: dominhquang Date: Fri, 21 Jul 2023 14:31:21 +0700 Subject: [PATCH] [Issue-281]: Implement Wallet Connect --- src/AppNavigator.tsx | 40 ++- src/assets/index.ts | 2 + src/assets/wallet-connect.svg | 3 + src/components/Header.tsx | 19 +- src/components/Input/InputConnectUrl.tsx | 126 +++++++++ src/components/Scanner/AddressScanner.tsx | 8 +- src/components/Scanner/QrAddressScanner.tsx | 10 +- src/components/Scanner/SignatureScanner.tsx | 8 +- .../WalletConnect/Account/WCAccountInput.tsx | 35 +++ .../WalletConnect/Account/WCAccountSelect.tsx | 124 +++++++++ .../WalletConnect/ConnectionItem.tsx | 81 ++++++ .../Network/WCNetworkAvatarGroup.tsx | 105 ++++++++ .../WalletConnect/Network/WCNetworkInput.tsx | 42 +++ .../WalletConnect/Network/WCNetworkItem.tsx | 67 +++++ .../Network/WCNetworkSelected.tsx | 76 ++++++ .../Network/WCNetworkSupported.tsx | 50 ++++ .../ConfirmationGeneralInfo/index.tsx | 6 +- .../common/Modal/DeleteModal/index.tsx | 22 +- .../common/Modal/UnlockModal/index.tsx | 1 + .../common/SelectModal/BasicSelectModal.tsx | 6 + .../background-icon/index.tsx | 22 +- .../design-system-ui/page-icon/index.tsx | 7 +- src/constants/index.ts | 1 + .../useSelectWalletConnectAccount.ts | 252 +++++++++++++++++ src/messaging/index.ts | 21 ++ src/providers/DataContext.tsx | 13 + src/routes/index.ts | 4 + src/screens/Confirmations/index.tsx | 5 + .../index.tsx | 254 ++++++++++++++++++ .../styles/index.ts | 20 ++ src/screens/Home/index.tsx | 2 +- .../WalletConnect/ConnectWalletConnect.tsx | 116 ++++++++ .../WalletConnect/ConnectionDetail.tsx | 186 +++++++++++++ .../Settings/WalletConnect/ConnectionList.tsx | 79 ++++++ src/screens/Settings/index.tsx | 18 +- src/screens/Signing/SigningScanPayload.tsx | 8 +- src/stores/base/RequestState.ts | 8 + src/stores/feature/WalletConnect.ts | 26 ++ src/stores/index.ts | 2 + src/stores/types.ts | 7 + src/stores/utils/index.ts | 34 +++ src/styles/scanner.ts | 4 +- src/types/walletConnect.ts | 7 + src/utils/browser.ts | 33 --- src/utils/deeplink/index.ts | 51 ++++ src/utils/index.tsx | 23 +- src/utils/scanner/walletConnect.ts | 24 ++ src/utils/walletConnect/index.ts | 106 ++++++++ 48 files changed, 2094 insertions(+), 70 deletions(-) create mode 100644 src/assets/wallet-connect.svg create mode 100644 src/components/Input/InputConnectUrl.tsx create mode 100644 src/components/WalletConnect/Account/WCAccountInput.tsx create mode 100644 src/components/WalletConnect/Account/WCAccountSelect.tsx create mode 100644 src/components/WalletConnect/ConnectionItem.tsx create mode 100644 src/components/WalletConnect/Network/WCNetworkAvatarGroup.tsx create mode 100644 src/components/WalletConnect/Network/WCNetworkInput.tsx create mode 100644 src/components/WalletConnect/Network/WCNetworkItem.tsx create mode 100644 src/components/WalletConnect/Network/WCNetworkSelected.tsx create mode 100644 src/components/WalletConnect/Network/WCNetworkSupported.tsx create mode 100644 src/hooks/wallet-connect/useSelectWalletConnectAccount.ts create mode 100644 src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/index.tsx create mode 100644 src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/styles/index.ts create mode 100644 src/screens/Settings/WalletConnect/ConnectWalletConnect.tsx create mode 100644 src/screens/Settings/WalletConnect/ConnectionDetail.tsx create mode 100644 src/screens/Settings/WalletConnect/ConnectionList.tsx create mode 100644 src/stores/feature/WalletConnect.ts create mode 100644 src/types/walletConnect.ts create mode 100644 src/utils/deeplink/index.ts create mode 100644 src/utils/scanner/walletConnect.ts create mode 100644 src/utils/walletConnect/index.ts diff --git a/src/AppNavigator.tsx b/src/AppNavigator.tsx index a5e28d652..924b61594 100644 --- a/src/AppNavigator.tsx +++ b/src/AppNavigator.tsx @@ -38,7 +38,7 @@ import { LoadingScreen } from 'screens/LoadingScreen'; import { RootRouteProps, RootStackParamList } from './routes'; import { THEME_PRESET } from 'styles/themes'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { deeplinks, getValidURL } from 'utils/browser'; +import { deeplinks, getProtocol, getValidURL } from 'utils/browser'; import ErrorBoundary from 'react-native-error-boundary'; import ApplyMasterPassword from 'screens/MasterPassword/ApplyMasterPassword'; import { NetworkSettingDetail } from 'screens/NetworkSettingDetail'; @@ -55,7 +55,7 @@ import { AddProvider } from 'screens/AddProvider'; import TransactionScreen from 'screens/Transaction/TransactionScreen'; import SendNFT from 'screens/Transaction/NFT'; import changeNavigationBarColor from 'react-native-navigation-bar-color'; -import { Platform } from 'react-native'; +import { Linking, Platform } from 'react-native'; import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; import { Home } from 'screens/Home'; import { deviceWidth } from 'constants/index'; @@ -65,6 +65,13 @@ import { WrapperParamList } from 'routes/wrapper'; import { ManageAddressBook } from 'screens/Settings/AddressBook'; import { BuyToken } from 'screens/Home/Crypto/BuyToken'; import useCheckEmptyAccounts from 'hooks/useCheckEmptyAccounts'; +import { ConnectionList } from 'screens/Settings/WalletConnect/ConnectionList'; +import { ConnectWalletConnect } from 'screens/Settings/WalletConnect/ConnectWalletConnect'; +import { ConnectionDetail } from 'screens/Settings/WalletConnect/ConnectionDetail'; +import urlParse from 'url-parse'; +import queryString from 'querystring'; +import { connectWalletConnect } from 'utils/walletConnect'; +import { useToast } from 'react-native-toast-notifications'; interface Props { isAppReady: boolean; @@ -121,6 +128,10 @@ const HistoryScreen = (props: JSX.IntrinsicAttributes) => { return withPageWrapper(History as ComponentType, ['transactionHistory'])(props); }; +const ConnectionListScreen = (props: JSX.IntrinsicAttributes) => { + return withPageWrapper(ConnectionList as ComponentType, ['walletConnect'])(props); +}; + const AppNavigator = ({ isAppReady }: Props) => { const isDarkMode = true; const theme = isDarkMode ? THEME_PRESET.dark : THEME_PRESET.light; @@ -131,6 +142,7 @@ const AppNavigator = ({ isAppReady }: Props) => { const isEmptyAccounts = useCheckEmptyAccounts(); const { hasConfirmations } = useSelector((state: RootState) => state.requestState); const { accounts, hasMasterPassword } = useSelector((state: RootState) => state.accountState); + const toast = useToast(); const needMigrate = useMemo( () => @@ -188,6 +200,27 @@ const AppNavigator = ({ isAppReady }: Props) => { } }, [isEmptyAccounts, navigationRef]); + useEffect(() => { + Linking.addEventListener('url', ({ url }) => { + const urlParsed = new urlParse(url); + if (getProtocol(url) === 'subwallet') { + if (urlParsed.hostname === 'wc') { + const decodedWcUrl = queryString.decode(urlParsed.query.slice(5)); + const finalWcUrl = Object.keys(decodedWcUrl)[0]; + connectWalletConnect(finalWcUrl, toast); + } + } else if (getProtocol(url) === 'https') { + if (urlParsed.pathname.split('/')[1] === 'wc') { + const decodedWcUrl = queryString.decode(urlParsed.query.slice(5)); + const finalWcUrl = Object.keys(decodedWcUrl)[0]; + connectWalletConnect(finalWcUrl, toast); + } + } + }); + + return () => Linking.removeAllListeners('url'); + }, [toast]); + return ( @@ -222,6 +255,9 @@ const AppNavigator = ({ isAppReady }: Props) => { + + + import('./subwallet-logo.svg')); const LogoGradient = React.lazy(() => import('./subwallet-logo-gradient.svg')); const MenuBarLogo = React.lazy(() => import('./menu-bar.svg')); const IcHalfSquare = React.lazy(() => import('./ic-half-square.svg')); +const WalletConnect = React.lazy(() => import('./wallet-connect.svg')); export const SVGImages = { Logo, @@ -21,6 +22,7 @@ export const SVGImages = { SignalSplashIcon, MenuBarLogo, IcHalfSquare, + WalletConnect, }; export const Images = { diff --git a/src/assets/wallet-connect.svg b/src/assets/wallet-connect.svg new file mode 100644 index 000000000..42cf49cbe --- /dev/null +++ b/src/assets/wallet-connect.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e08825160..905cd2815 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,6 +15,8 @@ import { isAddress } from '@polkadot/util-crypto'; import i18n from 'utils/i18n/i18n'; import { DrawerNavigationProp } from '@react-navigation/drawer'; import { DisabledStyle } from 'styles/sharedStyles'; +import { validWalletConnectUri } from 'utils/scanner/walletConnect'; +import { addConnection } from 'messaging/index'; export interface HeaderProps { rightComponent?: JSX.Element; @@ -47,6 +49,8 @@ export const Header = ({ rightComponent, disabled }: HeaderProps) => { const onScanAddress = useCallback( (data: string) => { + const _error = validWalletConnectUri(data); + console.log('error', _error); if (isAddress(data)) { setError(undefined); setIsScanning(false); @@ -55,8 +59,21 @@ export const Header = ({ rightComponent, disabled }: HeaderProps) => { screen: 'TransactionAction', params: { screen: 'SendFund', params: { recipient: data } }, }); + } else if (!validWalletConnectUri(data)) { + addConnection({ uri: data }) + .then(() => { + setError(undefined); + setIsScanning(false); + }) + .catch(e => { + const errMessage = (e as Error).message; + const message = errMessage.includes('Pairing already exists') + ? i18n.errorMessage.connectionAlreadyExist + : i18n.errorMessage.failToAddConnection; + setError(message); + }); } else { - setError(i18n.errorMessage.isNotAnAddress); + setError(i18n.errorMessage.unreadableQrCode); } }, [navigation], diff --git a/src/components/Input/InputConnectUrl.tsx b/src/components/Input/InputConnectUrl.tsx new file mode 100644 index 000000000..1b634d6c9 --- /dev/null +++ b/src/components/Input/InputConnectUrl.tsx @@ -0,0 +1,126 @@ +import Input, { InputProps } from 'components/design-system-ui/input'; +import React, { ForwardedRef, forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { TextInput } from 'react-native'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { Button, Icon } from 'components/design-system-ui'; +import { Scan } from 'phosphor-react-native'; +import { NativeSyntheticEvent } from 'react-native/Libraries/Types/CoreEventTypes'; +import { TextInputFocusEventData } from 'react-native/Libraries/Components/TextInput/TextInput'; +import { AddressScanner, AddressScannerProps } from 'components/Scanner/AddressScanner'; +import { requestCameraPermission } from 'utils/permission/camera'; +import { RESULTS } from 'react-native-permissions'; +import { setAdjustResize } from 'rn-android-keyboard-adjust'; + +interface Props extends InputProps { + isValidValue?: boolean; + showAvatar?: boolean; + scannerProps?: Omit; +} + +const Component = ( + { isValidValue, scannerProps = {}, value = '', ...inputProps }: Props, + ref: ForwardedRef, +) => { + const theme = useSubWalletTheme().swThemes; + const [isShowQrModalVisible, setIsShowQrModalVisible] = useState(false); + const isAddressValid = isValidValue !== undefined ? isValidValue : true; + const [error, setError] = useState(undefined); + + useEffect(() => setAdjustResize(), []); + + const onPressQrButton = useCallback(async () => { + const result = await requestCameraPermission(); + + if (result === RESULTS.GRANTED) { + setIsShowQrModalVisible(true); + } + }, []); + + const RightPart = useMemo(() => { + return ( + <> + + + ) : ( + + {availableAccounts.length > 1 && ( + + )} + {availableAccounts.map(item => { + const selected = !!selectedAccounts.find(address => isSameAddress(address, item.address)); + + return ( + + ); + })} + + )} + + ); +}; diff --git a/src/components/WalletConnect/ConnectionItem.tsx b/src/components/WalletConnect/ConnectionItem.tsx new file mode 100644 index 000000000..98112576e --- /dev/null +++ b/src/components/WalletConnect/ConnectionItem.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import { Icon, Image, Typography, Web3Block } from 'components/design-system-ui'; +import { SessionTypes } from '@walletconnect/types'; +import { stripUrl } from '@subwallet/extension-base/utils'; +import { TouchableOpacity, View } from 'react-native'; +import { DAppIconMap } from '../../predefined/dAppSites'; +import { AbstractAddressJson } from '@subwallet/extension-base/background/types'; +import { getWCAccountList } from 'utils/walletConnect'; +import { useSelector } from 'react-redux'; +import { RootState } from 'stores/index'; +import { CaretRight } from 'phosphor-react-native'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { FontMedium, FontSemiBold } from 'styles/sharedStyles'; +import { BUTTON_ACTIVE_OPACITY } from 'constants/index'; + +interface Props { + session: SessionTypes.Struct; + onPress: (topic: string) => void; +} + +export const ConnectionItem = ({ session, onPress }: Props) => { + const theme = useSubWalletTheme().swThemes; + const { accounts } = useSelector((state: RootState) => state.accountState); + const { + namespaces, + peer: { metadata: dAppInfo }, + topic, + } = session; + + const accountItems = useMemo( + (): AbstractAddressJson[] => getWCAccountList(accounts, namespaces), + [accounts, namespaces], + ); + const currentDomain = useMemo(() => { + try { + return stripUrl(dAppInfo.url); + } catch (e) { + return dAppInfo.url; + } + }, [dAppInfo.url]); + + function getImageSource(domain: string): string { + if (DAppIconMap[domain]) { + return DAppIconMap[domain]; + } + + return `https://icons.duckduckgo.com/ip2/${domain}.ico`; + } + + return ( + onPress(topic)} activeOpacity={BUTTON_ACTIVE_OPACITY}> + } + middleItem={ + + + {dAppInfo.name} + + + + {currentDomain} + + + {accountItems.length} + + + + } + rightItem={} + /> + + ); +}; diff --git a/src/components/WalletConnect/Network/WCNetworkAvatarGroup.tsx b/src/components/WalletConnect/Network/WCNetworkAvatarGroup.tsx new file mode 100644 index 000000000..8f869945e --- /dev/null +++ b/src/components/WalletConnect/Network/WCNetworkAvatarGroup.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useMemo } from 'react'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { Logo, Typography } from 'components/design-system-ui'; +import { ThemeTypes } from 'styles/themes'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { FontBold } from 'styles/sharedStyles'; + +interface Props { + networks: WalletConnectChainInfo[]; +} + +const sizeLogo = { + default: 20, + large: 24, +}; + +export const WCNetworkAvatarGroup = ({ networks }: Props) => { + const theme = useSubWalletTheme().swThemes; + const showCount: number = useMemo((): number => { + return networks.length > 2 ? 3 : 2; + }, [networks]); + + const avatarSize: number = useMemo((): number => { + return showCount === 3 ? sizeLogo.default : sizeLogo.large; + }, [showCount]); + + const _style = createStyle(theme, avatarSize); + + const countMore: number = useMemo((): number => { + return networks.length - 3; + }, [networks]); + + const getAvatarStyle = useCallback( + (index: number) => { + let avatarStyles: StyleProp = [_style.avatarContent]; + + if (index === 0) { + avatarStyles.push({ marginLeft: 0, opacity: 0.5 }); + } + + if (index === 2) { + avatarStyles.push({ opacity: 1 }); + } + + if (index === 2 && countMore > 0) { + avatarStyles.push(_style.avatarBlur); + } + + return avatarStyles; + }, + [_style.avatarBlur, _style.avatarContent, countMore], + ); + + return ( + 0 && _style.mlStrong]}> + {networks.slice(0, 3).map((network, index) => { + return ( + + + + ); + })} + {countMore > 0 && +{countMore}} + + ); +}; + +function createStyle(theme: ThemeTypes, avatarSize: number) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + position: 'relative', + alignItems: 'center', + }, + avatarContent: { + marginLeft: -12, + }, + mlStrong: { + marginLeft: -4, + }, + avatarBlur: { + shadowOffset: { width: 2, height: 2 }, + shadowOpacity: 1, + shadowColor: '#000000', + // opacity: 0.5, + }, + text: { + fontSize: theme.fontSizeXS, + lineHeight: theme.fontSizeXS * theme.lineHeightXS, + position: 'absolute', + color: theme.colorTextBase, + ...FontBold, + right: 0, + bottom: 0, + width: avatarSize, + height: avatarSize, + textAlign: 'center', + }, + }); +} diff --git a/src/components/WalletConnect/Network/WCNetworkInput.tsx b/src/components/WalletConnect/Network/WCNetworkInput.tsx new file mode 100644 index 000000000..c82c5f9f9 --- /dev/null +++ b/src/components/WalletConnect/Network/WCNetworkInput.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Icon, Typography, Web3Block } from 'components/design-system-ui'; +import { WCNetworkAvatarGroup } from 'components/WalletConnect/Network/WCNetworkAvatarGroup'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { DotsThree } from 'phosphor-react-native'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { FontMedium } from 'styles/sharedStyles'; + +interface Props { + networks: WalletConnectChainInfo[]; + content: string; + onPress: () => void; +} + +export const WCNetworkInput = ({ networks, content }: Props) => { + const theme = useSubWalletTheme().swThemes; + return ( + } + middleItem={ + + {content} + + } + rightItem={} + /> + ); +}; diff --git a/src/components/WalletConnect/Network/WCNetworkItem.tsx b/src/components/WalletConnect/Network/WCNetworkItem.tsx new file mode 100644 index 000000000..47f569f6b --- /dev/null +++ b/src/components/WalletConnect/Network/WCNetworkItem.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from 'react'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { StyleProp, View } from 'react-native'; +import { getNetworkLogo } from 'utils/index'; +import Text from 'components/Text'; +import { Icon } from 'components/design-system-ui'; +import { WarningCircle } from 'phosphor-react-native'; +import { ColorMap } from 'styles/color'; +import { FontSemiBold } from 'styles/sharedStyles'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; + +interface Props { + item: WalletConnectChainInfo; + selectedValueMap: Record; +} + +const itemArea: StyleProp = { + flexDirection: 'row', + justifyContent: 'space-between', + height: 52, + paddingLeft: 12, + paddingRight: 12, + alignItems: 'center', + backgroundColor: '#1A1A1A', + borderRadius: 8, +}; + +const itemBodyArea: StyleProp = { + flexDirection: 'row', + alignItems: 'center', +}; + +const itemTextStyle: StyleProp = { + paddingLeft: 8, + color: ColorMap.light, + fontSize: 16, + lineHeight: 24, + ...FontSemiBold, +}; + +const logoWrapperStyle: StyleProp = { + backgroundColor: 'transparent', + borderRadius: 28, +}; + +export const WCNetworkItem = ({ item, selectedValueMap }: Props) => { + const theme = useSubWalletTheme().swThemes; + const isSupported = useMemo(() => { + return selectedValueMap[item.slug]; + }, [item.slug, selectedValueMap]); + return ( + + + + {getNetworkLogo(item.slug, 28)} + {item.chainInfo?.name || ''} + + + {!isSupported && ( + + + + )} + + + ); +}; diff --git a/src/components/WalletConnect/Network/WCNetworkSelected.tsx b/src/components/WalletConnect/Network/WCNetworkSelected.tsx new file mode 100644 index 000000000..e7c8253db --- /dev/null +++ b/src/components/WalletConnect/Network/WCNetworkSelected.tsx @@ -0,0 +1,76 @@ +import React, { useMemo, useRef } from 'react'; +import { BasicSelectModal } from 'components/common/SelectModal/BasicSelectModal'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { WCNetworkInput } from 'components/WalletConnect/Network/WCNetworkInput'; +import { ModalRef } from 'types/modalRef'; +import { WCNetworkItem } from 'components/WalletConnect/Network/WCNetworkItem'; +import { Typography } from 'components/design-system-ui'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { FontSemiBold } from 'styles/sharedStyles'; +import i18n from 'utils/i18n/i18n'; + +interface Props { + networks: WalletConnectChainInfo[]; +} + +export const WCNetworkSelected = ({ networks }: Props) => { + const modalRef = useRef(); + const theme = useSubWalletTheme().swThemes; + const connectedNetworks = useMemo(() => networks.filter(network => network.supported), [networks]); + const connectedNetworksMap = useMemo(() => { + return connectedNetworks.reduce((o, key) => Object.assign(o, { [key.slug]: key.supported }), {}); + }, [connectedNetworks]); + + const showNetworks = useMemo((): WalletConnectChainInfo[] => { + const _connectedNetworks = networks.filter(network => network.supported); + const unSupportNetworks = networks.filter(network => !network.supported); + + const unSupportNetwork: WalletConnectChainInfo | null = unSupportNetworks.length + ? { + supported: false, + chainInfo: { + slug: '', + name: `${unSupportNetworks.length} unknown network`, + }, + slug: '', + } + : null; + + return [..._connectedNetworks, ...(unSupportNetwork ? [unSupportNetwork] : [])]; + }, [networks]); + + const renderItem = (item: WalletConnectChainInfo) => { + return ; + }; + + const networkNumber = connectedNetworks.length; + return ( + ( + {}} + /> + )} + disabled={!networkNumber} + beforeListItem={ + + {i18n.message.connectedNetworkConnected(networkNumber)} + + } + renderCustomItem={renderItem} + /> + ); +}; diff --git a/src/components/WalletConnect/Network/WCNetworkSupported.tsx b/src/components/WalletConnect/Network/WCNetworkSupported.tsx new file mode 100644 index 000000000..8117b054a --- /dev/null +++ b/src/components/WalletConnect/Network/WCNetworkSupported.tsx @@ -0,0 +1,50 @@ +import React, { useMemo, useRef } from 'react'; +import { BasicSelectModal } from 'components/common/SelectModal/BasicSelectModal'; +import { WCNetworkInput } from 'components/WalletConnect/Network/WCNetworkInput'; +import { Typography } from 'components/design-system-ui'; +import { FontSemiBold } from 'styles/sharedStyles'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { ModalRef } from 'types/modalRef'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { WCNetworkItem } from 'components/WalletConnect/Network/WCNetworkItem'; + +interface Props { + networks: WalletConnectChainInfo[]; +} + +export const WCNetworkSupported = ({ networks }: Props) => { + const theme = useSubWalletTheme().swThemes; + const modalRef = useRef(); + const supportedNetworksMap = useMemo(() => { + return networks.reduce((o, key) => Object.assign(o, { [key.slug]: key.supported }), {}); + }, [networks]); + + const renderItem = (item: WalletConnectChainInfo) => { + return ; + }; + + const networkNumber = networks.length; + return ( + ( + {}} /> + )} + beforeListItem={ + {`${networkNumber} networks support`} + } + renderCustomItem={renderItem} + /> + ); +}; diff --git a/src/components/common/Confirmation/ConfirmationGeneralInfo/index.tsx b/src/components/common/Confirmation/ConfirmationGeneralInfo/index.tsx index a33162e85..991f7e015 100644 --- a/src/components/common/Confirmation/ConfirmationGeneralInfo/index.tsx +++ b/src/components/common/Confirmation/ConfirmationGeneralInfo/index.tsx @@ -11,10 +11,12 @@ import { ImageLogosMap } from 'assets/logo'; interface Props { request: ConfirmationRequestBase; gap?: number; + linkIcon?: React.ReactNode; + linkIconBg?: string; } const ConfirmationGeneralInfo: React.FC = (props: Props) => { - const { request, gap = 0 } = props; + const { request, gap = 0, linkIcon, linkIconBg } = props; const domain = getDomainFromUrl(request.url); const leftLogoUrl = `https://icons.duckduckgo.com/ip2/${domain}.ico`; @@ -26,6 +28,8 @@ const ConfirmationGeneralInfo: React.FC = (props: Props) => { } + linkIcon={linkIcon} + linkIconBg={linkIconBg} rightLogo={} /> {domain} diff --git a/src/components/common/Modal/DeleteModal/index.tsx b/src/components/common/Modal/DeleteModal/index.tsx index 7e74b5f79..ed4721dd2 100644 --- a/src/components/common/Modal/DeleteModal/index.tsx +++ b/src/components/common/Modal/DeleteModal/index.tsx @@ -1,6 +1,6 @@ import { Button, Icon, PageIcon, SwModal } from 'components/design-system-ui'; import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; -import { Trash, XCircle } from 'phosphor-react-native'; +import { IconProps, Trash, XCircle } from 'phosphor-react-native'; import React, { useMemo } from 'react'; import { Text, View } from 'react-native'; import { VoidFunction } from 'types/index'; @@ -13,10 +13,22 @@ interface Props { onCompleteModal: VoidFunction; title: string; visible: boolean; + buttonTitle?: string; + buttonIcon?: (iconProps: IconProps) => JSX.Element; + loading?: boolean; } const DeleteModal: React.FC = (props: Props) => { - const { onCancelModal, onCompleteModal, visible, title, message } = props; + const { + onCancelModal, + onCompleteModal, + visible, + title, + message, + buttonTitle, + buttonIcon: ButtonIcon, + loading, + } = props; const theme = useSubWalletTheme().swThemes; @@ -30,10 +42,12 @@ const DeleteModal: React.FC = (props: Props) => { footer={ } diff --git a/src/components/common/Modal/UnlockModal/index.tsx b/src/components/common/Modal/UnlockModal/index.tsx index d837c74e8..33339e246 100644 --- a/src/components/common/Modal/UnlockModal/index.tsx +++ b/src/components/common/Modal/UnlockModal/index.tsx @@ -118,6 +118,7 @@ export const UnlockModal: React.FC = (props: Props) => { onChangeText={onChangePassword} errorMessages={formState.errors.password} onSubmitField={onSubmitField('password')} + isBusy={loading} /> diff --git a/src/components/common/SelectModal/BasicSelectModal.tsx b/src/components/common/SelectModal/BasicSelectModal.tsx index 79a67aebb..699e1f241 100644 --- a/src/components/common/SelectModal/BasicSelectModal.tsx +++ b/src/components/common/SelectModal/BasicSelectModal.tsx @@ -30,6 +30,8 @@ interface Props { renderCustomItem?: (item: T) => JSX.Element; onChangeModalVisible?: () => void; onBackButtonPress?: () => void; + titleTextAlign?: 'center' | 'left'; + beforeListItem?: React.ReactNode; } function _BasicSelectModal(selectModalProps: Props, ref: ForwardedRef) { @@ -50,6 +52,8 @@ function _BasicSelectModal(selectModalProps: Props, ref: ForwardedRef renderCustomItem, onChangeModalVisible, onBackButtonPress, + titleTextAlign, + beforeListItem, } = selectModalProps; const [isOpen, setOpen] = useState(false); const onCloseModal = () => setOpen(false); @@ -119,11 +123,13 @@ function _BasicSelectModal(selectModalProps: Props, ref: ForwardedRef onBackButtonPress={onBackButtonPress} modalVisible={isOpen} modalTitle={title} + titleTextAlign={titleTextAlign} onChangeModalVisible={() => { onChangeModalVisible && onChangeModalVisible(); onCloseModal(); }}> + {beforeListItem} {items.map(item => (renderCustomItem ? renderCustomItem(item) : renderItem(item)))} {selectModalType === 'multi' && renderFooter()} {children} diff --git a/src/components/design-system-ui/background-icon/index.tsx b/src/components/design-system-ui/background-icon/index.tsx index 32732c6a4..449c2ce1d 100644 --- a/src/components/design-system-ui/background-icon/index.tsx +++ b/src/components/design-system-ui/background-icon/index.tsx @@ -18,6 +18,7 @@ interface BackgroundIconProps { iconColor?: string; backgroundColor?: string; style?: StyleProp; + customIcon?: React.ReactNode; } const BackgroundIcon: React.FC = ({ @@ -30,6 +31,7 @@ const BackgroundIcon: React.FC = ({ weight, iconColor, backgroundColor, + customIcon, }) => { const theme = useSubWalletTheme().swThemes; const _style = BackgroundIconStyles(theme); @@ -72,14 +74,18 @@ const BackgroundIcon: React.FC = ({ return ( - + {customIcon ? ( + customIcon + ) : ( + + )} ); }; diff --git a/src/components/design-system-ui/page-icon/index.tsx b/src/components/design-system-ui/page-icon/index.tsx index 6424b033b..28e19f28f 100644 --- a/src/components/design-system-ui/page-icon/index.tsx +++ b/src/components/design-system-ui/page-icon/index.tsx @@ -4,12 +4,13 @@ import Icon from '../icon'; import { IconProps } from 'phosphor-react-native'; interface Props { - icon: React.ElementType; + icon?: React.ElementType; color: string; backgroundColor?: string; + customIcon?: React.ReactNode; } -const PageIcon = ({ icon, color, backgroundColor }: Props) => { +const PageIcon = ({ icon, color, backgroundColor, customIcon }: Props) => { return ( { justifyContent: 'center', alignItems: 'center', }}> - + {customIcon ? customIcon : } ); }; diff --git a/src/constants/index.ts b/src/constants/index.ts index 983b30b9b..692f5a3e9 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -22,6 +22,7 @@ export const ALLOW_FONT_SCALING = false; export const HIDE_MODAL_DURATION = 1000; export const SUBSTRATE_ACCOUNT_TYPE: KeypairType = 'sr25519'; export const EVM_ACCOUNT_TYPE: KeypairType = 'ethereum'; +export const DEFAULT_ACCOUNT_TYPES: KeypairType[] = [SUBSTRATE_ACCOUNT_TYPE, EVM_ACCOUNT_TYPE]; const window = Dimensions.get('window'); export const deviceWidth = window.width; export const deviceHeight = window.height; diff --git a/src/hooks/wallet-connect/useSelectWalletConnectAccount.ts b/src/hooks/wallet-connect/useSelectWalletConnectAccount.ts new file mode 100644 index 000000000..01de00247 --- /dev/null +++ b/src/hooks/wallet-connect/useSelectWalletConnectAccount.ts @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { isAccountAll } from 'utils/accountAll'; +import { RootState } from 'stores/index'; +import { AccountAuthType, AccountJson } from '@subwallet/extension-base/background/types'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { + isProposalExpired, + isSupportWalletConnectChain, + isSupportWalletConnectNamespace, +} from '@subwallet/extension-base/services/wallet-connect-service/helpers'; +import { isSameAddress, uniqueStringArray } from '@subwallet/extension-base/utils'; +import { + WALLET_CONNECT_EIP155_NAMESPACE, + WALLET_CONNECT_SUPPORT_NAMESPACES, +} from '@subwallet/extension-base/services/wallet-connect-service/constants'; +import { isEthereumAddress } from '@polkadot/util-crypto'; +import reformatAddress from 'utils/index'; +import { ProposalTypes } from '@walletconnect/types'; +import { chainsToWalletConnectChainInfos } from 'utils/walletConnect'; + +interface SelectAccount { + availableAccounts: AccountJson[]; + networks: WalletConnectChainInfo[]; + selectedAccounts: string[]; + appliedAccounts: string[]; +} + +const useSelectWalletConnectAccount = (params: ProposalTypes.Struct) => { + const [result, setResult] = useState>({}); + + const { accounts } = useSelector((state: RootState) => state.accountState); + const { chainInfoMap } = useSelector((state: RootState) => state.chainStore); + + const noAllAccount = useMemo(() => accounts.filter(({ address }) => !isAccountAll(address)), [accounts]); + + const namespaces: Record = useMemo(() => { + const availableNamespaces: Record = {}; + const _result: Record = {}; + + Object.entries(params.requiredNamespaces).forEach(([key, namespace]) => { + if (isSupportWalletConnectNamespace(key)) { + if (namespace.chains) { + availableNamespaces[key] = namespace.chains; + } + } + }); + + Object.entries(params.optionalNamespaces).forEach(([key, namespace]) => { + if (isSupportWalletConnectNamespace(key)) { + if (namespace.chains) { + const requiredNameSpace = availableNamespaces[key]; + const defaultChains: string[] = []; + + if (requiredNameSpace) { + availableNamespaces[key] = [ + ...(requiredNameSpace || defaultChains), + ...(namespace.chains || defaultChains), + ]; + } else { + if (namespace.chains.length) { + availableNamespaces[key] = namespace.chains; + } + } + } + } + }); + + for (const [namespace, chains] of Object.entries(availableNamespaces)) { + _result[namespace] = chainsToWalletConnectChainInfos(chainInfoMap, uniqueStringArray(chains)); + } + + return _result; + }, [chainInfoMap, params.optionalNamespaces, params.requiredNamespaces]); + + const supportedChains = useMemo(() => { + const chains: string[] = []; + + for (const [key, namespace] of Object.entries(params.requiredNamespaces)) { + if (isSupportWalletConnectNamespace(key)) { + chains.push(...(namespace.chains || [])); + } + } + + for (const [key, namespace] of Object.entries(params.optionalNamespaces)) { + if (isSupportWalletConnectNamespace(key)) { + chains.push(...(namespace.chains || [])); + } + } + + return chainsToWalletConnectChainInfos(chainInfoMap, uniqueStringArray(chains)).filter( + ({ supported }) => supported, + ); + }, [chainInfoMap, params.optionalNamespaces, params.requiredNamespaces]); + + const missingType = useMemo((): AccountAuthType[] => { + const _result: AccountAuthType[] = []; + + Object.keys(params.requiredNamespaces).forEach(namespace => { + if (WALLET_CONNECT_SUPPORT_NAMESPACES.includes(namespace)) { + const available = noAllAccount.some( + acc => (WALLET_CONNECT_EIP155_NAMESPACE === namespace) === isEthereumAddress(acc.address), + ); + + if (!available) { + _result.push(WALLET_CONNECT_EIP155_NAMESPACE === namespace ? 'evm' : 'substrate'); + } + } + }); + + return _result; + }, [noAllAccount, params.requiredNamespaces]); + + const isUnSupportCase = useMemo( + () => + Object.values(params.requiredNamespaces) + .map(namespace => namespace.chains || []) + .flat() + .some(chain => !isSupportWalletConnectChain(chain, chainInfoMap)), + [chainInfoMap, params.requiredNamespaces], + ); + + const supportOneChain = useMemo(() => supportedChains.length === 1, [supportedChains]); + const supportOneNamespace = useMemo(() => Object.keys(namespaces).length === 1, [namespaces]); + + const [isExpiredState, setIsExpiredState] = useState(isProposalExpired(params)); + const isExpired = useMemo( + () => isProposalExpired(params), + // eslint-disable-next-line react-hooks/exhaustive-deps + [params, isExpiredState], + ); + + const namespaceRef = useRef>({}); + + const onSelectAccount = useCallback((namespace: string, account: string, applyImmediately = false) => { + return () => { + setResult(oldState => { + const newState: Record = { ...oldState }; + const selectedAccounts = newState[namespace].selectedAccounts; + const availableAccounts = newState[namespace].availableAccounts; + + if (isAccountAll(account)) { + if (availableAccounts.length !== selectedAccounts.length) { + newState[namespace].selectedAccounts = availableAccounts.map(({ address }) => address); + } else { + newState[namespace].selectedAccounts = []; + } + } else { + const exists = selectedAccounts.some(address => isSameAddress(address, account)); + + if (exists) { + newState[namespace].selectedAccounts = selectedAccounts.filter(address => !isSameAddress(address, account)); + } else { + newState[namespace].selectedAccounts = [...selectedAccounts, reformatAddress(account)]; + } + } + + if (applyImmediately) { + newState[namespace].appliedAccounts = newState[namespace].selectedAccounts; + } + + return newState; + }); + }; + }, []); + + const onApplyAccounts = useCallback((namespace: string) => { + setResult(oldState => { + const newState: Record = { ...oldState }; + + newState[namespace].appliedAccounts = newState[namespace].selectedAccounts; + + return newState; + }); + }, []); + + const onCancelSelectAccounts = useCallback((namespace: string) => { + setResult(oldState => { + const newState: Record = { ...oldState }; + + newState[namespace].selectedAccounts = newState[namespace].appliedAccounts; + + return newState; + }); + }, []); + + useEffect(() => { + setResult(oldState => { + const _result: Record = {}; + + const selectReplace = JSON.stringify(namespaces) !== JSON.stringify(namespaceRef.current); + + for (const [namespace, networks] of Object.entries(namespaces)) { + if (WALLET_CONNECT_SUPPORT_NAMESPACES.includes(namespace)) { + _result[namespace] = { + networks, + selectedAccounts: selectReplace ? [] : oldState[namespace]?.selectedAccounts || [], + appliedAccounts: selectReplace ? [] : oldState[namespace]?.appliedAccounts || [], + availableAccounts: noAllAccount.filter( + acc => (WALLET_CONNECT_EIP155_NAMESPACE === namespace) === isEthereumAddress(acc.address), + ), + }; + } + } + + return _result; + }); + + return () => { + namespaceRef.current = namespaces; + }; + }, [noAllAccount, namespaces]); + + useEffect(() => { + const callback = (): boolean => { + const _isExpired = isProposalExpired(params); + + setIsExpiredState(_isExpired); + + return _isExpired; + }; + + callback(); + + const interval = setInterval(() => { + const _isExpired = callback(); + + if (_isExpired) { + clearInterval(interval); + } + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [params]); + + return { + isExpired, + isUnSupportCase, + missingType, + namespaceAccounts: result, + onApplyAccounts, + onCancelSelectAccounts, + onSelectAccount, + supportOneChain, + supportOneNamespace, + supportedChains, + }; +}; + +export default useSelectWalletConnectAccount; diff --git a/src/messaging/index.ts b/src/messaging/index.ts index d98632609..15049d947 100644 --- a/src/messaging/index.ts +++ b/src/messaging/index.ts @@ -58,10 +58,12 @@ import { RequestAccountCreateSuriV2, RequestAccountCreateWithSecretKey, RequestAccountMeta, + RequestApproveConnectWalletSession, RequestAuthorizationBlock, RequestAuthorizationPerSite, RequestBondingSubmit, RequestChangeMasterPassword, + RequestConnectWalletConnect, RequestCronAndSubscriptionAction, RequestCrossChainTransfer, RequestDeriveCreateMultiple, @@ -79,6 +81,7 @@ import { RequestParseTransactionSubstrate, RequestQrSignEvm, RequestQrSignSubstrate, + RequestRejectConnectWalletSession, RequestResetWallet, RequestSettingsType, RequestSigningApprovePasswordV2, @@ -1363,6 +1366,24 @@ export async function getTransaction(request: RequestGetTransaction): Promise { + return sendMessage('pri(walletConnect.connect)', request); +} + +export async function approveWalletConnectSession(request: RequestApproveConnectWalletSession): Promise { + return sendMessage('pri(walletConnect.session.approve)', request); +} + +export async function rejectWalletConnectSession(request: RequestRejectConnectWalletSession): Promise { + return sendMessage('pri(walletConnect.session.reject)', request); +} + +export async function disconnectWalletConnectConnection(topic: string): Promise { + return sendMessage('pri(walletConnect.session.disconnect)', { topic }); +} + export async function subscribeTransactions( callback: (rs: Record) => void, ): Promise> { diff --git a/src/providers/DataContext.tsx b/src/providers/DataContext.tsx index 12c08b483..050768147 100644 --- a/src/providers/DataContext.tsx +++ b/src/providers/DataContext.tsx @@ -30,6 +30,8 @@ import { subscribeTxHistory, subscribeUiSettings, subscribeXcmRefMap, + subscribeConnectWCRequests, + subscribeWalletConnectSessions, } from 'stores/utils'; import React, { useContext, useEffect, useRef } from 'react'; import { Provider } from 'react-redux'; @@ -294,6 +296,12 @@ export const DataContextProvider = ({ children }: DataContextProviderProps) => { relatedStores: ['requestState'], isStartImmediately: true, }); + _DataContext.addHandler({ + ...subscribeConnectWCRequests, + name: 'subscribeConnectWCRequests', + relatedStores: ['requestState'], + isStartImmediately: true, + }); // Features _DataContext.addHandler({ @@ -336,6 +344,11 @@ export const DataContextProvider = ({ children }: DataContextProviderProps) => { name: 'subscribeTxHistory', relatedStores: ['transactionHistory'], }); + _DataContext.addHandler({ + ...subscribeWalletConnectSessions, + name: 'subscribeWalletConnectSessions', + relatedStores: ['walletConnect'], + }); readyFlag.current.isStart = false; } diff --git a/src/routes/index.ts b/src/routes/index.ts index f47629713..f7a6ae15f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -26,6 +26,9 @@ export type RootStackParamList = { state?: string[]; }; ChangePassword: undefined; + ConnectList: undefined; + ConnectDetail: { topic: string }; + ConnectWalletConnect: undefined; MigratePassword: undefined; CreateAccount: { keyTypes?: KeypairType[]; isBack?: boolean }; QrScanner: undefined; @@ -109,6 +112,7 @@ export type HomeScreenProps = NativeStackScreenProps export type ConfigureTokenProps = NativeStackScreenProps; export type ImportTokenProps = NativeStackScreenProps; export type ImportNftProps = NativeStackScreenProps; +export type ConnectDetailProps = NativeStackScreenProps; export type NetworkConfigDetailProps = NativeStackScreenProps; export type NetworkSettingDetailProps = NativeStackScreenProps; export type TransactionDoneProps = NativeStackScreenProps; diff --git a/src/screens/Confirmations/index.tsx b/src/screens/Confirmations/index.tsx index fb369f8ba..93a4992ba 100644 --- a/src/screens/Confirmations/index.tsx +++ b/src/screens/Confirmations/index.tsx @@ -33,6 +33,8 @@ import { } from './variants'; import { STATUS_BAR_HEIGHT } from 'styles/sharedStyles'; import i18n from 'utils/i18n/i18n'; +import { WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; +import { ConnectWalletConnectConfirmation } from 'screens/Confirmations/variants/ConnectWalletConnectConfirmation'; const getConfirmationPopupWrapperStyle = (isShowSeparator: boolean): StyleProp => { return { @@ -72,6 +74,7 @@ export const Confirmations = () => { metadataRequest: i18n.header.updateMetadata, signingRequest: i18n.header.signatureRequest, switchNetworkRequest: i18n.header.addNetworkRequest, + connectWCRequest: i18n.header.walletConnect, }), [], ) as Record; @@ -193,6 +196,8 @@ export const Confirmations = () => { return ; case 'signingRequest': return ; + case 'connectWCRequest': + return ; } return null; diff --git a/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/index.tsx b/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/index.tsx new file mode 100644 index 000000000..12b28487b --- /dev/null +++ b/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/index.tsx @@ -0,0 +1,254 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; +import { approveWalletConnectSession, rejectWalletConnectSession } from 'messaging/index'; +import { isAccountAll } from 'utils/accountAll'; +import { + WALLET_CONNECT_EIP155_NAMESPACE, + WALLET_CONNECT_POLKADOT_NAMESPACE, +} from '@subwallet/extension-base/services/wallet-connect-service/constants'; +import useSelectWalletConnectAccount from 'hooks/wallet-connect/useSelectWalletConnectAccount'; +import { VoidFunction } from 'types/index'; +import { useToast } from 'react-native-toast-notifications'; +import { useNavigation } from '@react-navigation/native'; +import { convertKeyTypes } from 'utils/index'; +import { RootNavigationProps } from 'routes/index'; +import ConfirmationContent from '../../../../components/common/Confirmation/ConfirmationContent'; +import ConfirmationGeneralInfo from '../../../../components/common/Confirmation/ConfirmationGeneralInfo'; +import AlertBox from 'components/design-system-ui/alert-box'; +import { View } from 'react-native'; +import { Button, Icon, Typography } from 'components/design-system-ui'; +import { ConfirmationFooter } from 'components/common/Confirmation'; +import { CheckCircle, PlusCircle, XCircle } from 'phosphor-react-native'; +import i18n from 'utils/i18n/i18n'; +import { WCNetworkSelected } from 'components/WalletConnect/Network/WCNetworkSelected'; +import { WCAccountSelect } from 'components/WalletConnect/Account/WCAccountSelect'; +import createStyle from './styles'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { WCNetworkSupported } from 'components/WalletConnect/Network/WCNetworkSupported'; +import { SVGImages } from 'assets/index'; + +interface Props { + request: WalletConnectSessionRequest; +} + +const handleConfirm = async ({ id }: WalletConnectSessionRequest, selectedAccounts: string[]) => { + return await approveWalletConnectSession({ + id, + accounts: selectedAccounts.filter(item => !isAccountAll(item)), + }); +}; + +const handleCancel = async ({ id }: WalletConnectSessionRequest) => { + return await rejectWalletConnectSession({ + id, + }); +}; + +export const ConnectWalletConnectConfirmation = ({ request }: Props) => { + const navigation = useNavigation(); + const { params } = request.request; + const toast = useToast(); + const nameSpaceNameMap = useMemo( + (): Record => ({ + [WALLET_CONNECT_EIP155_NAMESPACE]: 'EVM networks', + [WALLET_CONNECT_POLKADOT_NAMESPACE]: 'Substrate networks', + }), + [], + ); + const theme = useSubWalletTheme().swThemes; + const styles = useMemo(() => createStyle(theme), [theme]); + + const { + isExpired, + isUnSupportCase, + missingType, + namespaceAccounts, + onApplyAccounts, + onCancelSelectAccounts, + onSelectAccount, + supportOneChain, + supportOneNamespace, + supportedChains, + } = useSelectWalletConnectAccount(params); + + const allowSubmit = useMemo(() => { + return Object.values(namespaceAccounts).every(({ appliedAccounts }) => appliedAccounts.length); + }, [namespaceAccounts]); + + const [loading, setLoading] = useState(false); + + const _onSelectAccount = useCallback( + (namespace: string): ((address: string, applyImmediately?: boolean) => VoidFunction) => { + return (address: string, applyImmediately = false) => { + return () => { + onSelectAccount(namespace, address, applyImmediately)(); + }; + }; + }, + [onSelectAccount], + ); + + const onCancel = useCallback(() => { + setLoading(true); + handleCancel(request).finally(() => { + setLoading(false); + }); + }, [request]); + + const onConfirm = useCallback(() => { + setLoading(true); + const selectedAccounts = Object.values(namespaceAccounts) + .map(({ appliedAccounts }) => appliedAccounts) + .flat(); + + handleConfirm(request, selectedAccounts) + .catch(e => { + toast.show((e as Error).message, { type: 'danger' }); + }) + .finally(() => { + setLoading(false); + }); + }, [namespaceAccounts, request, toast]); + + const onAddAccount = useCallback(() => { + navigation.replace('CreateAccount', { keyTypes: convertKeyTypes(missingType), isBack: true }); + }, [missingType, navigation]); + + const onApplyModal = useCallback( + (namespace: string) => { + return () => { + onApplyAccounts(namespace); + }; + }, + [onApplyAccounts], + ); + + const onCancelModal = useCallback( + (namespace: string) => { + return () => { + onCancelSelectAccounts(namespace); + }; + }, + [onCancelSelectAccounts], + ); + + const isSupportCase = !isUnSupportCase && !isExpired; + + return ( + + + } + /> + {isUnSupportCase && ( + + + + + + + + )} + {!isUnSupportCase && isExpired && ( + <> + + + )} + {isSupportCase && ( + + {Object.entries(namespaceAccounts).map(([namespace, value]) => { + const { appliedAccounts, availableAccounts, networks, selectedAccounts } = value; + + return ( + + {!supportOneChain && ( + <> + + {supportOneNamespace ? 'Networks' : nameSpaceNameMap[namespace]} + + + + )} + {supportOneNamespace && ( + {i18n.common.chooseAccount} + )} + + + + ); + })} + + )} + + + {!isSupportCase && ( + + )} + {isSupportCase && !missingType.length && ( + <> + + + + )} + {isSupportCase && !!missingType.length && ( + <> + + + + )} + + + ); +}; diff --git a/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/styles/index.ts b/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/styles/index.ts new file mode 100644 index 000000000..c238be58a --- /dev/null +++ b/src/screens/Confirmations/variants/ConnectWalletConnectConfirmation/styles/index.ts @@ -0,0 +1,20 @@ +import { StyleSheet, TextStyle } from 'react-native'; +import { ThemeTypes } from 'styles/themes'; +import { FontSemiBold } from 'styles/sharedStyles'; + +export interface ComponentStyle { + text: TextStyle; +} + +export default (theme: ThemeTypes) => { + return StyleSheet.create({ + text: { + color: theme.colorText, + fontSize: theme.fontSizeHeading6, + lineHeight: theme.fontSizeHeading6 * theme.lineHeightHeading6, + width: '100%', + ...FontSemiBold, + paddingBottom: theme.padding, + }, + }); +}; diff --git a/src/screens/Home/index.tsx b/src/screens/Home/index.tsx index 21c56c8f5..76e8e0e13 100644 --- a/src/screens/Home/index.tsx +++ b/src/screens/Home/index.tsx @@ -28,7 +28,7 @@ import { WrapperParamList } from 'routes/wrapper'; import { Settings } from 'screens/Settings'; import i18n from 'utils/i18n/i18n'; import { RootStackParamList } from 'routes/index'; -import { handleDeeplinkOnFirstOpen } from 'utils/browser'; +import { handleDeeplinkOnFirstOpen } from 'utils/deeplink'; interface tabbarIconColor { color: string; diff --git a/src/screens/Settings/WalletConnect/ConnectWalletConnect.tsx b/src/screens/Settings/WalletConnect/ConnectWalletConnect.tsx new file mode 100644 index 000000000..99199466c --- /dev/null +++ b/src/screens/Settings/WalletConnect/ConnectWalletConnect.tsx @@ -0,0 +1,116 @@ +import React, { useMemo, useState } from 'react'; +import { ContainerWithSubHeader } from 'components/ContainerWithSubHeader'; +import { useNavigation } from '@react-navigation/native'; +import { RootNavigationProps } from 'routes/index'; +import i18n from 'utils/i18n/i18n'; +import { Button, PageIcon, Typography } from 'components/design-system-ui'; +import { FontMedium } from 'styles/sharedStyles'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { SVGImages } from 'assets/index'; +import { Keyboard, View } from 'react-native'; +import useFormControl, { FormControlConfig } from 'hooks/screen/useFormControl'; +import { InputConnectUrl } from 'components/Input/InputConnectUrl'; +import { validWalletConnectUri } from 'utils/scanner/walletConnect'; +import { Warning } from 'components/Warning'; +import { addConnection } from 'messaging/index'; +import { useToast } from 'react-native-toast-notifications'; + +const validateUri = (uri: string) => { + const error = validWalletConnectUri(uri); + if (error) { + return [error]; + } else { + return []; + } +}; + +export const ConnectWalletConnect = () => { + const navigation = useNavigation(); + const [loading, setLoading] = useState(false); + const theme = useSubWalletTheme().swThemes; + const toast = useToast(); + + const formConfig = useMemo((): FormControlConfig => { + return { + uri: { + name: 'URI', + value: '', + require: true, + validateFunc: validateUri, + }, + }; + }, []); + + const onSubmit = () => { + const currentUri = formState.data.uri; + if (!currentUri) { + return; + } + setLoading(true); + + addConnection({ uri: currentUri }) + .then(() => { + setLoading(false); + navigation.goBack(); + }) + .catch(e => { + const errMessage = (e as Error).message; + const message = errMessage.includes('Pairing already exists') + ? i18n.errorMessage.connectionAlreadyExist + : i18n.errorMessage.failToAddConnection; + + toast.show(message, { type: 'danger' }); + }); + }; + + const { formState, onChangeValue, onSubmitField } = useFormControl(formConfig, { + onSubmitForm: onSubmit, + }); + + return ( + navigation.goBack()} title={i18n.header.walletConnect}> + + + {i18n.message.connectWalletConnectMessage} + + + + } + color={theme.colorPrimary} + /> + + + 0 ? () => Keyboard.dismiss() : onSubmitField('uri')} + /> + + {formState.errors.uri.length > 0 && + formState.errors.uri.map((message, index) => )} + + + + ); +}; diff --git a/src/screens/Settings/WalletConnect/ConnectionDetail.tsx b/src/screens/Settings/WalletConnect/ConnectionDetail.tsx new file mode 100644 index 000000000..82564a1d7 --- /dev/null +++ b/src/screens/Settings/WalletConnect/ConnectionDetail.tsx @@ -0,0 +1,186 @@ +import React, { useMemo, useState } from 'react'; +import { useToast } from 'react-native-toast-notifications'; +import { stripUrl } from '@subwallet/extension-base/utils'; +import { ContainerWithSubHeader } from 'components/ContainerWithSubHeader'; +import MetaInfo from 'components/MetaInfo'; +import { Button, Icon, Image, SwModal, Typography } from 'components/design-system-ui'; +import { WCNetworkAvatarGroup } from 'components/WalletConnect/Network/WCNetworkAvatarGroup'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { chainsToWalletConnectChainInfos, getWCAccountList } from 'utils/walletConnect'; +import { useSelector } from 'react-redux'; +import { RootState } from 'stores/index'; +import { useNavigation } from '@react-navigation/native'; +import { ConnectDetailProps } from 'routes/index'; +import { ScrollView, TouchableOpacity, View } from 'react-native'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { FontMedium } from 'styles/sharedStyles'; +import { AbstractAddressJson } from '@subwallet/extension-base/background/types'; +import AccountItemWithName from 'components/common/Account/Item/AccountItemWithName'; +import i18n from 'utils/i18n/i18n'; +import { Globe, Info, Plugs } from 'phosphor-react-native'; +import DeleteModal from 'components/common/Modal/DeleteModal'; +import { disconnectWalletConnectConnection } from 'messaging/index'; +import { EmptyList } from 'components/EmptyList'; +import { SessionTypes } from '@walletconnect/types'; +import { BUTTON_ACTIVE_OPACITY } from 'constants/index'; +import { WCNetworkItem } from 'components/WalletConnect/Network/WCNetworkItem'; + +export const ConnectionDetail = ({ + route: { + params: { topic }, + }, +}: ConnectDetailProps) => { + const { sessions } = useSelector((state: RootState) => state.walletConnect); + const theme = useSubWalletTheme().swThemes; + const currentSession: SessionTypes.Struct = sessions[topic]; + + const navigation = useNavigation(); + const toast = useToast(); + const [disconnectModalVisible, setDisconnectModalVisible] = useState(false); + const [networkModalVisible, setNetworkModalVisible] = useState(false); + const [loading, setLoading] = useState(false); + const { chainInfoMap } = useSelector((state: RootState) => state.chainStore); + const { accounts } = useSelector((state: RootState) => state.accountState); + + const domain = useMemo(() => { + if (currentSession) { + const _dAppInfo = currentSession.peer.metadata; + try { + return stripUrl(_dAppInfo.url); + } catch (e) { + return _dAppInfo.url; + } + } + }, [currentSession]); + + const accountItems = useMemo( + (): AbstractAddressJson[] => (currentSession ? getWCAccountList(accounts, currentSession.namespaces) : []), + [accounts, currentSession], + ); + + const chains = useMemo((): WalletConnectChainInfo[] => { + if (currentSession) { + const _chains = Object.values(currentSession.namespaces) + .map(namespace => namespace.chains || []) + .flat(); + + return chainsToWalletConnectChainInfos(chainInfoMap, _chains); + } else { + return []; + } + }, [currentSession, chainInfoMap]); + + const connectedChainsMap = useMemo(() => { + return chains.reduce((o, key) => Object.assign(o, { [key.slug]: key.supported }), {}); + }, [chains]); + + const img = `https://icons.duckduckgo.com/ip2/${domain}.ico`; + + const onDisconnect = () => { + setLoading(true); + disconnectWalletConnectConnection(topic) + .catch(() => { + toast.show('Fail to disconnect', { type: 'danger' }); + }) + .finally(() => { + setLoading(false); + setDisconnectModalVisible(false); + navigation.goBack(); + }); + }; + + return ( + navigation.goBack()} + title={i18n.header.walletConnect}> + <> + {Object.keys(sessions) && Object.keys(sessions).length ? ( + <> + + + + + + + {domain} + + + + + setNetworkModalVisible(true)} + style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> + + + {i18n.message.connectedNetworks(chains.length)} + + + + + + + + {i18n.message.connectedAccounts(accountItems.length)} + + + {accountItems.map(item => ( + + ))} + + + + setDisconnectModalVisible(false)} + onCompleteModal={onDisconnect} + buttonTitle={i18n.buttonTitles.disconnect} + buttonIcon={Plugs} + loading={loading} + /> + + setNetworkModalVisible(false)}> + + {chains.map(chain => ( + + ))} + + + + ) : ( + + )} + + + ); +}; diff --git a/src/screens/Settings/WalletConnect/ConnectionList.tsx b/src/screens/Settings/WalletConnect/ConnectionList.tsx new file mode 100644 index 000000000..093a23df6 --- /dev/null +++ b/src/screens/Settings/WalletConnect/ConnectionList.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { RootNavigationProps } from 'routes/index'; +import { FlatListScreen } from 'components/FlatListScreen'; +import { useSelector } from 'react-redux'; +import { RootState } from 'stores/index'; +import { EmptyList } from 'components/EmptyList'; +import i18n from 'utils/i18n/i18n'; +import { GlobeHemisphereWest } from 'phosphor-react-native'; +import { SessionTypes } from '@walletconnect/types'; +import { stripUrl } from '@subwallet/extension-base/utils'; +import { Button } from 'components/design-system-ui'; +import { SVGImages } from 'assets/index'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; +import { ConnectionItem } from 'components/WalletConnect/ConnectionItem'; +import { ListRenderItemInfo } from 'react-native'; + +const searchFunc = (items: SessionTypes.Struct[], searchString: string) => { + const searchTextLowerCase = searchString.toLowerCase(); + + return items.filter(item => { + const metadata = item.peer.metadata; + let id: string; + + try { + id = stripUrl(metadata.url); + } catch (e) { + id = metadata.url; + } + const name = metadata.name; + + return id.toLowerCase().includes(searchTextLowerCase) || name.toLowerCase().includes(searchTextLowerCase); + }); +}; + +export const ConnectionList = () => { + const theme = useSubWalletTheme().swThemes; + const { sessions } = useSelector((state: RootState) => state.walletConnect); + const items = useMemo(() => Object.values(sessions), [sessions]); + const navigation = useNavigation(); + + const renderEmptyList = () => { + return ( + + ); + }; + + const onPressItem = useCallback( + (topic: string) => navigation.navigate('ConnectDetail', { topic: topic }), + [navigation], + ); + + const renderItem = ({ item }: ListRenderItemInfo) => ( + + ); + + return ( + navigation.goBack()} + renderItem={renderItem} + renderListEmptyComponent={renderEmptyList} + searchFunction={searchFunc} + afterListItem={ + + } + /> + ); +}; diff --git a/src/screens/Settings/index.tsx b/src/screens/Settings/index.tsx index fdc28bf50..7f508620a 100644 --- a/src/screens/Settings/index.tsx +++ b/src/screens/Settings/index.tsx @@ -39,7 +39,7 @@ import { } from 'constants/index'; import VersionNumber from 'react-native-version-number'; import useAppLock from 'hooks/useAppLock'; -import { Button, Icon, SelectItem } from 'components/design-system-ui'; +import { BackgroundIcon, Button, Icon, SelectItem } from 'components/design-system-ui'; import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; import { SVGImages } from 'assets/index'; import { DrawerContentComponentProps } from '@react-navigation/drawer'; @@ -108,6 +108,13 @@ export const Settings = ({ navigation: drawerNavigation }: DrawerContentComponen onPress: () => navigation.navigate('History', {}), backgroundColor: '#2595E6', }, + { + icon: Clock, + title: i18n.header.walletConnect, + rightIcon: , + onPress: () => navigation.navigate('ConnectList'), + backgroundColor: '#004BFF', + }, ], [ { @@ -225,6 +232,15 @@ export const Settings = ({ navigation: drawerNavigation }: DrawerContentComponen rightIcon={setting.rightIcon} key={setting.title} label={setting.title} + leftItemIcon={ + setting.title === i18n.header.walletConnect ? ( + } + /> + ) : undefined + } icon={setting.icon} backgroundColor={setting.backgroundColor} onPress={setting.onPress} diff --git a/src/screens/Signing/SigningScanPayload.tsx b/src/screens/Signing/SigningScanPayload.tsx index 75d168e51..e4824f52f 100644 --- a/src/screens/Signing/SigningScanPayload.tsx +++ b/src/screens/Signing/SigningScanPayload.tsx @@ -3,7 +3,7 @@ import Text from 'components/Text'; import { Warning } from 'components/Warning'; import { BUTTON_ACTIVE_OPACITY } from 'constants/index'; import { SCANNER_QR_STEP } from 'constants/qr'; -import { overlayColor, rectDimensions } from 'constants/scanner'; +import { rectDimensions } from 'constants/scanner'; import usePayloadScanner from 'hooks/qr/usePayloadScanner'; import { ArrowLeft } from 'phosphor-react-native'; import { ScannerContext } from 'providers/ScannerContext'; @@ -19,6 +19,7 @@ import { FontMedium, FontSize0, sharedStyles, STATUS_BAR_LIGHT_CONTENT } from 's import { convertHexColorToRGBA } from 'utils/color'; import i18n from 'utils/i18n/i18n'; import { Button } from 'components/design-system-ui'; +import { useSubWalletTheme } from 'hooks/useSubWalletTheme'; const WrapperStyle: StyleProp = { flex: 1, @@ -68,6 +69,7 @@ const ProgressButtonStyle: StyleProp = { }; const SigningScanPayload = () => { + const theme = useSubWalletTheme().swThemes; const navigation = useNavigation(); const { state: { totalFrameCount, completedFramesCount, step }, @@ -96,7 +98,7 @@ const SigningScanPayload = () => { return ( - + { customMarker={ - + {i18n.title.scanPayload} diff --git a/src/stores/base/RequestState.ts b/src/stores/base/RequestState.ts index d7b0b507f..32e5be2af 100644 --- a/src/stores/base/RequestState.ts +++ b/src/stores/base/RequestState.ts @@ -11,12 +11,14 @@ import { } from '@subwallet/extension-base/background/types'; import { SWTransactionResult } from '@subwallet/extension-base/services/transaction-service/types'; import { ReduxStatus, RequestState } from 'stores/types'; +import { WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; const initialState: RequestState = { authorizeRequest: {}, metadataRequest: {}, signingRequest: {}, transactionRequest: {}, + connectWCRequest: {}, // Type of confirmation requets addNetworkRequest: {}, @@ -41,6 +43,7 @@ export const CONFIRMATIONS_FIELDS: Array = [ 'switchNetworkRequest', 'evmSignatureRequest', 'evmSendTransactionRequest', + 'connectWCRequest', ]; export interface ConfirmationQueueItem { @@ -106,6 +109,11 @@ const requestStateSlice = createSlice({ updateTransactionRequests(state, { payload }: PayloadAction>) { state.transactionRequest = payload; }, + updateConnectWCRequests(state, { payload }: PayloadAction>) { + state.connectWCRequest = payload; + readyMap.updateConfirmationRequests = true; + computeStateSummary(state); + }, }, }); diff --git a/src/stores/feature/WalletConnect.ts b/src/stores/feature/WalletConnect.ts new file mode 100644 index 000000000..50a29d9ee --- /dev/null +++ b/src/stores/feature/WalletConnect.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { SessionTypes } from '@walletconnect/types'; +import { ReduxStatus, WalletConnectStore } from 'stores/types'; + +const initialState: WalletConnectStore = { + sessions: {}, + reduxStatus: ReduxStatus.INIT, +}; + +const walletConnectSlice = createSlice({ + initialState, + name: 'walletConnect', + reducers: { + updateSessions(state, action: PayloadAction>) { + const { payload } = action; + + return { + sessions: payload, + reduxStatus: ReduxStatus.READY, + }; + }, + }, +}); + +export const { updateSessions } = walletConnectSlice.actions; +export default walletConnectSlice.reducer; diff --git a/src/stores/index.ts b/src/stores/index.ts index 18a64c32c..c407cbca1 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -28,6 +28,7 @@ import CrowdloanReducer from './feature/Crowdloan'; import NftReducer from './feature/Nft'; import PriceReducer from './feature/Price'; import StakingReducer from './feature/Staking'; +import WalletConnectReducer from './feature/WalletConnect'; import TransactionHistoryReducer from './feature/TransactionHistory'; import PasswordModalReducer from 'stores/PasswordModalState'; import LogoMap from 'stores/base/LogoMap'; @@ -67,6 +68,7 @@ const rootReducer = combineReducers({ price: persistReducer({ key: 'price', storage: mmkvReduxStore } as PersistConfig, PriceReducer), balance: persistReducer({ key: 'balance', storage: mmkvReduxStore } as PersistConfig, BalanceReducer), bonding: BondingReducer, + walletConnect: WalletConnectReducer, //Common chainStore: persistReducer( diff --git a/src/stores/types.ts b/src/stores/types.ts index 7d43ec5ff..fbdb752c2 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -33,6 +33,8 @@ import { SettingsStruct } from '@polkadot/ui-settings/types'; import { SWTransactionResult } from '@subwallet/extension-base/services/transaction-service/types'; import { _AssetRef, _ChainAsset, _ChainInfo, _MultiChainAsset } from '@subwallet/chain-list/types'; import { _ChainState } from '@subwallet/extension-base/services/chain-service/types'; +import { SessionTypes } from '@walletconnect/types'; +import { WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; export type StoreStatus = 'INIT' | 'CACHED' | 'SYNCED' | 'WAITING'; @@ -151,6 +153,7 @@ export interface RequestState extends ConfirmationsQueue, BaseReduxStore { hasInternalConfirmations: boolean; numberOfConfirmations: number; transactionRequest: Record; + connectWCRequest: Record; } export interface UpdateConfirmationsQueueRequest extends BaseReduxStore { @@ -210,3 +213,7 @@ export interface ChainNominationPoolParams { export type TransactionHistoryReducerType = { historyList: TransactionHistoryItem[]; }; + +export interface WalletConnectStore extends BaseReduxStore { + sessions: Record; +} diff --git a/src/stores/utils/index.ts b/src/stores/utils/index.ts index 25ff832aa..a5784025f 100644 --- a/src/stores/utils/index.ts +++ b/src/stores/utils/index.ts @@ -38,6 +38,8 @@ import { lazySendMessage, lazySubscribeMessage } from 'messaging/index'; import { AppSettings } from 'stores/types'; import { store } from '..'; import { buildHierarchy } from 'utils/buildHierarchy'; +import { WalletConnectSessionRequest } from '@subwallet/extension-base/services/wallet-connect-service/types'; +import { SessionTypes } from '@walletconnect/types'; // Setup redux stores function voidFn() { @@ -416,6 +418,38 @@ export const subscribeTxHistory = lazySubscribeMessage( updateTxHistory, ); +// Wallet connect +export const updateConnectWCRequests = (data: WalletConnectSessionRequest[]) => { + // Convert data to object with key as id + const requests = convertConfirmationToMap(data); + + store.dispatch({ type: 'requestState/updateConnectWCRequests', payload: requests }); +}; + +export const subscribeConnectWCRequests = lazySubscribeMessage( + 'pri(walletConnect.requests.subscribe)', + null, + updateConnectWCRequests, + updateConnectWCRequests, +); + +export const updateWalletConnectSessions = (data: SessionTypes.Struct[]) => { + console.log('data', data); + const payload: Record = {}; + + data.forEach(session => { + payload[session.topic] = session; + }); + store.dispatch({ type: 'walletConnect/updateSessions', payload: payload }); +}; + +export const subscribeWalletConnectSessions = lazySubscribeMessage( + 'pri(walletConnect.session.subscribe)', + null, + updateWalletConnectSessions, + updateWalletConnectSessions, +); + // export const updateChainValidators = (data: ChainValidatorParams) => { // store.dispatch({ type: 'bonding/updateChainValidators', payload: data }); // }; diff --git a/src/styles/scanner.ts b/src/styles/scanner.ts index c2c47e9c9..afec40a02 100644 --- a/src/styles/scanner.ts +++ b/src/styles/scanner.ts @@ -33,7 +33,7 @@ const TopOverlayStyle: StyleProp = { height: topOverlayHeight, width: deviceWidth, backgroundColor: overlayColor, - paddingTop: 13, + // paddingTop: 13, }; const CenterOverlayStyle: StyleProp = { @@ -83,7 +83,7 @@ const HeaderStyle: StyleProp = { width: '100%', alignItems: 'center', justifyContent: 'center', - height: 40, + height: 56, }; const HeaderTitleTextStyle: StyleProp = { diff --git a/src/types/walletConnect.ts b/src/types/walletConnect.ts new file mode 100644 index 000000000..8e0f72978 --- /dev/null +++ b/src/types/walletConnect.ts @@ -0,0 +1,7 @@ +import { ChainInfo } from 'types/index'; + +export interface WalletConnectChainInfo { + chainInfo: ChainInfo | null; + slug: string; + supported: boolean; +} diff --git a/src/utils/browser.ts b/src/utils/browser.ts index 24bf29462..918fef7a6 100644 --- a/src/utils/browser.ts +++ b/src/utils/browser.ts @@ -1,11 +1,3 @@ -import { Linking } from 'react-native'; -import urlParse from 'url-parse'; -import queryString from 'querystring'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from 'routes/index'; - -let prevDeeplinkUrl = ''; - export const deeplinks = ['subwallet://', 'https://mobile.subwallet.app']; export function isValidURL(str: string): boolean { @@ -47,28 +39,3 @@ export function getValidURL(address: string): string { return `https://${searchDomain}/?q=${encodeURIComponent(address)}`; } } - -export function handleDeeplinkOnFirstOpen(navigation: NativeStackNavigationProp) { - Linking.getInitialURL() - .then(url => { - if (!url || prevDeeplinkUrl === url) { - return; - } - prevDeeplinkUrl = url; - if (getProtocol(url) === 'subwallet') { - Linking.openURL(url); - } else if (getProtocol(url) === 'https') { - const urlParsed = new urlParse(url); - if (urlParsed.pathname.split('/')[1] === 'browser') { - // Format like: https://subwallet-link.vercel.app/browser?url=https://hackadot.subwallet.app/ - const finalUrl = queryString.parse(urlParsed.query)['?url'] || ''; - navigation.navigate('BrowserTabsManager', { - url: Array.isArray(finalUrl) ? finalUrl[0] : finalUrl, - name: '', - isOpenTabs: false, - }); - } - } - }) - .catch(e => console.warn('e', e)); -} diff --git a/src/utils/deeplink/index.ts b/src/utils/deeplink/index.ts new file mode 100644 index 000000000..d22ae9d03 --- /dev/null +++ b/src/utils/deeplink/index.ts @@ -0,0 +1,51 @@ +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from 'routes/index'; +import { Linking } from 'react-native'; +import urlParse from 'url-parse'; +import queryString from 'querystring'; +import { connectWalletConnect } from 'utils/walletConnect'; +import { getProtocol } from 'utils/browser'; +import { ToastType } from 'react-native-toast-notifications'; + +let prevDeeplinkUrl = ''; + +export function handleDeeplinkOnFirstOpen( + navigation: NativeStackNavigationProp, + toast?: ToastType, +) { + Linking.getInitialURL() + .then(url => { + if (!url || prevDeeplinkUrl === url) { + return; + } + prevDeeplinkUrl = url; + if (getProtocol(url) === 'subwallet') { + const urlParsed = new urlParse(url); + if (urlParsed.hostname === 'wc') { + const decodedWcUrl = queryString.decode(urlParsed.query.slice(5)); + const finalWcUrl = Object.keys(decodedWcUrl)[0]; + connectWalletConnect(finalWcUrl, toast); + } + Linking.openURL(url); + } else if (getProtocol(url) === 'https') { + const urlParsed = new urlParse(url); + if (urlParsed.pathname.split('/')[1] === 'browser') { + // Format like: https://subwallet-link.vercel.app/browser?url=https://hackadot.subwallet.app/ + const finalUrl = queryString.parse(urlParsed.query)['?url'] || ''; + navigation.navigate('BrowserTabsManager', { + url: Array.isArray(finalUrl) ? finalUrl[0] : finalUrl, + name: '', + isOpenTabs: false, + }); + return; + } + + if (urlParsed.pathname.split('/')[1] === 'wc') { + const decodedWcUrl = queryString.decode(urlParsed.query.slice(5)); + const finalWcUrl = Object.keys(decodedWcUrl)[0]; + connectWalletConnect(finalWcUrl, toast); + } + } + }) + .catch(e => console.warn('e', e)); +} diff --git a/src/utils/index.tsx b/src/utils/index.tsx index f7add8934..86fafb36f 100644 --- a/src/utils/index.tsx +++ b/src/utils/index.tsx @@ -10,8 +10,8 @@ import { TransakNetwork, } from '@subwallet/extension-base/background/KoniTypes'; import { KeypairType } from '@polkadot/util-crypto/types'; -import { AccountJson, AccountWithChildren } from '@subwallet/extension-base/background/types'; -import { isAccountAll } from '@subwallet/extension-base/utils'; +import { AccountAuthType, AccountJson, AccountWithChildren } from '@subwallet/extension-base/background/types'; +import { isAccountAll, uniqueStringArray } from '@subwallet/extension-base/utils'; import { decodeAddress, encodeAddress, ethereumEncode, isAddress, isEthereumAddress } from '@polkadot/util-crypto'; import { StyleProp, View } from 'react-native'; import { ColorMap } from 'styles/color'; @@ -24,6 +24,7 @@ import { isValidURL } from 'utils/browser'; import { SUPPORTED_TRANSFER_SUBSTRATE_CHAIN } from 'types/nft'; import { _ChainInfo } from '@subwallet/chain-list/types'; import { Logo as SWLogo } from 'components/design-system-ui'; +import { DEFAULT_ACCOUNT_TYPES, EVM_ACCOUNT_TYPE, SUBSTRATE_ACCOUNT_TYPE } from 'constants/index'; export const PREDEFINED_TRANSAK_NETWORK: Record = { polkadot: { networks: ['mainnet'], @@ -667,3 +668,21 @@ export function isNftTransferSupported(networkKey: string, networkJson: NetworkJ export function isUrl(targetString: string) { return targetString.startsWith('http:') || targetString.startsWith('https:') || targetString.startsWith('wss:'); } + +export const convertKeyTypes = (authTypes: AccountAuthType[]): KeypairType[] => { + const result: KeypairType[] = []; + + for (const authType of authTypes) { + if (authType === 'evm') { + result.push(EVM_ACCOUNT_TYPE); + } else if (authType === 'substrate') { + result.push(SUBSTRATE_ACCOUNT_TYPE); + } else if (authType === 'both') { + result.push(SUBSTRATE_ACCOUNT_TYPE, EVM_ACCOUNT_TYPE); + } + } + + const _rs = uniqueStringArray(result) as KeypairType[]; + + return _rs.length ? _rs : DEFAULT_ACCOUNT_TYPES; +}; diff --git a/src/utils/scanner/walletConnect.ts b/src/utils/scanner/walletConnect.ts new file mode 100644 index 000000000..a0aa291fb --- /dev/null +++ b/src/utils/scanner/walletConnect.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { parseUri } from '@walletconnect/utils'; + +export const validWalletConnectUri = (data: string): string | null => { + try { + const { protocol, topic, version } = parseUri(data); + + if (version === 1) { + return 'Failed to connect. Please use Wallet Connect v2 on dApp'; + } + + if (protocol !== 'wc' || !topic) { + return 'Invalid uri'; + } + } catch (e) { + console.error({ error: e }); + + return (e as Error).message; + } + + return null; +}; diff --git a/src/utils/walletConnect/index.ts b/src/utils/walletConnect/index.ts new file mode 100644 index 000000000..6e28ff6f2 --- /dev/null +++ b/src/utils/walletConnect/index.ts @@ -0,0 +1,106 @@ +import { _ChainInfo } from '@subwallet/chain-list/types'; +import { AbstractAddressJson, AccountJson } from '@subwallet/extension-base/background/types'; +import { + findChainInfoByChainId, + findChainInfoByHalfGenesisHash, +} from '@subwallet/extension-base/services/chain-service/utils'; +import { + WALLET_CONNECT_EIP155_NAMESPACE, + WALLET_CONNECT_POLKADOT_NAMESPACE, +} from '@subwallet/extension-base/services/wallet-connect-service/constants'; +import { SessionTypes } from '@walletconnect/types'; + +import { findAccountByAddress } from '../account'; +import { WalletConnectChainInfo } from 'types/walletConnect'; +import { validWalletConnectUri } from 'utils/scanner/walletConnect'; +import { addConnection } from 'messaging/index'; +import { ToastType } from 'react-native-toast-notifications'; + +export const chainsToWalletConnectChainInfos = ( + chainMap: Record, + chains: string[], +): Array => { + return chains.map(chain => { + const [namespace, info] = chain.split(':'); + + if (namespace === WALLET_CONNECT_EIP155_NAMESPACE) { + const chainInfo = findChainInfoByChainId(chainMap, parseInt(info)); + + return { + chainInfo, + slug: chainInfo?.slug || chain, + supported: !!chainInfo, + }; + } else if (namespace === WALLET_CONNECT_POLKADOT_NAMESPACE) { + const chainInfo = findChainInfoByHalfGenesisHash(chainMap, info); + + return { + chainInfo, + slug: chainInfo?.slug || chain, + supported: !!chainInfo, + }; + } else { + return { + chainInfo: null, + slug: chain, + supported: false, + }; + } + }); +}; + +export const getWCAccountList = ( + accounts: AccountJson[], + namespaces: SessionTypes.Namespaces, +): AbstractAddressJson[] => { + const rawMap: Record = {}; + const rawList = Object.values(namespaces) + .map(namespace => namespace.accounts || []) + .flat(); + + rawList.forEach(info => { + const [, , address] = info.split(':'); + + rawMap[address] = address; + }); + + const convertMap: Record = {}; + const convertList = Object.keys(rawMap).map((address): AbstractAddressJson | null => { + const account = findAccountByAddress(accounts, address); + + if (account) { + return { + address: account.address, + name: account.name, + }; + } else { + return null; + } + }); + + convertList.forEach(info => { + if (info) { + convertMap[info.address] = info; + } + }); + + return Object.values(convertMap); +}; + +export const isValidUri = (uri: string) => { + return !validWalletConnectUri(uri); +}; + +export const connectWalletConnect = (wcUrl: string, toast?: ToastType) => { + if (isValidUri(wcUrl)) { + addConnection({ uri: wcUrl }).catch(e => { + const errMessage = (e as Error).message; + const message = errMessage.includes('Pairing already exists') + ? 'Connection already exists' + : 'Fail to add connection'; + toast?.show(message, { type: 'danger' }); + }); + } else { + toast?.show('Invalid uri'); + } +};