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,
}
};