From f0d9f35c762d448e44c249f6412ff5a9516eb247 Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 31 Jan 2024 15:23:04 +0100 Subject: [PATCH] feat: support intent URL to start a new WalletConnect session (#271) ## Description Closes: DPM-190 This PR adds support for a new intent URI to start a new WalletConnect session from an external application. The new intent URI has the following schema: `dpm://wcV2?uri=${WALLET_CONNECT_URI}&returnToApp=${true | false}`. Here are the details about the query parameters: * `uri` - The URL-encoded WalletConnect URI to start the new session; * `returnToApp` - Indicates whether the application should return to the application that has triggered the session start after the session is established. --- ### Author Checklist *All items are required. Please add a note to the item if the item is not applicable and please add links to any relevant follow up issues.* - [x] included the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title - [ ] provided a link to the relevant issue or specification - [x] reviewed "Files changed" and left comments if necessary - [ ] confirmed all CI checks have passed ### Reviewers Checklist *All items are required. Please add a note if the item is not applicable and please add your handle next to the items reviewed if you only reviewed selected items.* - [ ] confirmed the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title - [ ] confirmed all author checklist items have been addressed ## Summary by CodeRabbit - **New Features** - Added support for custom URL schemes to enhance app interoperability. - Enhanced URI action handling with new parsing and caching capabilities. - Improved WalletConnect functionality with new hooks for session management and action handling. - Introduced a more flexible GraphQL client setup to support child components. - Updated theme provider to support child components for better theme management. - **Enhancements** - Refined navigation logic to better handle cached URI actions and return to the current screen accurately. - Streamlined the handling of received actions and WalletConnect session proposals with updated logic and hooks. - Enhanced loading modal styling for better user experience. - **Bug Fixes** - Fixed issues related to WalletConnect session establishment tracking and proposal handling. - **Refactor** - Simplified WalletConnect hooks and removed unnecessary dependencies to streamline the codebase. - Updated the `UnlockApplication` and `WalletConnectRequest` components for improved logic and user flow. - **Documentation** - Added comments to clarify the logic in WalletConnect session request handling. --- android/app/src/main/AndroidManifest.xml | 2 +- ios/DesmosProfileManager/Info.plist | 6 ++ src/App.tsx | 32 ++++++-- src/assets/locales/en/walletConnect.json | 4 +- src/contexts/GraphQLClientProvider.tsx | 4 +- src/contexts/ThemeProvider.tsx | 4 +- ...useTrackWalletConnectSessionEstablished.ts | 7 +- src/hooks/uriactions/useHandleUriAction.tsx | 77 +++++++++++++++++-- .../uriactions/useInitLinkingUriActions.ts | 33 ++++++++ src/hooks/useHandleReceivedActions.ts | 72 +++++++++++++++++ src/hooks/useReturnToCurrentScreen.ts | 11 ++- .../useInitWalletConnectLogic.ts | 4 +- .../useWalletConnectApproveSessionProposal.ts | 28 +++---- .../useWalletConnectOnSessionRequest.ts | 19 ++--- src/lib/UriActions/caching.ts | 17 ++++ src/lib/UriActions/parsing.ts | 21 +++++ src/modals/LoadingModal/useStyles.ts | 1 + src/navigation/RootNavigator/index.tsx | 4 + src/recoil/uriaction.ts | 31 ++++++++ src/screens/DevScreen/index.tsx | 9 ++- src/screens/UnlockApplication/index.tsx | 44 ++++------- src/screens/WalletConnectRequest/index.tsx | 26 +++++-- src/screens/WalletConnectRequest/useHooks.ts | 13 +++- .../WalletConnectSessionProposal/index.tsx | 35 ++++++--- src/types/uri.ts | 25 +++++- src/types/walletConnect.ts | 1 - 26 files changed, 419 insertions(+), 111 deletions(-) create mode 100644 src/hooks/uriactions/useInitLinkingUriActions.ts create mode 100644 src/hooks/useHandleReceivedActions.ts create mode 100644 src/recoil/uriaction.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 20b93e8f8..7cf7fe230 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -73,11 +73,11 @@ + - diff --git a/ios/DesmosProfileManager/Info.plist b/ios/DesmosProfileManager/Info.plist index 6b63945bc..f11b3158d 100644 --- a/ios/DesmosProfileManager/Info.plist +++ b/ios/DesmosProfileManager/Info.plist @@ -32,6 +32,12 @@ dpm + + CFBundleURLSchemes + + dpm + + CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/src/App.tsx b/src/App.tsx index 994d4637c..0c4ba61ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import SnackBarProvider from 'lib/SnackBarProvider'; import DesmosPostHogProvider from 'components/DesmosPostHogProvider'; import useCheckKeyChainIntegrity from 'hooks/dataintegrity/useCheckKeyChainIntegrity'; import useInitNotifications from 'hooks/notifications/useInitNotifications'; +import { getCachedUriAction, isUriActionPending, onCachedUriActionChange } from 'lib/UriActions'; +import { useSetUriAction } from '@recoil/uriaction'; const AppLockLogic = () => { useLockApplicationOnBlur(); @@ -20,14 +22,28 @@ const AppLockLogic = () => { return null; }; -const Navigation = () => ( - RNBootSplash.hide({ fade: true, duration: 500 })}> - - - - - -); +const Navigation = () => { + const setUriAction = useSetUriAction(); + + React.useEffect(() => { + if (isUriActionPending()) { + const action = getCachedUriAction(); + if (action) { + setUriAction(action); + } + } + return onCachedUriActionChange(setUriAction); + }, [setUriAction]); + + return ( + RNBootSplash.hide({ fade: true, duration: 500 })}> + + + + + + ); +}; const App = () => ( diff --git a/src/assets/locales/en/walletConnect.json b/src/assets/locales/en/walletConnect.json index 399fbffbc..29d21a880 100644 --- a/src/assets/locales/en/walletConnect.json +++ b/src/assets/locales/en/walletConnect.json @@ -15,6 +15,8 @@ "reject": "Reject", "authorization": "Authorization", "app authorized": "{{app}} authorized", + "app authorized, you can return to the app": "{{app}} authorized, you can return to the app", "go to authorization": "Go to authorization", - "error while closing session": "Ooops an error occurred while terminating the session:\n{{error}}" + "error while closing session": "Ooops an error occurred while terminating the session:\n{{error}}", + "initializing new session": "Initializing new WalletConnect session..." } diff --git a/src/contexts/GraphQLClientProvider.tsx b/src/contexts/GraphQLClientProvider.tsx index 118d61be4..114e50eba 100644 --- a/src/contexts/GraphQLClientProvider.tsx +++ b/src/contexts/GraphQLClientProvider.tsx @@ -1,8 +1,8 @@ import { ApolloProvider } from '@apollo/client'; -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import useGraphQLClient from 'services/graphql/client'; -const GraphQLClientProvider: React.FC = ({ children }) => { +const GraphQLClientProvider: React.FC = ({ children }) => { const client = useGraphQLClient(); return {children}; }; diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index 5d654d6fa..9ded771a8 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { Appearance, NativeEventSubscription } from 'react-native'; import { Provider as PaperProvider } from 'react-native-paper'; import { Settings } from 'react-native-paper/lib/typescript/core/settings'; @@ -12,7 +12,7 @@ const PaperProviderSettings: Settings = { icon: (props) => , }; -const ThemeProvider: React.FC = ({ children }) => { +const ThemeProvider: React.FC = ({ children }) => { const settings = useSettings(); const [appTheme, setAppTheme] = useState(LightTheme); const colorScheme = useDebouncingColorScheme(); diff --git a/src/hooks/analytics/useTrackWalletConnectSessionEstablished.ts b/src/hooks/analytics/useTrackWalletConnectSessionEstablished.ts index 7e0e6b299..0f632906c 100644 --- a/src/hooks/analytics/useTrackWalletConnectSessionEstablished.ts +++ b/src/hooks/analytics/useTrackWalletConnectSessionEstablished.ts @@ -1,5 +1,4 @@ import React from 'react'; -import { SessionTypes } from '@walletconnect/types'; import useTrackEvent from 'hooks/analytics/useTrackEvent'; import { Events } from 'types/analytics'; @@ -11,11 +10,11 @@ const useTrackWalletConnectSessionEstablished = () => { const trackEvent = useTrackEvent(); return React.useCallback( - (session: SessionTypes.Struct) => { + (appName: string, namespaces: any) => { trackEvent(Events.WalletConnectSessionEstablished, { CreationTime: new Date().toISOString(), - ApplicationName: session.peer.metadata.name, - Namespaces: session.requiredNamespaces, + ApplicationName: appName, + Namespaces: namespaces, }); }, [trackEvent], diff --git a/src/hooks/uriactions/useHandleUriAction.tsx b/src/hooks/uriactions/useHandleUriAction.tsx index a6e865137..c22d1b42b 100644 --- a/src/hooks/uriactions/useHandleUriAction.tsx +++ b/src/hooks/uriactions/useHandleUriAction.tsx @@ -7,18 +7,24 @@ import { UriActionType, UserAddressActionUri, ViewProfileActionUri, + WalletConnectPairActionUri, } from 'types/uri'; import useShowModal from 'hooks/useShowModal'; import GenericUriActionModal from 'modals/GenericUriActionModal'; -import { getCachedUriAction } from 'lib/UriActions'; import { useNavigation } from '@react-navigation/native'; import { RootNavigatorParamList } from 'navigation/RootNavigator'; import { StackNavigationProp } from '@react-navigation/stack'; import ROUTES from 'navigation/routes'; import useRequestChainChange from 'hooks/chainselect/useRequestChainChange'; -import { Trans } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { chainTypeToChainName } from 'lib/FormatUtils'; import Typography from 'components/Typography'; +import { useActiveAccountAddress } from '@recoil/activeAccount'; +import useWalletConnectPair from 'hooks/walletconnect/useWalletConnectPair'; +import ErrorModal from 'modals/ErrorModal'; +import LoadingModal from 'modals/LoadingModal'; +import useModal from 'hooks/useModal'; +import { useGetUriAction, useSetUriAction } from '@recoil/uriaction'; const useHandleGenericAction = () => { const showModal = useShowModal(); @@ -97,6 +103,40 @@ const useHandleSendTokensAction = () => { ); }; +const useHandleWalletConnectPairAction = () => { + const { t } = useTranslation('walletConnect'); + const activeAccountAddress = useActiveAccountAddress(); + const pair = useWalletConnectPair(); + const navigation = useNavigation>(); + const { showModal, hideModal } = useModal(); + + return React.useCallback( + async (action: WalletConnectPairActionUri) => { + if (activeAccountAddress === undefined) { + // Ignore it if we don't have an account. + return; + } + + showModal(LoadingModal, { + text: t('initializing new session'), + }); + const pairResult = await pair(action.uri); + hideModal(); + if (pairResult.isErr()) { + showModal(ErrorModal, { + text: pairResult.error.message, + }); + } else { + navigation.navigate(ROUTES.WALLET_CONNECT_SESSION_PROPOSAL, { + proposal: pairResult.value, + returnToApp: action.returnToApp, + }); + } + }, + [activeAccountAddress, hideModal, navigation, pair, showModal, t], + ); +}; + /** * Hook that provides a function that will handle the uri action * if present. @@ -105,18 +145,29 @@ const useHandleUriAction = () => { const handleGenericAction = useHandleGenericAction(); const handleViewProfileAction = useHandleViewProfileAction(); const handleSendTokensAction = useHandleSendTokensAction(); + const handleWalletConnectPairAction = useHandleWalletConnectPairAction(); + const getUriAction = useGetUriAction(); + const setUriAction = useSetUriAction(); return React.useCallback( - (uriAction?: UriAction, genericActionOverride?: GenericActionsTypes) => { - const action = uriAction ?? getCachedUriAction(); + async (uriAction?: UriAction, genericActionOverride?: GenericActionsTypes) => { + const globalAction = await getUriAction(); + const action = uriAction ?? globalAction; + + // Clear the global action if is the one that + // we are handling. + if (action === globalAction) { + // Clear the global action. + setUriAction(undefined); + } + if (action !== undefined) { let toHandleAction = action.type; if (toHandleAction === UriActionType.Generic && genericActionOverride !== undefined) { toHandleAction = genericActionOverride; } - - // In the following switch-cases we need to berform some casting - // becouse ts is not able to infer the type of action + // In the following switch-cases we need to perform some casting + // because ts is not able to infer the type of action // since we are performing the switch on another variable instead // of action.type. switch (toHandleAction) { @@ -130,12 +181,22 @@ const useHandleUriAction = () => { case UriActionType.SendTokens: handleSendTokensAction(action as SendTokensActionUri | GenericActionUri); break; + case UriActionType.WalletConnectPair: + handleWalletConnectPairAction(action as WalletConnectPairActionUri); + break; default: break; } } }, - [handleGenericAction, handleSendTokensAction, handleViewProfileAction], + [ + getUriAction, + handleGenericAction, + handleSendTokensAction, + handleViewProfileAction, + handleWalletConnectPairAction, + setUriAction, + ], ); }; diff --git a/src/hooks/uriactions/useInitLinkingUriActions.ts b/src/hooks/uriactions/useInitLinkingUriActions.ts new file mode 100644 index 000000000..1b1d369d8 --- /dev/null +++ b/src/hooks/uriactions/useInitLinkingUriActions.ts @@ -0,0 +1,33 @@ +import { setCachedUriAction, parseNativeActionUri } from 'lib/UriActions'; +import React from 'react'; +import { Linking } from 'react-native'; + +/** + * Hook that initialize the logic to handle the + * actions received from the Linking library. + */ +const useInitLinkingUriActions = () => { + const handleLinkingUrl = React.useCallback((url: string | null) => { + if (url) { + const action = parseNativeActionUri(url); + if (action) { + setCachedUriAction(action); + } + } + }, []); + + React.useEffect(() => { + // Handle the uri that has triggered the app open. + Linking.getInitialURL().then(handleLinkingUrl); + + // Handle the uri received while the app was + // in the background. + const listener = Linking.addEventListener('url', ({ url }) => { + handleLinkingUrl(url); + }); + + return () => listener.remove(); + }, [handleLinkingUrl]); +}; + +export default useInitLinkingUriActions; diff --git a/src/hooks/useHandleReceivedActions.ts b/src/hooks/useHandleReceivedActions.ts new file mode 100644 index 000000000..5b5fd47bb --- /dev/null +++ b/src/hooks/useHandleReceivedActions.ts @@ -0,0 +1,72 @@ +import { useAppState } from '@recoil/appState'; +import { useUriAction } from '@recoil/uriaction'; +import { useAllWalletConnectSessionsRequests } from '@recoil/walletConnectRequests'; +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { RootNavigatorParamList } from 'navigation/RootNavigator'; +import { StackNavigationProp } from '@react-navigation/stack'; +import ROUTES from 'navigation/routes'; +import useHandleUriAction from './uriactions/useHandleUriAction'; + +/** + * Hook that provides the logic to handle the requests received + * while the application was in background. + * The actions that the app can receive while in background are: + * - URIActions triggered by a deep link or from a system intent; + * - WalletConnect session requests. + */ +export default function useHandleReceivedActions() { + const appState = useAppState(); + + const uriAction = useUriAction(); + const handlingUriAction = React.useRef(false); + const handleUriAction = useHandleUriAction(); + + const walletConnectRequests = useAllWalletConnectSessionsRequests(); + const handlingWalletConnectRequests = React.useRef(false); + const navigation = useNavigation>(); + + React.useEffect(() => { + // Prevent the execution if we are already handling an action. + if (handlingUriAction.current) { + if (uriAction === undefined) { + handlingUriAction.current = false; + } else { + return; + } + } + + // Prevent the execution if we are handling the WalletConnect requests. + if (handlingWalletConnectRequests.current) { + if (walletConnectRequests.length === 0) { + handlingWalletConnectRequests.current = false; + } else { + return; + } + } + + // Ensure that the app is active and unlocked before performing any operation. + if (appState.locked === false && appState.lastObBlur === undefined) { + // We give priority to uri actions received from a deep link + // or a system intent. + if (uriAction !== undefined) { + handleUriAction(uriAction); + handlingUriAction.current = true; + return; + } + + if (walletConnectRequests.length > 0) { + // Navigate to the screen that handle the WalletConnect requests. + navigation.navigate(ROUTES.WALLET_CONNECT_REQUEST); + handlingWalletConnectRequests.current = true; + } + } + }, [ + appState.lastObBlur, + appState.locked, + handleUriAction, + navigation, + uriAction, + walletConnectRequests, + ]); +} diff --git a/src/hooks/useReturnToCurrentScreen.ts b/src/hooks/useReturnToCurrentScreen.ts index bcdc829ba..b1266b98d 100644 --- a/src/hooks/useReturnToCurrentScreen.ts +++ b/src/hooks/useReturnToCurrentScreen.ts @@ -14,7 +14,13 @@ const useReturnToCurrentScreen = () => { const navigator = useNavigation>(); const startingScreenNavigateParams = useMemo(() => { - const { routes } = navigator.getState(); + const { routes } = navigator.getState() ?? { routes: [] }; + // Handle the case where we default to the empty routes in case + // this hook is called while the navigator instance is not ready. + if (routes.length === 0) { + return undefined; + } + let currentRoute = routes[routes.length - 1]; // Check if the screen that we should return to is one of the modal that we @@ -32,8 +38,9 @@ const useReturnToCurrentScreen = () => { return useCallback(() => { const canNavigate = + startingScreenNavigateParams !== undefined && navigator.getState().routes.find((r) => r.key === startingScreenNavigateParams.key) !== - undefined; + undefined; if (canNavigate) { navigator.navigate(startingScreenNavigateParams); } diff --git a/src/hooks/walletconnect/useInitWalletConnectLogic.ts b/src/hooks/walletconnect/useInitWalletConnectLogic.ts index 0898533ae..f4eed3ddc 100644 --- a/src/hooks/walletconnect/useInitWalletConnectLogic.ts +++ b/src/hooks/walletconnect/useInitWalletConnectLogic.ts @@ -63,8 +63,8 @@ const useInitWalletConnectLogic = () => { .filter((s) => !activeSessions.find((as) => as.topic === s.topic)) .forEach(({ topic }) => deleteSessionByTopic(topic)); - state.client.pendingRequest.values.forEach((pendingRequest, index, array) => { - onSessionRequest(state.client, pendingRequest, array.length - 1 === index); + state.client.pendingRequest.values.forEach((pendingRequest) => { + onSessionRequest(state.client, pendingRequest); }); } }, [deleteSessionByTopic, onSessionRequest, savedSessions, state]); diff --git a/src/hooks/walletconnect/useWalletConnectApproveSessionProposal.ts b/src/hooks/walletconnect/useWalletConnectApproveSessionProposal.ts index 398227006..00ccd823b 100644 --- a/src/hooks/walletconnect/useWalletConnectApproveSessionProposal.ts +++ b/src/hooks/walletconnect/useWalletConnectApproveSessionProposal.ts @@ -4,7 +4,7 @@ import { getAccountSupportedMethods } from 'lib/WalletConnectUtils'; import { useStoreWalletConnectSession } from '@recoil/walletConnectSessions'; import { WalletConnectSessionProposal } from 'types/walletConnect'; import useTrackWalletConnectSessionEstablished from 'hooks/analytics/useTrackWalletConnectSessionEstablished'; -import { err, ok } from 'neverthrow'; +import { Result, err, ok } from 'neverthrow'; import { promiseToResult } from 'lib/NeverThrowUtils'; import useGetOrConnectWalletConnectClient from './useGetOrConnectWalletConnetClient'; @@ -18,7 +18,7 @@ const useWalletConnectApproveSessionProposal = () => { const trackSessionEstablished = useTrackWalletConnectSessionEstablished(); return useCallback( - async (proposal: WalletConnectSessionProposal) => { + async (proposal: WalletConnectSessionProposal): Promise> => { if (activeAccount === undefined) { return err(new Error('active account is undefined')); } @@ -52,28 +52,18 @@ const useWalletConnectApproveSessionProposal = () => { } const approveResponse = approveResponseResult.value; - const sessionResult = await promiseToResult( - approveResponse.acknowledged(), - 'Unknown error while approving the session proposal', - ); - if (sessionResult.isErr()) { - return err(sessionResult.error); - } - - const session = sessionResult.value; - trackSessionEstablished(session); + trackSessionEstablished(proposal.name, proposal.proposal.requiredNamespaces); storeSession(activeAccount.address, { accountAddress: activeAccount.address, - topic: session.topic, + topic: approveResponse.topic, creationDate: new Date().toISOString(), - description: session.peer.metadata.description, - name: session.peer.metadata.name, - icon: session.peer.metadata.icons[0], - url: session.peer.metadata.url, + description: proposal.description, + name: proposal.name, + icon: proposal.iconUri, }); - return ok(session); + return ok(undefined); }, - [activeAccount, getClient, trackSessionEstablished, storeSession], + [activeAccount, getClient, storeSession, trackSessionEstablished], ); }; diff --git a/src/hooks/walletconnect/useWalletConnectOnSessionRequest.ts b/src/hooks/walletconnect/useWalletConnectOnSessionRequest.ts index 14e60bd81..b78a9b4ae 100644 --- a/src/hooks/walletconnect/useWalletConnectOnSessionRequest.ts +++ b/src/hooks/walletconnect/useWalletConnectOnSessionRequest.ts @@ -2,9 +2,6 @@ import SignClient from '@walletconnect/sign-client'; import { useStoredAccounts } from '@recoil/accounts'; import { useGetSessionByTopic } from '@recoil/walletConnectSessions'; import { useStoreWalletConnectSessionRequest } from '@recoil/walletConnectRequests'; -import { useNavigation } from '@react-navigation/native'; -import { StackNavigationProp } from '@react-navigation/stack'; -import { RootNavigatorParamList } from 'navigation/RootNavigator'; import { useCallback } from 'react'; import { SignClientTypes } from '@walletconnect/types'; import { getSdkError } from '@walletconnect/utils'; @@ -13,20 +10,14 @@ import { CosmosRPCMethods, encodeGetAccountsRpcResponse, } from '@desmoslabs/desmjs-walletconnect-v2'; -import ROUTES from 'navigation/routes'; const useWalletConnectOnSessionRequest = () => { const accounts = useStoredAccounts(); const getSessionByTopic = useGetSessionByTopic(); const storeSessionRequest = useStoreWalletConnectSessionRequest(); - const navigation = useNavigation>(); return useCallback( - ( - signClient: SignClient, - args: SignClientTypes.EventArguments['session_request'], - showRequest?: boolean, - ) => { + async (signClient: SignClient, args: SignClientTypes.EventArguments['session_request']) => { // Find the session from the app state sessions. const session = getSessionByTopic(args.topic); if (session === undefined) { @@ -85,10 +76,10 @@ const useWalletConnectOnSessionRequest = () => { break; case CosmosRPCMethods.SignAmino: case CosmosRPCMethods.SignDirect: + // In this case we just store the request since + // the logic to display the request to the user is inside the + // useHandleReceivedActions hook. storeSessionRequest(decodedRequest); - if (showRequest !== false) { - navigation.navigate(ROUTES.WALLET_CONNECT_REQUEST); - } break; default: signClient.respond({ @@ -102,7 +93,7 @@ const useWalletConnectOnSessionRequest = () => { break; } }, - [accounts, getSessionByTopic, navigation, storeSessionRequest], + [accounts, getSessionByTopic, storeSessionRequest], ); }; diff --git a/src/lib/UriActions/caching.ts b/src/lib/UriActions/caching.ts index edb61ef43..1c46fc6bf 100644 --- a/src/lib/UriActions/caching.ts +++ b/src/lib/UriActions/caching.ts @@ -1,13 +1,18 @@ +import EventEmitter from 'events'; import { UriAction } from 'types/uri'; let CachedUriAction: UriAction | undefined; +const NEW_ACTION_EVENT = 'new_action'; +const UriActionEventEmitter = new EventEmitter(); + /** * Updates the cached uri action. * @param action - The new uri action. */ export const setCachedUriAction = (action: UriAction): void => { CachedUriAction = action; + UriActionEventEmitter.emit(NEW_ACTION_EVENT, action); }; /** @@ -26,3 +31,15 @@ export const getCachedUriAction = (): UriAction | undefined => { * to be handled. */ export const isUriActionPending = (): boolean => CachedUriAction !== undefined; + +/** + * Function to subscribe to the event raised when a new {@link UriAction} + * is received. + * @param callback - Callback that will be called when a new {@link UriAction} is received. + */ +export function onCachedUriActionChange(callback: (action: UriAction) => void) { + UriActionEventEmitter.addListener(NEW_ACTION_EVENT, callback); + return () => { + UriActionEventEmitter.removeListener(NEW_ACTION_EVENT, callback); + }; +} diff --git a/src/lib/UriActions/parsing.ts b/src/lib/UriActions/parsing.ts index 48a08bb42..648173266 100644 --- a/src/lib/UriActions/parsing.ts +++ b/src/lib/UriActions/parsing.ts @@ -118,3 +118,24 @@ export const actionUriFromRecord = (data: Record): UriAction | unde return parser(data); }; + +/** + * Generates a {@link UriAction} received from the Linking library. + * @param uri - The uri to be parsed. + * @returns - The parsed action if it is valid, otherwise undefined. + */ +export const parseNativeActionUri = (uri: string): UriAction | undefined => { + try { + const url = new URL(uri); + if (url.hostname === 'wcV2' && url.searchParams.has('uri')) { + return { + type: UriActionType.WalletConnectPair, + uri: url.searchParams.get('uri')!, + returnToApp: url.searchParams.get('returnToApp') === 'true', + }; + } + } catch (e) { + return undefined; + } + return undefined; +}; diff --git a/src/modals/LoadingModal/useStyles.ts b/src/modals/LoadingModal/useStyles.ts index 50b648752..b36a99525 100644 --- a/src/modals/LoadingModal/useStyles.ts +++ b/src/modals/LoadingModal/useStyles.ts @@ -9,6 +9,7 @@ const useStyles = makeStyle((theme) => ({ title: { marginTop: theme.spacing.s, alignSelf: 'center', + textAlign: 'center', }, })); diff --git a/src/navigation/RootNavigator/index.tsx b/src/navigation/RootNavigator/index.tsx index 1271a9263..826a1f715 100644 --- a/src/navigation/RootNavigator/index.tsx +++ b/src/navigation/RootNavigator/index.tsx @@ -62,6 +62,8 @@ import { NavigatorScreenParams } from '@react-navigation/native'; import { useSetting } from '@recoil/settings'; import SettingsSwitchScreen, { SettingsSwitchScreenProps } from 'screens/SettingsSwitchScreen'; import useWalletConnectAutoReconnect from 'hooks/walletconnect/useWalletConnectAutoReconnect'; +import useInitLinkingUriActions from 'hooks/uriactions/useInitLinkingUriActions'; +import useHandleReceivedActions from 'hooks/useHandleReceivedActions'; export type RootNavigatorParamList = { [ROUTES.DEV_SCREEN]: undefined; @@ -130,6 +132,8 @@ const RootNavigator = () => { // Hook to update all the profiles, this will also take care of updating // the profiles when the user change the chain. useUpdateAccountsProfiles(); + useInitLinkingUriActions(); + useHandleReceivedActions(); const initialRouteName = useMemo(() => { if (__DEV__) { diff --git a/src/recoil/uriaction.ts b/src/recoil/uriaction.ts new file mode 100644 index 000000000..982ec4d7f --- /dev/null +++ b/src/recoil/uriaction.ts @@ -0,0 +1,31 @@ +import { atom, useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; +import { UriAction } from 'types/uri'; + +/** + * Atom that contains the {@link UriAction} that the application + * should handle. + */ +const uriActionAtom = atom({ + key: 'uriActionAppState', + default: undefined, +}); + +/** + * Hook that provides the current {@link UriAction} that need to be handled. + */ +export const useUriAction = () => useRecoilValue(uriActionAtom); + +/** + * Hook that provides the setter for the {@link UriAction} that need to be handled. + */ +export const useSetUriAction = () => useSetRecoilState(uriActionAtom); + +/** + * Hook that provides a function to get the curret {@link UriAction}. + */ +export const useGetUriAction = () => + useRecoilCallback( + ({ snapshot }) => + async () => + snapshot.getPromise(uriActionAtom), + ); diff --git a/src/screens/DevScreen/index.tsx b/src/screens/DevScreen/index.tsx index 8eeff399e..9466d16c9 100644 --- a/src/screens/DevScreen/index.tsx +++ b/src/screens/DevScreen/index.tsx @@ -20,8 +20,8 @@ import AmountAndMemoModal from 'modals/AmountAndMemoModal'; import { AmountLimit } from 'components/CoinAmountInput/limits'; import { resetSecureStorage } from 'lib/SecureStorage'; import useHandleUriAction from 'hooks/uriactions/useHandleUriAction'; -import { isUriActionPending } from 'lib/UriActions'; import LoadingModal from 'modals/LoadingModal'; +import { useUriAction } from '@recoil/uriaction'; import useStyles from './useStyles'; enum DevRoutes { @@ -62,6 +62,7 @@ const DevScreen: FC = ({ navigation }) => { const showModal = useShowModal(); const appFeatureFlags = useAppFeatureFlags(); const handleUriAction = useHandleUriAction(); + const uriAction = useUriAction(); const itemSeparator = React.useCallback(() => , []); const showTestTransaction = useShowTestTransaction(); @@ -198,7 +199,11 @@ const DevScreen: FC = ({ navigation }) => { - diff --git a/src/screens/UnlockApplication/index.tsx b/src/screens/UnlockApplication/index.tsx index 34cf2ef17..5d5d27ca0 100644 --- a/src/screens/UnlockApplication/index.tsx +++ b/src/screens/UnlockApplication/index.tsx @@ -16,9 +16,9 @@ import { useSetAppState } from '@recoil/appState'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import useGetPasswordFromBiometrics from 'hooks/useGetPasswordFromBiometrics'; import Spacer from 'components/Spacer'; -import useHandleUriAction from 'hooks/uriactions/useHandleUriAction'; import DKeyboardAvoidingView from 'components/DKeyboardAvoidingView'; import { Platform } from 'react-native'; +import useResetToHomeScreen from 'hooks/navigation/useResetToHomeScreen'; import useStyles from './useStyles'; // Development related @@ -31,11 +31,10 @@ type NavProps = StackScreenProps = (props) => { + const { navigation } = props; const { t } = useTranslation('account'); - const styles = useStyles(); - - const { navigation } = props; + const resetToHome = useResetToHomeScreen(); // -------------------------------------------------------------------------------------- // --- Hooks @@ -56,8 +55,6 @@ const UnlockApplication: React.FC = (props) => { return { key: currentRoute.key, params: currentRoute.params }; }, [navigation]); - const handleUriAction = useHandleUriAction(); - // -------------------------------------------------------------------------------------- // --- Local state // -------------------------------------------------------------------------------------- @@ -76,19 +73,20 @@ const UnlockApplication: React.FC = (props) => { if (!__DEV__ && ALLOWED_NAVIGATION_ACTIONS.indexOf(e.data.action.type) === -1) { e.preventDefault(); } else { - // Unlock the application - setAppState((currentState) => ({ - ...currentState, - locked: false, - noLockOnBackground: false, - })); + // Give time to the application to close the screen + // before marking it as unlocked. + setTimeout(() => { + // Unlock the application + setAppState((currentState) => ({ + ...currentState, + locked: false, + noLockOnBackground: false, + })); + }, 200); } }; - navigation.addListener('beforeRemove', listener); - return () => { - navigation.removeListener('beforeRemove', listener); - }; + return navigation.addListener('beforeRemove', listener); }, [navigation, setAppState]); // Use the biometrics authentication if the user has enabled it @@ -121,21 +119,11 @@ const UnlockApplication: React.FC = (props) => { navigation.navigate(previousScreenParams); } else { // Reset to home screen. - navigation.reset({ - index: 0, - routes: [{ name: ROUTES.HOME_TABS }], - }); - } - - if (passwordOk) { - // The application has been unlocked correctly, - // handle the uri action. - handleUriAction(); + resetToHome(); } - setLoading(false); }, - [handleUriAction, navigation, previousScreenParams, t], + [navigation, previousScreenParams, resetToHome, t], ); const unlockWithBiometrics = useCallback(async () => { diff --git a/src/screens/WalletConnectRequest/index.tsx b/src/screens/WalletConnectRequest/index.tsx index 0f9a4d3ba..7df5bd03c 100644 --- a/src/screens/WalletConnectRequest/index.tsx +++ b/src/screens/WalletConnectRequest/index.tsx @@ -20,7 +20,8 @@ import useUnlockWallet from 'hooks/useUnlockWallet'; import { SigningMode } from '@desmoslabs/desmjs'; import SingleButtonModal from 'modals/SingleButtonModal'; import useShowModal from 'hooks/useShowModal'; -import { useRequestFields } from 'screens/WalletConnectRequest/useHooks'; +import { useRemoveWalletConnectSessionRequest } from '@recoil/walletConnectRequests'; +import { useRejectAllRequests, useRequestFields } from './useHooks'; import useStyles from './useStyles'; type NavProps = StackScreenProps; @@ -35,20 +36,33 @@ const WalletConnectRequest: React.FC = (props) => { const walletConnectReject = useWalletConnectRequestReject(); const showModal = useShowModal(); const { request, memo, stdFee, messages } = useRequestFields(); + const rejectAllRequest = useRejectAllRequests(); + const removeRequest = useRemoveWalletConnectSessionRequest(); + + useEffect( + () => + navigation.addListener('beforeRemove', (e) => { + if (e.data.action.type === 'GO_BACK') { + rejectAllRequest(); + } + }), + [navigation, rejectAllRequest], + ); useEffect(() => { // Close the screen when we have processed all the requests. if (request === undefined) { - navigation.goBack(); + navigation.pop(); } }, [request, navigation]); const showErrorModal = useCallback( - (errorMsg: string) => { + (errorMsg: string, action?: () => void) => { showModal(SingleButtonModal, { title: t('error'), message: errorMsg, actionLabel: t('ok'), + action, }); }, [showModal, t], @@ -58,10 +72,12 @@ const WalletConnectRequest: React.FC = (props) => { if (request !== undefined) { const rejectResult = await walletConnectReject(request, getSdkError('USER_REJECTED')); if (rejectResult.isErr()) { - showErrorModal(rejectResult.error.message); + showErrorModal(rejectResult.error.message, () => { + removeRequest(request); + }); } } - }, [request, showErrorModal, walletConnectReject]); + }, [removeRequest, request, showErrorModal, walletConnectReject]); const onApprove = useCallback(async () => { if (request !== undefined) { diff --git a/src/screens/WalletConnectRequest/useHooks.ts b/src/screens/WalletConnectRequest/useHooks.ts index 255e7f422..8f16f4bbe 100644 --- a/src/screens/WalletConnectRequest/useHooks.ts +++ b/src/screens/WalletConnectRequest/useHooks.ts @@ -1,9 +1,11 @@ import { StdFee } from '@cosmjs/amino'; import { EncodeObject } from '@desmoslabs/desmjs'; import { CosmosRPCMethods } from '@desmoslabs/desmjs-walletconnect-v2'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { WalletConnectRequest as Request } from 'types/walletConnect'; import { useAllWalletConnectSessionsRequests } from '@recoil/walletConnectRequests'; +import useWalletConnectRequestReject from 'hooks/walletconnect/useWalletConnectRequestReject'; +import { getSdkError } from '@walletconnect/utils'; export const useRequestFields = () => { const requests = useAllWalletConnectSessionsRequests(); @@ -47,3 +49,12 @@ export const useRequestFields = () => { return { request, stdFee, memo, messages }; }; + +export const useRejectAllRequests = () => { + const requests = useAllWalletConnectSessionsRequests(); + const rejectRequest = useWalletConnectRequestReject(); + + return React.useCallback(() => { + requests.forEach((request) => rejectRequest(request, getSdkError('USER_REJECTED'))); + }, [requests, rejectRequest]); +}; diff --git a/src/screens/WalletConnectSessionProposal/index.tsx b/src/screens/WalletConnectSessionProposal/index.tsx index b3a3a1c92..c03332cac 100644 --- a/src/screens/WalletConnectSessionProposal/index.tsx +++ b/src/screens/WalletConnectSessionProposal/index.tsx @@ -16,42 +16,57 @@ import useWalletConnectRejectSessionProposal from 'hooks/walletconnect/useWallet import { DPMImages } from 'types/images'; import { walletConnectIconUriToImageSource } from 'lib/WalletConnectUtils'; import FastImage from 'react-native-fast-image'; +import useResetToHomeScreen from 'hooks/navigation/useResetToHomeScreen'; import useStyles from './useStyles'; export interface WalletConnectSessionProposalParams { proposal: Proposal; + /** + * Tells if the application should return to the app that + * has triggered the paring request after the session is approved. + */ + returnToApp?: boolean; } type NavProps = StackScreenProps; const WalletConnectSessionProposal: FC = (props) => { const { route, navigation } = props; - const { proposal } = route.params; + const { proposal, returnToApp } = route.params; const { t } = useTranslation('walletConnect'); const styles = useStyles(); const openModal = useShowModal(); const approveSession = useWalletConnectApproveSessionProposal(); const rejectSession = useWalletConnectRejectSessionProposal(); + const resetToHome = useResetToHomeScreen(); const [authorizing, setAuthorizing] = useState(false); const [rejecting, setRejecting] = useState(false); const appName = useMemo(() => proposal.name, [proposal]); - const dAppIcon = useMemo(() => walletConnectIconUriToImageSource(proposal.iconUri), [proposal]); const showSuccessModal = useCallback(() => { openModal(SingleButtonModal, { image: DPMImages.Success, title: t('common:success'), - message: t('app authorized', { app: appName }), - actionLabel: t('go to authorization'), - action: () => - navigation.reset({ - index: 0, - routes: [{ name: ROUTES.HOME_TABS, params: { screen: ROUTES.WALLET_CONNECT_SESSIONS } }], - }), + message: returnToApp + ? t('app authorized, you can return to the app', { app: appName }) + : t('app authorized', { app: appName }), + actionLabel: returnToApp ? t('common:ok') : t('go to authorization'), + action: () => { + if (returnToApp) { + resetToHome(); + } else { + navigation.reset({ + index: 0, + routes: [ + { name: ROUTES.HOME_TABS, params: { screen: ROUTES.WALLET_CONNECT_SESSIONS } }, + ], + }); + } + }, }); - }, [appName, navigation, openModal, t]); + }, [appName, navigation, openModal, resetToHome, returnToApp, t]); const showErrorModal = useCallback( (errorMsg: string) => { diff --git a/src/types/uri.ts b/src/types/uri.ts index bde902349..3ac42c7fa 100644 --- a/src/types/uri.ts +++ b/src/types/uri.ts @@ -27,6 +27,11 @@ export enum UriActionType { * to send some tokens to a user. */ SendTokens = 'send_tokens', + /** + * Type representing a URI action that tells the application + * to create a new wallet connect session. + */ + WalletConnectPair = 'walletconnect_pair', } /** @@ -100,8 +105,26 @@ export interface SendTokensActionUri { readonly amount?: Coin; } +/** + * Interface representing a URI action that tells the application + * to create a new wallet connect session. + */ +export interface WalletConnectPairActionUri { + readonly type: UriActionType.WalletConnectPair; + /** + * The wallet connect URI to be used. + */ + readonly uri: string; + /** + * Indicates whether the application should return to the app that has + * triggered the pairing request after the user has accepted the pairing request. + */ + readonly returnToApp?: boolean; +} + export type UriAction = | UserAddressActionUri | GenericActionUri | ViewProfileActionUri - | SendTokensActionUri; + | SendTokensActionUri + | WalletConnectPairActionUri; diff --git a/src/types/walletConnect.ts b/src/types/walletConnect.ts index b247c35d3..b46d69a27 100644 --- a/src/types/walletConnect.ts +++ b/src/types/walletConnect.ts @@ -69,7 +69,6 @@ export interface WalletConnectSession { readonly icon: string | undefined; readonly name: string; readonly description: string | undefined; - readonly url: string; // RFC 3339 date readonly creationDate: string; }