diff --git a/lib/facts/links.js b/lib/facts/links.js index fd22cc2f4..478494bd6 100644 --- a/lib/facts/links.js +++ b/lib/facts/links.js @@ -1,42 +1,63 @@ // @flow /* Invite Links */ function inviteLinkUrl(secret: string): string { return `https://comm.app/invite/${secret}`; } -function parseSecretFromInviteLinkURL(url: string): ?string { - const urlRegex = /invite\/(\S+)$/; - const match = urlRegex.exec(url); - return match?.[1]; -} - -function parseInstallReferrerFromInviteLinkURL(referrer: string): ?string { - const referrerRegex = /utm_source=(invite\/(\S+))$/; - const match = referrerRegex.exec(referrer); - return match?.[1]; -} - /* QR Code */ function qrCodeLinkUrl(aes256Param: string, ed25519Param: string): string { const keys = { aes256: aes256Param, ed25519: ed25519Param, }; const keysString = encodeURIComponent(JSON.stringify(keys)); return `comm://qr-code/${keysString}`; } -function parseKeysFromQRCodeURL(url: string): ?string { - const urlRegex = /qr-code\/(\S+)$/; - const match = urlRegex.exec(url); +/* Deep Link Utils */ +function parseInstallReferrerFromInviteLinkURL(referrer: string): ?string { + const referrerRegex = /utm_source=(invite\/(\S+))$/; + const match = referrerRegex.exec(referrer); return match?.[1]; } +type ParsedInviteLinkData = { + +type: 'invite-link', + +data: { +secret: string }, +}; +type ParsedQRCodeData = { + +type: 'qr-code', + +data: { +keys: string }, +}; +export type ParsedDeepLinkData = ParsedInviteLinkData | ParsedQRCodeData | null; + +function parseDataFromDeepLink(url: string): ParsedDeepLinkData { + const inviteLinkSecretRegex = /invite\/(\S+)$/; + const qrCodeKeysRegex = /qr-code\/(\S+)$/; + + const inviteLinkSecretMatch = inviteLinkSecretRegex.exec(url); + if (inviteLinkSecretMatch) { + return { + type: 'invite-link', + data: { secret: inviteLinkSecretMatch[1] }, + }; + } + + const qrCodeKeysMatch = qrCodeKeysRegex.exec(url); + if (qrCodeKeysMatch) { + return { + type: 'qr-code', + data: { keys: qrCodeKeysMatch[1] }, + }; + } + + return null; +} + export { inviteLinkUrl, - parseSecretFromInviteLinkURL, - parseInstallReferrerFromInviteLinkURL, qrCodeLinkUrl, - parseKeysFromQRCodeURL, + parseInstallReferrerFromInviteLinkURL, + parseDataFromDeepLink, }; diff --git a/native/navigation/deep-links-context-provider.react.js b/native/navigation/deep-links-context-provider.react.js index 8a71c9482..5e04f311f 100644 --- a/native/navigation/deep-links-context-provider.react.js +++ b/native/navigation/deep-links-context-provider.react.js @@ -1,130 +1,139 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as Application from 'expo-application'; import * as React from 'react'; import { Linking, Platform } from 'react-native'; import { verifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { - parseSecretFromInviteLinkURL, parseInstallReferrerFromInviteLinkURL, + parseDataFromDeepLink, + type ParsedDeepLinkData, } from 'lib/facts/links.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { SetState } from 'lib/types/hook-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; -import { InviteLinkModalRouteName } from './route-names.js'; +import { + InviteLinkModalRouteName, + SecondaryDeviceQRCodeScannerRouteName, +} from './route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; type DeepLinksContextType = { +setCurrentLinkUrl: SetState, }; const defaultContext = { setCurrentLinkUrl: () => {}, }; const DeepLinksContext: React.Context = React.createContext(defaultContext); type Props = { +children: React.Node, }; function DeepLinksContextProvider(props: Props): React.Node { const { children } = props; const [currentLink, setCurrentLink] = React.useState(null); React.useEffect(() => { // This listener listens for an event where a user clicked a link when the // app was running const subscription = Linking.addEventListener('url', ({ url }) => setCurrentLink(url), ); - // We're also checking if the app was opened by using an invite link. + // We're also checking if the app was opened by using a link. // In that case the listener won't be called and we're instead checking // if the initial URL is set. (async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { setCurrentLink(initialURL); } })(); return () => { subscription.remove(); }; }, []); const checkInstallReferrer = React.useCallback(async () => { if (Platform.OS !== 'android') { return; } const installReferrer = await Application.getInstallReferrerAsync(); if (!installReferrer) { return; } const linkSecret = parseInstallReferrerFromInviteLinkURL(installReferrer); if (linkSecret) { setCurrentLink(linkSecret); } }, []); useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const validateLink = useServerCall(verifyInviteLink); const navigation = useNavigation(); React.useEffect(() => { (async () => { if (!loggedIn || !currentLink) { return; } // We're setting this to null so that we ensure that each link click // results in at most one validation and navigation. setCurrentLink(null); - const secret = parseSecretFromInviteLinkURL(currentLink); - if (!secret) { + const parsedData: ParsedDeepLinkData = parseDataFromDeepLink(currentLink); + if (!parsedData) { return; } - const validateLinkPromise = validateLink({ secret }); - dispatchActionPromise(verifyInviteLinkActionTypes, validateLinkPromise); - const result = await validateLinkPromise; - if (result.status === 'already_joined') { - return; + if (parsedData.type === 'invite-link') { + const { secret } = parsedData.data; + const validateLinkPromise = validateLink({ secret }); + dispatchActionPromise(verifyInviteLinkActionTypes, validateLinkPromise); + const result = await validateLinkPromise; + if (result.status === 'already_joined') { + return; + } + + navigation.navigate<'InviteLinkModal'>({ + name: InviteLinkModalRouteName, + params: { + invitationDetails: result, + secret, + }, + }); + } else if (parsedData.type === 'qr-code') { + navigation.navigate(SecondaryDeviceQRCodeScannerRouteName); } - - navigation.navigate<'InviteLinkModal'>({ - name: InviteLinkModalRouteName, - params: { - invitationDetails: result, - secret, - }, - }); })(); }, [currentLink, dispatchActionPromise, loggedIn, navigation, validateLink]); const contextValue = React.useMemo( () => ({ setCurrentLinkUrl: setCurrentLink, }), [], ); return ( {children} ); } export { DeepLinksContext, DeepLinksContextProvider }; diff --git a/native/navigation/navigation-handler.react.js b/native/navigation/navigation-handler.react.js index cd0a24689..46e016530 100644 --- a/native/navigation/navigation-handler.react.js +++ b/native/navigation/navigation-handler.react.js @@ -1,88 +1,86 @@ // @flow import * as React from 'react'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionType, logOutActionType } from './action-types.js'; import ModalPruner from './modal-pruner.react.js'; import NavFromReduxHandler from './nav-from-redux-handler.react.js'; import { useIsAppLoggedIn } from './nav-selectors.js'; import { NavContext, type NavAction } from './navigation-context.js'; import PolicyAcknowledgmentHandler from './policy-acknowledgment-handler.react.js'; -import QRCodeLinkHandler from './qr-code-link-handler.react.js'; import ThreadScreenTracker from './thread-screen-tracker.react.js'; import DevTools from '../redux/dev-tools.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js'; const NavigationHandler: React.ComponentType<{}> = React.memo<{}>( function NavigationHandler() { const navContext = React.useContext(NavContext); const persistedStateLoaded = usePersistedStateLoaded(); const devTools = __DEV__ ? : null; if (!navContext || !persistedStateLoaded) { if (__DEV__) { return ( <> {devTools} ); } else { return null; } } const { dispatch } = navContext; return ( <> - {devTools} ); }, ); NavigationHandler.displayName = 'NavigationHandler'; type LogInHandlerProps = { +dispatch: (action: NavAction) => void, }; const LogInHandler = React.memo(function LogInHandler( props: LogInHandlerProps, ) { const { dispatch } = props; const hasCurrentUserInfo = useSelector(isLoggedIn); const cookie = useSelector(cookieSelector); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = hasCurrentUserInfo && hasUserCookie; const navLoggedIn = useIsAppLoggedIn(); const prevLoggedInRef = React.useRef(); React.useEffect(() => { if (loggedIn === prevLoggedInRef.current) { return; } prevLoggedInRef.current = loggedIn; if (loggedIn && !navLoggedIn) { dispatch({ type: (logInActionType: 'LOG_IN') }); } else if (!loggedIn && navLoggedIn) { dispatch({ type: (logOutActionType: 'LOG_OUT') }); } }, [navLoggedIn, loggedIn, dispatch]); return null; }); LogInHandler.displayName = 'LogInHandler'; export default NavigationHandler; diff --git a/native/navigation/qr-code-link-handler.react.js b/native/navigation/qr-code-link-handler.react.js deleted file mode 100644 index 2f9c9070d..000000000 --- a/native/navigation/qr-code-link-handler.react.js +++ /dev/null @@ -1,51 +0,0 @@ -// @flow - -import { useNavigation } from '@react-navigation/native'; -import * as React from 'react'; -import { Linking } from 'react-native'; - -import { parseKeysFromQRCodeURL } from 'lib/facts/links.js'; -import { isLoggedIn } from 'lib/selectors/user-selectors.js'; - -import { SecondaryDeviceQRCodeScannerRouteName } from './route-names.js'; -import { useSelector } from '../redux/redux-utils.js'; - -function QRCodeLinkHandler(): null { - const [currentLink, setCurrentLink] = React.useState(null); - - React.useEffect(() => { - const subscription = Linking.addEventListener('url', ({ url }) => - setCurrentLink(url), - ); - (async () => { - const initialURL = await Linking.getInitialURL(); - if (initialURL) { - setCurrentLink(initialURL); - } - })(); - - return () => subscription.remove(); - }, []); - - const loggedIn = useSelector(isLoggedIn); - const { navigate } = useNavigation(); - - React.useEffect(() => { - if (!loggedIn || !currentLink) { - return; - } - - setCurrentLink(null); - - const keys = parseKeysFromQRCodeURL(currentLink); - if (!keys) { - return; - } - - navigate(SecondaryDeviceQRCodeScannerRouteName); - }, [currentLink, loggedIn, navigate]); - - return null; -} - -export default QRCodeLinkHandler; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 7fbbe619c..bee7a8b14 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,125 +1,126 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import * as React from 'react'; import { View } from 'react-native'; -import { parseKeysFromQRCodeURL } from 'lib/facts/links.js'; +import { parseDataFromDeepLink } from 'lib/facts/links.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; // eslint-disable-next-line no-unused-vars function SecondaryDeviceQRCodeScanner(props: { ... }): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); const styles = useStyles(unboundStyles); const navigation = useNavigation(); React.useEffect(() => { (async () => { const { status } = await BarCodeScanner.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { Alert.alert( 'No access to camera', 'Please allow Comm to access your camera in order to scan the QR code.', [{ text: 'OK' }], ); navigation.goBack(); } })(); }, [navigation]); const onConnect = React.useCallback((barCodeEvent: BarCodeEvent) => { const { data } = barCodeEvent; - const keysMatch = parseKeysFromQRCodeURL(data); + const parsedData = parseDataFromDeepLink(data); + const keysMatch = parsedData?.data?.keys; - if (!keysMatch) { + if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } const keys = JSON.parse(decodeURIComponent(keysMatch)); Alert.alert( 'Scan successful', `QR code contains the following keys: ${JSON.stringify(keys)}`, [{ text: 'OK' }], ); }, []); const onCancelScan = React.useCallback(() => setScanned(false), []); const handleBarCodeScanned = React.useCallback( (barCodeEvent: BarCodeEvent) => { setScanned(true); Alert.alert( 'Connect with this device?', 'Are you sure you want to allow this device to log in to your account?', [ { text: 'Cancel', style: 'cancel', onPress: onCancelScan, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [onCancelScan, onConnect], ); if (hasPermission === null) { return ; } // Note: According to the BarCodeScanner Expo docs, we should adhere to two // guidances when using the BarCodeScanner: // 1. We should specify the potential barCodeTypes we want to scan for to // minimize battery usage. // 2. We should set the onBarCodeScanned callback to undefined if it scanned // in order to 'pause' the scanner from continuing to scan while we // process the data from the scan. // See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner return ( ); } const unboundStyles = { container: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, }; export default SecondaryDeviceQRCodeScanner;