import escapeStringRegexp from 'escape-string-regexp'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Linking, View, ActivityIndicator, Text, Platform } from 'react-native'; import { OnShouldStartLoadWithRequest, ShouldStartLoadRequestEvent, WebViewError, WebViewErrorEvent, WebViewHttpErrorEvent, WebViewMessageEvent, WebViewNavigation, WebViewNavigationEvent, WebViewOpenWindowEvent, WebViewProgressEvent, WebViewRenderProcessGoneEvent, WebViewTerminatedEvent, } from './WebViewTypes'; import styles from './WebView.styles'; const defaultOriginWhitelist = ['http://*', 'https://*'] as const; const extractOrigin = (url: string): string => { const result = /^[A-Za-z][A-Za-z0-9+\-.]+:(\/\/)?[^/]*/.exec(url); return result === null ? '' : result[0]; }; const originWhitelistToRegex = (originWhitelist: string): string => `^${escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*')}`; const passesWhitelist = (compiledWhitelist: readonly string[], url: string) => { const origin = extractOrigin(url); return compiledWhitelist.some((x) => new RegExp(x).test(origin)); }; const compileWhitelist = ( originWhitelist: readonly string[] ): readonly string[] => ['about:blank', ...(originWhitelist || [])].map(originWhitelistToRegex); const createOnShouldStartLoadWithRequest = ( loadRequest: ( shouldStart: boolean, url: string, lockIdentifier: number ) => void, originWhitelist: readonly string[], onShouldStartLoadWithRequest?: OnShouldStartLoadWithRequest ) => { return ({ nativeEvent }: ShouldStartLoadRequestEvent) => { let shouldStart = true; const { url, lockIdentifier } = nativeEvent; if (!passesWhitelist(compileWhitelist(originWhitelist), url)) { Linking.canOpenURL(url) .then((supported) => { if (supported) { return Linking.openURL(url); } console.warn(`Can't open url: ${url}`); return undefined; }) .catch((e) => { console.warn('Error opening URL: ', e); }); shouldStart = false; } else if (onShouldStartLoadWithRequest) { shouldStart = onShouldStartLoadWithRequest(nativeEvent); } loadRequest(shouldStart, url, lockIdentifier); }; }; const defaultRenderLoading = () => ( ); const defaultRenderError = ( errorDomain: string | undefined, errorCode: number, errorDesc: string ) => ( Error loading page {`Domain: ${errorDomain}`} {`Error Code: ${errorCode}`} {`Description: ${errorDesc}`} ); export { defaultOriginWhitelist, createOnShouldStartLoadWithRequest, defaultRenderLoading, defaultRenderError, }; export const useWebViewLogic = ({ startInLoadingState, onNavigationStateChange, onLoadStart, onLoad, onLoadProgress, onLoadEnd, onError, onHttpErrorProp, onMessageProp, onOpenWindowProp, onRenderProcessGoneProp, onContentProcessDidTerminateProp, originWhitelist, onShouldStartLoadWithRequestProp, onShouldStartLoadWithRequestCallback, }: { startInLoadingState?: boolean; onNavigationStateChange?: (event: WebViewNavigation) => void; onLoadStart?: (event: WebViewNavigationEvent) => void; onLoad?: (event: WebViewNavigationEvent) => void; onLoadProgress?: (event: WebViewProgressEvent) => void; onLoadEnd?: (event: WebViewNavigationEvent | WebViewErrorEvent) => void; onError?: (event: WebViewErrorEvent) => void; onHttpErrorProp?: (event: WebViewHttpErrorEvent) => void; onMessageProp?: (event: WebViewMessageEvent) => void; onOpenWindowProp?: (event: WebViewOpenWindowEvent) => void; onRenderProcessGoneProp?: (event: WebViewRenderProcessGoneEvent) => void; onContentProcessDidTerminateProp?: (event: WebViewTerminatedEvent) => void; originWhitelist: readonly string[]; onShouldStartLoadWithRequestProp?: OnShouldStartLoadWithRequest; onShouldStartLoadWithRequestCallback: ( shouldStart: boolean, url: string, lockIdentifier?: number | undefined ) => void; }) => { const [viewState, setViewState] = useState<'IDLE' | 'LOADING' | 'ERROR'>( startInLoadingState ? 'LOADING' : 'IDLE' ); const [lastErrorEvent, setLastErrorEvent] = useState( null ); const startUrl = useRef(null); const updateNavigationState = useCallback( (event: WebViewNavigationEvent) => { onNavigationStateChange?.(event.nativeEvent); }, [onNavigationStateChange] ); const onLoadingStart = useCallback( (event: WebViewNavigationEvent) => { // Needed for android startUrl.current = event.nativeEvent.url; // !Needed for android onLoadStart?.(event); updateNavigationState(event); }, [onLoadStart, updateNavigationState] ); const onLoadingError = useCallback( (event: WebViewErrorEvent) => { event.persist(); if (onError) { onError(event); } else { console.warn('Encountered an error loading page', event.nativeEvent); } onLoadEnd?.(event); if (event.isDefaultPrevented()) { return; } setViewState('ERROR'); setLastErrorEvent(event.nativeEvent); }, [onError, onLoadEnd] ); const onHttpError = useCallback( (event: WebViewHttpErrorEvent) => { onHttpErrorProp?.(event); }, [onHttpErrorProp] ); // Android Only const onRenderProcessGone = useCallback( (event: WebViewRenderProcessGoneEvent) => { onRenderProcessGoneProp?.(event); }, [onRenderProcessGoneProp] ); // !Android Only // iOS Only const onContentProcessDidTerminate = useCallback( (event: WebViewTerminatedEvent) => { onContentProcessDidTerminateProp?.(event); }, [onContentProcessDidTerminateProp] ); // !iOS Only const onLoadingFinish = useCallback( (event: WebViewNavigationEvent) => { onLoad?.(event); onLoadEnd?.(event); const { nativeEvent: { url }, } = event; // on Android, only if url === startUrl if (Platform.OS !== 'android' || url === startUrl.current) { setViewState('IDLE'); } // !on Android, only if url === startUrl updateNavigationState(event); }, [onLoad, onLoadEnd, updateNavigationState] ); const onMessage = useCallback( (event: WebViewMessageEvent) => { onMessageProp?.(event); }, [onMessageProp] ); const onLoadingProgress = useCallback( (event: WebViewProgressEvent) => { const { nativeEvent: { progress }, } = event; // patch for Android only if (Platform.OS === 'android' && progress === 1) { setViewState((prevViewState) => prevViewState === 'LOADING' ? 'IDLE' : prevViewState ); } // !patch for Android only onLoadProgress?.(event); }, [onLoadProgress] ); const onShouldStartLoadWithRequest = useMemo( () => createOnShouldStartLoadWithRequest( onShouldStartLoadWithRequestCallback, originWhitelist, onShouldStartLoadWithRequestProp ), [ originWhitelist, onShouldStartLoadWithRequestProp, onShouldStartLoadWithRequestCallback, ] ); const onOpenWindow = useCallback( (event: WebViewOpenWindowEvent) => { onOpenWindowProp?.(event); }, [onOpenWindowProp] ); return { onShouldStartLoadWithRequest, onLoadingStart, onLoadingProgress, onLoadingError, onLoadingFinish, onHttpError, onRenderProcessGone, onContentProcessDidTerminate, onMessage, onOpenWindow, viewState, setViewState, lastErrorEvent, }; };