diff --git a/howdju-mobile-app/package.json b/howdju-mobile-app/package.json index 91509f74..e21b8159 100644 --- a/howdju-mobile-app/package.json +++ b/howdju-mobile-app/package.json @@ -6,12 +6,12 @@ "android": "react-native run-android", "android-release": "react-native run-android --variant release", "bundle": "react-native webpack-bundle", - "check": "typecheck && lint && check-format && test", + "check": "yarn run typecheck && yarn run lint && yarn run check-format && yarn run test", "check-format": "yarn run prettier --check .", "debugger": "react-devtools", "fix-format": "yarn run lint --fix && yarn run prettier --write .", "install-pods": "npx pod-install", - "ios": "react-native run-ios --simulator='iPhone 14'", + "ios": "react-native run-ios --simulator='iPhone 15'", "ios-release": "react-native run-ios --configuration Release", "ios-release-device": "react-native run-ios --configuration Release --device ${0}", "lint": "eslint --ignore-path=.gitignore .", @@ -36,8 +36,10 @@ "react-native-safe-area-context": "^4.5.0", "react-native-screens": "^3.15.0", "react-native-share-menu": "github:Howdju/react-native-share-menu#ff9c65e456cf80b23b881ed2e1247f14337260ec", + "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^9.2.0", "react-native-webview": "^11.15.0", + "use-deep-compare-effect": "^1.8.1", "validator": "^13.9.0" }, "devDependencies": { diff --git a/howdju-mobile-app/src/App.tsx b/howdju-mobile-app/src/App.tsx index 697a2290..5f41df05 100644 --- a/howdju-mobile-app/src/App.tsx +++ b/howdju-mobile-app/src/App.tsx @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useState } from "react"; -import { useColorScheme } from "react-native"; +import { + useColorScheme, + KeyboardAvoidingView, + Platform, + StyleSheet, +} from "react-native"; import { NavigationContainer, DefaultTheme } from "@react-navigation/native"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import ShareMenu, { ShareResponse } from "react-native-share-menu"; @@ -42,7 +47,7 @@ const App = (): JSX.Element => { }; }, [handleShare]); - const items = shareResponse?.items; + const shareDataItems = shareResponse?.items; const extraData = shareResponse?.extraData as Record; const isDark = useColorScheme() === "dark"; const theme = isDark ? darkTheme : lightTheme; @@ -59,78 +64,91 @@ const App = (): JSX.Element => { - - - {/* TODO(62): ensure using a render callback does not introduce - performance issues - https://reactnavigation.org/docs/hello-react-navigation/#passing-additional-props - */} - - ); - }, - }} - > - {(props) => } - - - ); - }, - }} - > - {(props) => ( - - )} - - - ); - }, - }} - /> - - + + + {/* TODO(62): ensure using a render callback does not introduce + performance issues + https://reactnavigation.org/docs/hello-react-navigation/#passing-additional-props + */} + + ); + }, + }} + > + {(props) => ( + + )} + + + ); + }, + }} + > + {(props) => ( + + )} + + + ); + }, + }} + /> + + + ); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + export default App; diff --git a/howdju-mobile-app/src/__snapshots__/App.test.tsx.snap b/howdju-mobile-app/src/__snapshots__/App.test.tsx.snap index 8dd8206d..760210c2 100644 --- a/howdju-mobile-app/src/__snapshots__/App.test.tsx.snap +++ b/howdju-mobile-app/src/__snapshots__/App.test.tsx.snap @@ -33,85 +33,84 @@ exports[`App renders correctly 1`] = ` } > - - - - + - - - - + + + + accessible={true} + onLayout={[Function]} + > + + - + + > + + - - - - 󰁍 - + + 󰁍 + + - - - - 󰁔 - + + 󰁔 + + - - - - 󰑐 - + + 󰅖 + + - - - - 󰒖 - + + 󰒖 + + @@ -1044,104 +1094,93 @@ exports[`App renders correctly 1`] = ` - - - - + + - + - + + 󰖟 + + + - 󰖟 - + + 󰖟 + + - - - 󰖟 - - + Browser + - - Browser - - - - - - 󰃤 - - - - + 󰃤 + + + - 󰨰 - + + 󰨰 + + + + Debug + - - Debug - - - - - - 󰒓 - - - - + 󰒓 + + + - 󰢻 - + + 󰢻 + + + + Settings + - - Settings - diff --git a/howdju-mobile-app/src/components/Browser.tsx b/howdju-mobile-app/src/components/Browser.tsx new file mode 100644 index 00000000..b047216a --- /dev/null +++ b/howdju-mobile-app/src/components/Browser.tsx @@ -0,0 +1,438 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { WebView, WebViewProps } from "react-native-webview"; +import { Alert, Share, StyleSheet, View } from "react-native"; +import { + ActivityIndicator, + Appbar, + ProgressBar, + TextInput, +} from "react-native-paper"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { toJson } from "howdju-common"; + +import logger from "@/logger"; + +const UPDATE_STATE_AFTER_CANCEL = "UPDATE_STATE_AFTER_CANCEL"; + +// This message helps the browser to correct its state after the user cancels +// an in-progress load. +type UpdateStateAfterCancelMessage = { + messageType: typeof UPDATE_STATE_AFTER_CANCEL; + url: string; + historyState?: { canGoBack: boolean; canGoForward: boolean }; +}; +type Message = UpdateStateAfterCancelMessage; + +interface BrowserProps extends WebViewProps { + url: string | undefined; + onUrlChange?: (url: string) => void; +} + +/** + * A browser component that wraps a WebView and provides the following controls: + * + * - an editable URL bar + * - Backward, forward, and refresh buttons + * - Share URL button + */ +export function Browser({ + url, + onUrlChange, + onNavigationStateChange, + onLoadStart, + onLoadProgress, + onLoadEnd, + onMessage, + onContentProcessDidTerminate, + ...rest +}: BrowserProps) { + const webViewRef = useRef(null); + + const insets = useSafeAreaInsets(); + const safeArea = { + paddingTop: insets.top, + // bottom is handled by the parent container. + paddingLeft: insets.left, + paddingRight: insets.right, + }; + + // Whether the WebView is currently loading a page. Determines whether to show + // the progress bar. + const [isLoading, setIsLoading] = useState(true); + // The progress of the current page load, from 0 to 100. + const [loadProgress, setLoadProgress] = useState(0); + // These three states help infer whether a user can go back from a canceled + // load. + const [goingBack, setGoingBack] = useState(false); + const [goingForward, setGoingForward] = useState(false); + const [isClicking, setIsClicking] = useState(false); + + // Whether the user can go back or forward. Control the disabled states of the + // back/forward buttons. + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + + // The URL that the WebView is currently displaying. + const [webViewUrl, setWebViewUrl] = useState(undefined as string | undefined); + // The URL that URL text input is showing. + const [inputUrl, setInputUrl] = useState(undefined as string | undefined); + + /** Set the URL the Browser considers to be active. */ + const setBrowserUrl = useCallback( + (url: string) => { + setInputUrl(url); + if (onUrlChange) { + onUrlChange(url); + } + }, + [setInputUrl, onUrlChange] + ); + + /** Set both the WebView and */ + const setBothUrls = useCallback( + (url: string) => { + setWebViewUrl(url); + setInputUrl(url); + }, + [setWebViewUrl] + ); + + useEffect(() => { + if (url && url !== inputUrl) { + setBothUrls(url); + } + }, [url, inputUrl, setBothUrls]); + + function goBackward() { + webViewRef.current?.goBack(); + } + + function goForward() { + webViewRef.current?.goForward(); + } + + function refresh() { + webViewRef.current?.reload(); + } + + /** + * Injects JavaScript into the WebView. + * + * JavaScript is wrapped in an IIFE to prevent it from leaking into the + * global scope. If a message type is provided, a message will be posted to + * the window after the script is executed. + */ + function injectJavaScript( + script: string, + messageType?: string, + messageFields: string[] = [] + ) { + logger.debug("injectJavaScript", script); + if (!webViewRef.current) { + logger.error( + "Unable to inject JavaScript because the WebView is missing." + ); + return; + } + + if (!script.trimEnd().endsWith(";")) { + script = script.trimEnd() + ";"; + } + const postMessageScript = messageType + ? `window.ReactNativeWebView.postMessage(JSON.stringify({ + messageType: ${JSON.stringify(messageType)}, + ${messageFields.join(",")} + }));` + : ""; + const iifeScript = ` + (() => { + ${script} + ${postMessageScript} + })(); + true; + `; + + logger.debug("injecting JavaScript", iifeScript); + webViewRef.current.injectJavaScript(iifeScript); + } + + function reloadStateFromWebView() { + injectJavaScript( + ` + const url = window.location.href; + const historyState = getHistoryState(); + function getHistoryState() { + if (window.navigation && "canGoForward" in window.navigation) { + // Chrome + return { + canGoBack: window.navigation.canGoBack, + canGoForward: window.navigation.canGoForward + }; + } else { + return undefined; + } + }`, + UPDATE_STATE_AFTER_CANCEL, + ["url", "historyState"] + ); + } + + function setGoingForwardNotBackward() { + setGoingForward(true); + setGoingBack(false); + } + + function setGoingBackwardNotForwardward() { + setGoingForward(false); + setGoingBack(true); + } + + function handleMessage(message: Message) { + logger.debug(`handleMessage: ${toJson(message)}`); + switch (message.messageType) { + case UPDATE_STATE_AFTER_CANCEL: + updateStateAfterCancel(message); + break; + default: + logger.error( + `Unknown message type "${message.messageType}": ${message}` + ); + } + } + + /** + * Corrects the URL and attempts to correct canGoForward/canGoBack after the + * user cancels an in-progress load. + */ + function updateStateAfterCancel(message: UpdateStateAfterCancelMessage) { + if (message.historyState) { + // If the browser was able to provide history state, use it because + // it is probably more reliable than our own tracking. + setCanGoBack(message.historyState.canGoBack); + setCanGoForward(message.historyState.canGoForward); + } else { + // The inputUrl updates immediately to the new URL. If message.url is + // equal to it, then the navigation got far enough to at least partially + // load the page. + + // Use startsWith in case inputUrl has a text fragment which won't be + // visible on the page. + if (inputUrl?.startsWith(message.url)) { + if (goingBack) { + // If we were going back and partially succeeded, we can go forward + setCanGoForward(true); + } else if (goingForward) { + // If we were going forward and partially succeeded, we can go back. + setCanGoBack(true); + if (isClicking) { + // And if we were clicking, there can't be anything forward. + setCanGoForward(false); + } + } + } + } + // Always update the display URL to match the actual page's URL. + setBrowserUrl(message.url); + } + + function cancel() { + webViewRef.current?.stopLoading(); + // If we cancel a load, the only way to know for sure which URL the WebView + // is displaying is to ask it. We will also try to update the forward/back + // state. + reloadStateFromWebView(); + endLoading(); + } + + async function shareBrowserUrl() { + if (!webViewUrl) { + logger.warn("Cannot share current URL because it is missing."); + return; + } + try { + const result = await Share.share({ + message: webViewUrl, + }); + logger.log({ shareResult: result }); + } catch (error: any) { + Alert.alert(error.message); + } + } + + function submitDisplayUrl(url: string) { + if (!url.startsWith("http")) { + url = `https://${url}`; + } + setBothUrls(url); + } + + function startLoading() { + setIsLoading(true); + setLoadProgress(0); + } + + function endLoading() { + setIsLoading(false); + setLoadProgress(100); + } + + // Don't display the webview until we have a currentUrl because it requires a + // valid URL to render and if we pass a placeholder it can enter an infinite + // loop as the callbacks interfere with each other. + const webView = !webViewUrl ? null : ( + { + webViewRef.current = wv; + }} + source={{ uri: webViewUrl }} + allowsBackForwardNavigationGestures={true} + onNavigationStateChange={(navState) => { + if (onNavigationStateChange) { + onNavigationStateChange(navState); + } + setCanGoBack(navState.canGoBack); + setCanGoForward(navState.canGoForward); + setBrowserUrl(navState.url); + if (navState.navigationType === "click") { + setGoingForwardNotBackward(); + setIsClicking(true); + } + }} + onLoadStart={(event) => { + if (onLoadStart) { + onLoadStart(event); + } + const { nativeEvent } = event; + startLoading(); + if (nativeEvent.navigationType === "click") { + setGoingForwardNotBackward(); + setIsClicking(true); + } + }} + onLoadProgress={(event) => { + if (onLoadProgress) { + onLoadProgress(event); + } + const { + nativeEvent: { progress }, + } = event; + if (progress === 1) { + endLoading(); + } else { + setLoadProgress(progress); + } + }} + onLoadEnd={(event) => { + if (onLoadEnd) { + onLoadEnd(event); + } + const { nativeEvent } = event; + endLoading(); + // When a page finishes loading, ensure the display URL reflects what + // the WebView is showing. + setBrowserUrl(nativeEvent.url); + }} + onMessage={(event) => { + if (onMessage) { + onMessage(event); + } + const { + nativeEvent: { data }, + } = event; + const message = JSON.parse(data); + handleMessage(message); + }} + onContentProcessDidTerminate={(event) => { + if (onContentProcessDidTerminate) { + onContentProcessDidTerminate(event); + } + const { nativeEvent } = event; + if (!webViewRef.current) { + logger.error( + "Unable to reload webview because it is missing.", + toJson(nativeEvent) + ); + return; + } + webViewRef.current.reload(); + }} + {...rest} + /> + ); + const activityIndicator = ( + + + + ); + return ( + + {webView || activityIndicator} + {isLoading && } + submitDisplayUrl(text)} + accessibilityLabel="Current URL" + autoCorrect={false} + autoCapitalize="none" + clearButtonMode="while-editing" + keyboardType="url" + returnKeyType="go" + placeholder="Enter URL" + placeholderTextColor={"#666"} + /> + + { + setGoingBackwardNotForwardward(); + goBackward(); + }} + disabled={!canGoBack} + accessibilityLabel="Browser go back" + /> + { + setGoingForwardNotBackward(); + goForward(); + }} + disabled={!canGoForward} + accessibilityLabel="Browser go forward" + /> + {isLoading ? ( + cancel()} + accessibilityLabel="Cancel browser load" + /> + ) : ( + refresh()} + accessibilityLabel="Refresh browser" + /> + )} + void shareBrowserUrl()} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + activityContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, +}); + +export default Browser; diff --git a/howdju-mobile-app/src/screens/BrowserScreen.tsx b/howdju-mobile-app/src/screens/BrowserScreen.tsx index 5171d20c..21489c03 100644 --- a/howdju-mobile-app/src/screens/BrowserScreen.tsx +++ b/howdju-mobile-app/src/screens/BrowserScreen.tsx @@ -1,9 +1,7 @@ -import React, { useContext, useEffect, useRef, useState } from "react"; -import { WebView } from "react-native-webview"; +import React, { useContext } from "react"; import type { ShareDataItem } from "react-native-share-menu"; -import { Alert, Share, StyleSheet, View } from "react-native"; -import { Appbar, TextInput } from "react-native-paper"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { URL } from "react-native-url-polyfill"; +import useDeepCompareEffect from "use-deep-compare-effect"; import { toJson } from "howdju-common"; @@ -11,146 +9,78 @@ import { inferSubmitUrl } from "@/services/submitUrls"; import logger from "@/logger"; import { makeRecentActivityUrl } from "@/services/urls"; import { HowdjuSiteAuthority } from "@/contexts"; +import Browser from "@/components/Browser"; -export function BrowserScreen({ items }: { items: ShareDataItem[] }) { - const webViewRef = useRef(null); +export function BrowserScreen({ + shareDataItems, +}: { + shareDataItems: ShareDataItem[]; +}) { + const howdjuSiteAuthority = useContext(HowdjuSiteAuthority); - const insets = useSafeAreaInsets(); - const safeArea = { - paddingTop: insets.top, - // bottom is handled by the parent container. - paddingLeft: insets.left, - paddingRight: insets.right, - }; + const [url, setUrl] = React.useState(undefined as string | undefined); - const [canGoBack, setCanGoBack] = useState(false); - const [canGoForward, setCanGoForward] = useState(false); - const [currentUrl, setCurrentUrl] = useState(undefined as string | undefined); - - function goBackward() { - webViewRef.current?.goBack(); - } - function goForward() { - webViewRef.current?.goForward(); - } - function refresh() { - webViewRef.current?.reload(); - } - async function shareCurrentUrl() { - if (!currentUrl) { - logger.warn("Cannot share current URL because it is missing."); + // Initialize the URL any time the authority or share items change. + useDeepCompareEffect(() => { + if (!howdjuSiteAuthority) { return; } - try { - const result = await Share.share({ - message: currentUrl, - }); - logger.log({ shareResult: result }); - } catch (error: any) { - Alert.alert(error.message); + if (shareDataItems.length) { + // Regardless of whether the authority or the share items changed, if + // there are changed share items, we should navigate to consume them. + setUrl(inferSubmitUrl(howdjuSiteAuthority, shareDataItems)); + } else if (!url || isHowdjuUrl(url)) { + // Otherwise, only navigate if we have no URL or are already on a Howdju URL. + setUrl(makeRecentActivityUrl(howdjuSiteAuthority)); } - } + }, [howdjuSiteAuthority, shareDataItems]); - const authority = useContext(HowdjuSiteAuthority); - - // Set the currentUrl whenever the share items change. Otherwise don't change it and allow - // navigation to occur without interfering. - useEffect(() => { - if (!authority) { - return; - } - const newCurrentUrl = items.length - ? inferSubmitUrl(authority, items) - : makeRecentActivityUrl(authority); - setCurrentUrl(newCurrentUrl); - }, [authority, items]); - // Don't display the webview until we have a currentUrl because it requires a - // valid URL to render and if we pass a placeholder it can enter an infinite - // loop as the callbacks interfere with each other. - const webView = !currentUrl ? null : ( - { - webViewRef.current = wv; - }} - source={{ uri: currentUrl }} - onError={({ nativeEvent }) => { - logger.error("WebView error: ", nativeEvent); - }} - onHttpError={({ nativeEvent }) => { - logger.warn("WebView HTTP error: ", nativeEvent.statusCode); - }} - onRenderProcessGone={({ nativeEvent }) => { - logger.error("WebView Crashed: ", nativeEvent.didCrash); - }} + return ( + setUrl(url)} onNavigationStateChange={(navState) => { logger.debug(`WebView onNavigationStateChange: ${toJson(navState)}`); - setCanGoBack(navState.canGoBack); - setCanGoForward(navState.canGoForward); - setCurrentUrl(navState.url); }} onLoadStart={({ nativeEvent }) => { logger.debug(`WebView onLoadStart: ${toJson(nativeEvent)}`); }} - onLoadProgress={({ nativeEvent }) => { - logger.debug(`WebView onLoadProgress: ${toJson(nativeEvent)}`); + onLoadProgress={({ nativeEvent: { progress } }) => { + logger.debug(`WebView onLoadProgress: ${toJson({ progress })}`); }} onLoadEnd={({ nativeEvent }) => { logger.debug(`WebView onLoadEnd: ${toJson(nativeEvent)}`); - if ("navigationType" in nativeEvent) { - const { mainDocumentURL } = nativeEvent; - if (mainDocumentURL) { - setCurrentUrl(mainDocumentURL); - } - } + }} + onMessage={({ nativeEvent: { data } }) => { + logger.debug(`WebView onMessage: ${data}`); }} onContentProcessDidTerminate={({ nativeEvent }) => { logger.warn( `Content process terminated, reloading ${toJson(nativeEvent)}` ); - if (!webViewRef.current) { - logger.error("Unable to reload webview because it is missing."); - return; - } - webViewRef.current.reload(); + }} + onError={({ nativeEvent }) => { + logger.error("WebView error: ", nativeEvent); + }} + onHttpError={({ nativeEvent }) => { + logger.warn("WebView HTTP error: ", nativeEvent.statusCode); + }} + onRenderProcessGone={({ nativeEvent }) => { + logger.error("WebView Crashed: ", nativeEvent.didCrash); }} /> ); +} + +function isHowdjuUrl(url: string | undefined) { + if (!url) { + return false; + } + const urlObject = new URL(url); return ( - - {webView} - - - goBackward()} - disabled={!canGoBack} - accessibilityLabel="Browser go back" - /> - goForward()} - disabled={!canGoForward} - accessibilityLabel="Browser go forward" - /> - refresh()} - accessibilityLabel="Refresh browser" - /> - void shareCurrentUrl()} /> - - + urlObject.hostname === "howdju.com" || + urlObject.hostname.endsWith(".howdju.com") ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - }, -}); - export default BrowserScreen; diff --git a/yarn.lock b/yarn.lock index 4b246b1b..e5aa08ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14267,7 +14267,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.5.0": +"buffer@npm:^5.4.3, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -21941,12 +21941,14 @@ __metadata: react-native-safe-area-context: "npm:^4.5.0" react-native-screens: "npm:^3.15.0" react-native-share-menu: "github:Howdju/react-native-share-menu#ff9c65e456cf80b23b881ed2e1247f14337260ec" + react-native-url-polyfill: "npm:^2.0.0" react-native-vector-icons: "npm:^9.2.0" react-native-webview: "npm:^11.15.0" react-test-renderer: "npm:17.0.2" terser-webpack-plugin: "npm:^5.3.3" ts-node: "npm:^10.9.1" typescript: "npm:^4.9.4" + use-deep-compare-effect: "npm:^1.8.1" validator: "npm:^13.9.0" webpack: "npm:^5.76.0" languageName: unknown @@ -31339,6 +31341,17 @@ __metadata: languageName: node linkType: hard +"react-native-url-polyfill@npm:^2.0.0": + version: 2.0.0 + resolution: "react-native-url-polyfill@npm:2.0.0" + dependencies: + whatwg-url-without-unicode: "npm:8.0.0-3" + peerDependencies: + react-native: "*" + checksum: 6a8d605eeb1b0ee9b0f47f1866acc2edfa2131a4a8fb1ea3839ceb507e225b894ed66f49a3bd826fc964f2c8005b3678c9d3b65d07eb0a3b979be830cb618686 + languageName: node + linkType: hard + "react-native-vector-icons@npm:^9.2.0": version: 9.2.0 resolution: "react-native-vector-icons@npm:9.2.0" @@ -36691,6 +36704,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^5.0.0": + version: 5.0.0 + resolution: "webidl-conversions@npm:5.0.0" + checksum: cea864dd9cf1f2133d82169a446fb94427ba089e4676f5895273ea085f165649afe587ae3f19f2f0370751a724bba2d96e9956d652b3e41ac1feaaa4376e2d70 + languageName: node + linkType: hard + "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" @@ -37006,6 +37026,17 @@ __metadata: languageName: node linkType: hard +"whatwg-url-without-unicode@npm:8.0.0-3": + version: 8.0.0-3 + resolution: "whatwg-url-without-unicode@npm:8.0.0-3" + dependencies: + buffer: "npm:^5.4.3" + punycode: "npm:^2.1.1" + webidl-conversions: "npm:^5.0.0" + checksum: aa588b54b75304335c5e189f8572626f989364c2ac5be5a1643ac687c2501f044405e1eb5761d65a826f570befade5fe51a723d917e9ab7672bb65d14065e82f + languageName: node + linkType: hard + "whatwg-url@npm:^11.0.0": version: 11.0.0 resolution: "whatwg-url@npm:11.0.0"