From 39632ee250ee564570fd2bbd4c8b6df7e9333c02 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 22 May 2026 11:16:07 +0300 Subject: [PATCH] add shim for webview on web --- .../Embed/ExternalEmbed/ExternalPlayer.tsx | 2 +- src/components/WebView/index.tsx | 2 + src/components/WebView/index.web.tsx | 110 ++++++++++++++++++ .../Signup/StepCaptcha/CaptchaWebView.tsx | 7 +- 4 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/components/WebView/index.tsx create mode 100644 src/components/WebView/index.web.tsx diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx index 56e84cfd4f..36a080e0b9 100644 --- a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx +++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx @@ -13,7 +13,6 @@ import Animated, { useFrameCallback, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {WebView} from 'react-native-webview' import {Image} from 'expo-image' import {type AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/core/macro' @@ -33,6 +32,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' import {Fill} from '#/components/Fill' import {KeepAwake} from '#/components/KeepAwake' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {WebView} from '#/components/WebView' import {IS_NATIVE} from '#/env' interface ShouldStartLoadRequest { diff --git a/src/components/WebView/index.tsx b/src/components/WebView/index.tsx new file mode 100644 index 0000000000..349ba69b57 --- /dev/null +++ b/src/components/WebView/index.tsx @@ -0,0 +1,2 @@ +export * from 'react-native-webview' +export {default} from 'react-native-webview' diff --git a/src/components/WebView/index.web.tsx b/src/components/WebView/index.web.tsx new file mode 100644 index 0000000000..084b30fb35 --- /dev/null +++ b/src/components/WebView/index.web.tsx @@ -0,0 +1,110 @@ +import {type Ref, useEffect, useImperativeHandle, useRef} from 'react' +import {type StyleProp, StyleSheet, type ViewStyle} from 'react-native' +// @ts-expect-error untyped +import {unstable_createElement} from 'react-native-web' + +type WebViewSource = {uri?: string; html?: string} + +type WebViewMessageEvent = { + nativeEvent: {data: string; origin?: string} +} + +export type WebViewHandle = { + postMessage: (message: string, targetOrigin?: string) => void +} + +type WebViewProps = { + ref?: Ref + source: WebViewSource + style?: StyleProp + title?: string + scrollEnabled?: boolean + injectedJavaScript?: string + onLoad?: () => void + onMessage?: (event: WebViewMessageEvent) => void + // Accepted for API parity with react-native-webview but ignored on web: + // these don't translate meaningfully to iframes. + javaScriptEnabled?: boolean + mediaPlaybackRequiresUserAction?: boolean + allowsInlineMediaPlayback?: boolean + allowsFullscreenVideo?: boolean + bounces?: boolean + nestedScrollEnabled?: boolean + setSupportMultipleWindows?: boolean + onShouldStartLoadWithRequest?: (event: {url: string}) => boolean + onNavigationStateChange?: (event: {url: string}) => void + onError?: (event: {nativeEvent: unknown}) => void + onHttpError?: (event: {nativeEvent: unknown}) => void +} + +function injectScript(html: string | undefined, script: string | undefined) { + if (!script || !html) return html + return html.replace('', ``) +} + +export function WebView({ + ref, + source, + style, + title, + scrollEnabled = true, + injectedJavaScript, + onLoad, + onMessage, +}: WebViewProps) { + const frameRef = useRef(null) + + useImperativeHandle(ref, () => ({ + postMessage(message, targetOrigin = '*') { + frameRef.current?.contentWindow?.postMessage(message, targetOrigin) + }, + })) + + useEffect(() => { + if (!onMessage) return + const handler = (event: MessageEvent) => { + onMessage({ + nativeEvent: { + data: + typeof event.data === 'string' ? event.data : String(event.data), + origin: event.origin, + }, + }) + } + window.addEventListener('message', handler, true) + return () => window.removeEventListener('message', handler, true) + }, [onMessage]) + + const flat = StyleSheet.flatten(style) as ViewStyle | undefined + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, react-hooks/refs + return unstable_createElement('iframe', { + title, + ref: frameRef, + src: source.uri, + srcDoc: injectScript(source.html, injectedJavaScript), + style: StyleSheet.flatten([ + styles.iframe, + !scrollEnabled && styles.noScroll, + style, + ]), + width: flat?.width, + height: flat?.height, + allowFullScreen: true, + frameBorder: '0', + onLoad, + }) +} + +export default WebView + +const styles = StyleSheet.create({ + iframe: { + width: '100%', + height: '100%', + borderWidth: 0, + }, + noScroll: { + overflow: 'hidden', + }, +}) diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index fa7b3064ee..895998cd65 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -1,8 +1,7 @@ import {useEffect, useMemo, useRef} from 'react' -import {WebView, type WebViewNavigation} from 'react-native-webview' -import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' import {type SignupState} from '#/screens/Signup/state' +import {WebView, type WebViewNavigation} from '#/components/WebView' const ALLOWED_HOSTS = [ 'bsky.social', @@ -54,8 +53,8 @@ export function CaptchaWebView({ const wasSuccessful = useRef(false) - const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => { - const urlp = new URL(event.url) + const onShouldStartLoadWithRequest = (evt: WebViewNavigation) => { + const urlp = new URL(evt.url) return ALLOWED_HOSTS.includes(urlp.host) }