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'; export const ERROR_CODE = { CONNECTION_FAILED: -1001000, } 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) { // redirection between different domains may cause failure on Android if (event.nativeEvent.url === null) { setViewState('ERROR'); setLastErrorEvent({ url: event.nativeEvent.url, loading: event.nativeEvent.loading, title: event.nativeEvent.title, canGoBack: event.nativeEvent.canGoBack, canGoForward: event.nativeEvent.canGoForward, lockIdentifier: event.nativeEvent.lockIdentifier, code: ERROR_CODE.CONNECTION_FAILED, description: 'connection failed', }); } else { 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, } };