import { StatusBar } from 'expo-status-bar'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, type AppStateStatus, BackHandler, Linking, Platform, Pressable, StyleSheet, Text, View, } from 'react-native'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { WebViewMessageEvent } from 'react-native-webview'; import { WebView } from 'react-native-webview'; import { type HostBridgeEventName, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, } from '../../../../packages/shared/src/contracts/hostBridge'; import { configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, resolveMobileHostCapabilities, } from '../host-bridge/bridge'; import { buildMobileShellUrlFromDeepLink } from './deepLink'; import { lifecyclePayloadFromAppState } from './lifecycle'; import { type MobileShellLoadFailure, normalizeMobileShellLoadFailure, } from './loadFailure'; import { resolveMobileShellExternalUrl, shouldAcceptMobileShellHostBridgeMessage, shouldOpenInMobileShellWebView, } from './navigation'; import { getMobileNetworkStatus, subscribeMobileNetworkStatus, } from './network'; import { MOBILE_SHELL_HOST_VERSION } from './runtime'; import { MOBILE_SHELL_SAFE_AREA_EDGES } from './safeArea'; import { buildMobileShellUrl, resolveMobileShellBaseWebUrl, } from './url'; import { MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewNavigationRequest, } from './webViewPolicy'; import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; function buildHostBridgeMessageScript(message: unknown) { return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( JSON.stringify(message), )}, origin: window.location.origin, source: window })); true;`; } type MobileWebViewLoadErrorEvent = { nativeEvent: { url: string; code?: number; description?: string; }; }; type MobileWebViewHttpErrorEvent = { nativeEvent: { url: string; statusCode?: number; description?: string; }; }; export default function ShellApp() { const webViewRef = useRef(null); const nativeCanGoBackRef = useRef(false); const h5CanGoBackRef = useRef(false); const [canGoBack, setCanGoBack] = useState(false); const baseWebUrl = resolveMobileShellBaseWebUrl( process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, ); const urlOptions = useMemo( () => ({ platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const, hostVersion: MOBILE_SHELL_HOST_VERSION, capabilities: resolveMobileHostCapabilities(), }), [], ); const [webUrl, setWebUrl] = useState(() => buildMobileShellUrl(baseWebUrl, urlOptions), ); const [loadFailure, setLoadFailure] = useState(null); const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]); const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); }, []); const injectHostBridgeEvent = useCallback( (event: HostBridgeEventName, payload: unknown) => { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript({ bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, event, payload, }), ); }, [], ); const injectLifecycleEvent = useCallback( (state: AppStateStatus) => { injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); }, [injectHostBridgeEvent], ); const injectNetworkStatusEvent = useCallback( (payload: Awaited>) => { injectHostBridgeEvent('network.statusChanged', payload); }, [injectHostBridgeEvent], ); const syncNavigationCanGoBack = useCallback( (source: 'native' | 'h5', nextCanGoBack: boolean) => { if (source === 'native') { nativeCanGoBackRef.current = nextCanGoBack; } else { h5CanGoBackRef.current = nextCanGoBack; } const combinedCanGoBack = nativeCanGoBackRef.current || h5CanGoBackRef.current; setCanGoBack(combinedCanGoBack); injectHostBridgeEvent('navigation.canGoBack', { canGoBack: combinedCanGoBack, }); }, [injectHostBridgeEvent], ); const resetNavigationCanGoBack = useCallback(() => { nativeCanGoBackRef.current = false; h5CanGoBackRef.current = false; setCanGoBack(false); }, []); useEffect(() => { configureMobileHostBridgeNavigation({ allowedOrigin: allowedWebOrigin, urlOptions, openWebViewUrl(url) { resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(url); }, reloadWebView: reloadCurrentWebView, }); return () => configureMobileHostBridgeNavigation(null); }, [allowedWebOrigin, reloadCurrentWebView, resetNavigationCanGoBack, urlOptions]); useEffect(() => { let disposed = false; const openDeepLink = (url: string | null | undefined) => { const nextUrl = buildMobileShellUrlFromDeepLink( url, baseWebUrl, urlOptions, ); resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(nextUrl); }; void Linking.getInitialURL().then((url) => { if (!disposed) { openDeepLink(url); } }); const subscription = Linking.addEventListener('url', (event) => { openDeepLink(event.url); }); return () => { disposed = true; subscription.remove(); }; }, [baseWebUrl, resetNavigationCanGoBack, urlOptions]); useEffect(() => { const subscription = BackHandler.addEventListener( 'hardwareBackPress', () => { if (!canGoBack) { return false; } if (h5CanGoBackRef.current) { webViewRef.current?.injectJavaScript('window.history.back(); true;'); } else { webViewRef.current?.goBack(); } return true; }, ); return () => subscription.remove(); }, [canGoBack]); useEffect(() => { const subscription = AppState.addEventListener( 'change', injectLifecycleEvent, ); injectLifecycleEvent(AppState.currentState); return () => subscription.remove(); }, [injectLifecycleEvent]); useEffect(() => { return subscribeMobileNetworkStatus(injectNetworkStatusEvent); }, [injectNetworkStatusEvent]); const handleMessage = (event: WebViewMessageEvent) => { if ( !shouldAcceptMobileShellHostBridgeMessage( event.nativeEvent.url, allowedWebOrigin, ) ) { return; } const historyState = parseMobileWebViewHistoryStateMessage( event.nativeEvent.data, ); if (historyState) { syncNavigationCanGoBack('h5', historyState.canGoBack); return; } void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript(response), ); }); }; const handleShouldStartLoad = (request: { url: string }) => { if (shouldBlockMobileWebViewNavigationRequest(request)) { return false; } if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) { return true; } const externalUrl = resolveMobileShellExternalUrl(request.url); if (externalUrl) { void Linking.canOpenURL(externalUrl) .then((canOpen) => (canOpen ? Linking.openURL(externalUrl) : undefined)) .catch(() => undefined); } return false; }; const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { if ( !shouldAcceptMobileShellHostBridgeMessage( event.nativeEvent.url, allowedWebOrigin, ) ) { return; } setLoadFailure(null); injectLifecycleEvent(AppState.currentState); void getMobileNetworkStatus() .then(injectNetworkStatusEvent) .catch(() => undefined); }; const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { type: 'native', url: event.nativeEvent.url, code: event.nativeEvent.code, description: event.nativeEvent.description, }, allowedWebOrigin, webUrl, ), ); }; const handleWebViewHttpError = (event: MobileWebViewHttpErrorEvent) => { resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { type: 'http', url: event.nativeEvent.url, statusCode: event.nativeEvent.statusCode, description: event.nativeEvent.description, }, allowedWebOrigin, webUrl, ), ); }; const handleRetryLoadFailure = () => { setLoadFailure(null); reloadCurrentWebView(); }; const handleBlockedFileDownload = () => undefined; return ( { syncNavigationCanGoBack('native', event.canGoBack); }} setSupportMultipleWindows={false} /> {loadFailure ? ( {loadFailure.title} {loadFailure.detail} {loadFailure.retryLabel} ) : null} ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#fffdf9', }, loadFailurePanel: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28, backgroundColor: '#fffdf9', }, loadFailureTitle: { color: '#211a16', fontSize: 18, fontWeight: '700', lineHeight: 24, textAlign: 'center', }, loadFailureDetail: { maxWidth: 320, marginTop: 10, color: '#6a5c52', fontSize: 14, lineHeight: 20, textAlign: 'center', }, loadFailureButton: { minWidth: 112, minHeight: 42, alignItems: 'center', justifyContent: 'center', marginTop: 20, borderRadius: 8, backgroundColor: '#211a16', paddingHorizontal: 22, }, loadFailureButtonText: { color: '#fffdf9', fontSize: 15, fontWeight: '700', lineHeight: 20, textAlign: 'center', }, });