diff --git a/packages/@magic-sdk/react-native-bare/jest.config.ts b/packages/@magic-sdk/react-native-bare/jest.config.ts index a5e166e8b..591c76e49 100644 --- a/packages/@magic-sdk/react-native-bare/jest.config.ts +++ b/packages/@magic-sdk/react-native-bare/jest.config.ts @@ -8,7 +8,7 @@ const config: Config.InitialOptions = { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, transformIgnorePatterns: [ - 'node_modules/(?!((react-native|@magic-sdk/provider|@magic-sdk/react-native.*|react-navigation|@react-native|react-native-gesture-handler|react-native-event-listeners)/).*)', + 'node_modules/(?!((react-native|@magic-sdk/provider|@magic-sdk/react-native.*|react-navigation|@react-native|react-native-gesture-handler|react-native-event-listeners|react-native-inappbrowser-reborn)/).*)', ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; diff --git a/packages/@magic-sdk/react-native-bare/src/lib/links.ts b/packages/@magic-sdk/react-native-bare/src/lib/links.ts new file mode 100644 index 000000000..7f5267642 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/lib/links.ts @@ -0,0 +1,11 @@ +import { Linking } from 'react-native'; + +export const openInBrowser = async (url: string) => { + const supported = await Linking.canOpenURL(url); + + if (supported) { + await Linking.openURL(url); + } else { + console.warn(`Cannot open URL: ${url}`); + } +}; diff --git a/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx index 59ff62517..b200ac61e 100644 --- a/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx @@ -3,7 +3,7 @@ import { Linking, StyleSheet, View } from 'react-native'; import { WebView } from 'react-native-webview'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ViewController, createModalNotReadyError } from '@magic-sdk/provider'; -import { MagicMessageEvent } from '@magic-sdk/types'; +import { MagicIncomingWindowMessage, MagicMessageEvent } from '@magic-sdk/types'; import { isTypedArray } from 'lodash'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { EventRegister } from 'react-native-event-listeners'; @@ -13,6 +13,7 @@ import { useInternetConnection } from './hooks'; import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './native-crypto/keychain'; import { getDpop } from './native-crypto/dpop'; import { checkNativeModules } from './native-crypto/check-native-modules'; +import { openInBrowser } from './lib/links'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -117,6 +118,17 @@ export class ReactNativeWebViewController extends ViewController { } }, [mountOverlay]); + useEffect(() => { + const removeHandler = this.on(MagicIncomingWindowMessage.MAGIC_OPEN_MOBILE_URL, (event: MagicMessageEvent) => { + const url = event.data.response.result?.url; + if (!url) return; + + openInBrowser(url); + }); + + return removeHandler; + }, []); + /** * Saves a reference to the underlying `` node so we can interact * with incoming messages. diff --git a/packages/@magic-sdk/react-native-bare/test/spec/lib/links.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/lib/links.spec.ts new file mode 100644 index 000000000..18226728d --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/lib/links.spec.ts @@ -0,0 +1,42 @@ +import { Linking } from 'react-native'; +import { openInBrowser } from '../../../src/lib/links'; + +const mockCanOpenURL = Linking.canOpenURL as jest.Mock; +const mockOpenURL = Linking.openURL as jest.Mock; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('openInBrowser', () => { + it('calls Linking.openURL when the URL is supported', async () => { + const url = 'https://magic.link'; + mockCanOpenURL.mockResolvedValue(true); + mockOpenURL.mockResolvedValue(undefined); + + await openInBrowser(url); + + expect(mockCanOpenURL).toHaveBeenCalledWith(url); + expect(mockOpenURL).toHaveBeenCalledWith(url); + }); + + it('does not call Linking.openURL when the URL is not supported', async () => { + const url = 'unsupported://scheme'; + mockCanOpenURL.mockResolvedValue(false); + + await openInBrowser(url); + + expect(mockCanOpenURL).toHaveBeenCalledWith(url); + expect(mockOpenURL).not.toHaveBeenCalled(); + }); + + it('logs a warning when the URL is not supported', async () => { + const url = 'unsupported://scheme'; + mockCanOpenURL.mockResolvedValue(false); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await openInBrowser(url); + + expect(warnSpy).toHaveBeenCalledWith(`Cannot open URL: ${url}`); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/src/lib/links.ts b/packages/@magic-sdk/react-native-expo/src/lib/links.ts new file mode 100644 index 000000000..0320c3805 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/lib/links.ts @@ -0,0 +1,10 @@ +import { Linking } from 'react-native'; + +export const openInBrowser = async (url: string) => { + const supported = await Linking.canOpenURL(url); + if (supported) { + await Linking.openURL(url); + } else { + console.warn(`Cannot open URL: ${url}`); + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index 108394fee..e9a6cdbe1 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -3,13 +3,14 @@ import { Linking, StyleSheet, View } from 'react-native'; import { WebView } from 'react-native-webview'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ViewController, createModalNotReadyError } from '@magic-sdk/provider'; -import { MagicMessageEvent } from '@magic-sdk/types'; +import { MagicIncomingWindowMessage, MagicMessageEvent } from '@magic-sdk/types'; import { isTypedArray } from 'lodash'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; +import { openInBrowser } from './lib/links'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -91,6 +92,17 @@ export class ReactNativeWebViewController extends ViewController { this.isConnectedToInternet = isConnected; }, [isConnected]); + useEffect(() => { + const removeHandler = this.on(MagicIncomingWindowMessage.MAGIC_OPEN_MOBILE_URL, (event: MagicMessageEvent) => { + const url = event.data.response.result?.url; + if (!url) return; + + openInBrowser(url); + }); + + return removeHandler; + }, []); + useEffect(() => { // reset lastMessage when webview is first mounted AsyncStorage.setItem(LAST_MESSAGE_TIME, ''); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/lib/links.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/lib/links.spec.ts new file mode 100644 index 000000000..18226728d --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/lib/links.spec.ts @@ -0,0 +1,42 @@ +import { Linking } from 'react-native'; +import { openInBrowser } from '../../../src/lib/links'; + +const mockCanOpenURL = Linking.canOpenURL as jest.Mock; +const mockOpenURL = Linking.openURL as jest.Mock; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('openInBrowser', () => { + it('calls Linking.openURL when the URL is supported', async () => { + const url = 'https://magic.link'; + mockCanOpenURL.mockResolvedValue(true); + mockOpenURL.mockResolvedValue(undefined); + + await openInBrowser(url); + + expect(mockCanOpenURL).toHaveBeenCalledWith(url); + expect(mockOpenURL).toHaveBeenCalledWith(url); + }); + + it('does not call Linking.openURL when the URL is not supported', async () => { + const url = 'unsupported://scheme'; + mockCanOpenURL.mockResolvedValue(false); + + await openInBrowser(url); + + expect(mockCanOpenURL).toHaveBeenCalledWith(url); + expect(mockOpenURL).not.toHaveBeenCalled(); + }); + + it('logs a warning when the URL is not supported', async () => { + const url = 'unsupported://scheme'; + mockCanOpenURL.mockResolvedValue(false); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await openInBrowser(url); + + expect(warnSpy).toHaveBeenCalledWith(`Cannot open URL: ${url}`); + }); +}); diff --git a/packages/@magic-sdk/types/src/core/message-types.ts b/packages/@magic-sdk/types/src/core/message-types.ts index a96c940f7..2e92c6937 100644 --- a/packages/@magic-sdk/types/src/core/message-types.ts +++ b/packages/@magic-sdk/types/src/core/message-types.ts @@ -12,6 +12,7 @@ export enum MagicIncomingWindowMessage { MAGIC_POPUP_RESPONSE = 'MAGIC_POPUP_RESPONSE', MAGIC_POPUP_OAUTH_VERIFY_RESPONSE = 'MAGIC_POPUP_OAUTH_VERIFY_RESPONSE', MAGIC_THIRD_PARTY_WALLET_REQUEST = 'MAGIC_THIRD_PARTY_WALLET_REQUEST', + MAGIC_OPEN_MOBILE_URL = 'MAGIC_OPEN_MOBILE_URL', } export enum MagicOutgoingWindowMessage {