diff --git a/landing/connect-farcaster.react.js b/landing/connect-farcaster.react.js index b2c8e4a49..f7ed6afea 100644 --- a/landing/connect-farcaster.react.js +++ b/landing/connect-farcaster.react.js @@ -1,96 +1,88 @@ // @flow import { AuthKitProvider, useSignIn } from '@farcaster/auth-kit'; import * as React from 'react'; -type FarcasterWebViewMessage = - | { - +type: 'farcaster_url', - +url: string, - } - | { - +type: 'farcaster_data', - +fid: string, - }; +import type { FarcasterWebViewMessage } from 'lib/types/farcaster-types.js'; const config = { domain: 'Comm', siweURL: 'https://comm.app/connect-farcaster', rpcURL: 'https://mainnet.optimism.io', relay: 'https://relay.farcaster.xyz', }; function postMessageToNativeWebView(message: FarcasterWebViewMessage) { window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); } type OnSuccessCallbackArgs = { fid: string, }; function onSuccessCallback({ fid }: OnSuccessCallbackArgs) { postMessageToNativeWebView({ type: 'farcaster_data', fid, }); } function ConnectFarcaster(): React.Node { const signInState = useSignIn({ onSuccess: onSuccessCallback }); const { signIn, connect, reconnect, isSuccess, isError, channelToken, url, validSignature, } = signInState; React.useEffect(() => { if (!channelToken) { connect(); } }, [channelToken, connect]); const messageSentRef = React.useRef(false); const authenticated = isSuccess && validSignature; React.useEffect(() => { if (authenticated) { return; } if (isError) { reconnect(); } signIn(); if (url && messageSentRef.current === false) { messageSentRef.current = true; postMessageToNativeWebView({ type: 'farcaster_url', url: url.toString(), }); } }, [authenticated, isError, reconnect, signIn, url]); return null; } function ConnectFarcasterWrapper(): React.Node { const connectFarcasterWrapper = React.useMemo( () => ( ), [], ); return connectFarcasterWrapper; } export default ConnectFarcasterWrapper; diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js new file mode 100644 index 000000000..8dcecf9b3 --- /dev/null +++ b/lib/types/farcaster-types.js @@ -0,0 +1,15 @@ +// @flow + +// This is a message that the rendered webpage +// (landing/connect-farcaster.react.js) uses to communicate back +// to the React Native WebView that is rendering it +// (native/components/farcaster-web-view.react.js) +export type FarcasterWebViewMessage = + | { + +type: 'farcaster_url', + +url: string, + } + | { + +type: 'farcaster_data', + +fid: string, + }; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index 495bd9a59..8bbb15001 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,257 +1,250 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import { identityGenerateNonceActionTypes, useIdentityGenerateNonce, } from 'lib/actions/user-actions.js'; import type { ServerCallSelectorParams } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { SIWEWebViewMessage, SIWEResult } from 'lib/types/siwe-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { useKeyboardHeight } from '../keyboard/keyboard-hooks.js'; import { useSelector } from '../redux/redux-utils.js'; import type { BottomSheetRef } from '../types/bottom-sheet.js'; +import type { WebViewMessageEvent } from '../types/web-view-types.js'; import { UnknownErrorAlertDetails } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; import { defaultLandingURLPrefix } from '../utils/url-utils.js'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const identityGenerateNonceLoadingStatusSelector = createLoadingStatusSelector( identityGenerateNonceActionTypes, ); const legacySiweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); -type WebViewMessageEvent = { - +nativeEvent: { - +data: string, - ... - }, - ... -}; - type Props = { +onClosed: () => mixed, +onClosing: () => mixed, +onSuccessfulWalletSignature: SIWEResult => mixed, +closing: boolean, +setLoading: boolean => mixed, +keyserverCallParamOverride?: Partial, }; function SIWEPanel(props: Props): React.Node { const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useLegacyAshoatKeyserverCall( getSIWENonce, props.keyserverCallParamOverride, ); const identityGenerateNonce = useIdentityGenerateNonce(); const legacyGetSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const identityGenerateNonceFailed = useSelector( state => identityGenerateNonceLoadingStatusSelector(state) === 'error', ); const { onClosing } = props; const legacySiweAuthCallLoading = useSelector( state => legacySiweAuthLoadingStatusSelector(state) === 'loading', ); const [nonce, setNonce] = React.useState(null); const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = React.useState(null); React.useEffect(() => { const generateNonce = async (nonceFunction: () => Promise) => { try { const response = await nonceFunction(); setNonce(response); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: onClosing }], { cancelable: false }, ); throw e; } }; void (async () => { if (usingCommServicesAccessToken) { void dispatchActionPromise( identityGenerateNonceActionTypes, generateNonce(identityGenerateNonce), ); } else { void dispatchActionPromise( getSIWENonceActionTypes, generateNonce(getSIWENonceCall), ); } const ed25519 = await getContentSigningKey(); setPrimaryIdentityPublicKey(ed25519); })(); }, [ dispatchActionPromise, getSIWENonceCall, identityGenerateNonce, onClosing, ]); const [isLoading, setLoading] = React.useState(true); const [walletConnectModalHeight, setWalletConnectModalHeight] = React.useState(0); const insets = useSafeAreaInsets(); const keyboardHeight = useKeyboardHeight(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (walletConnectModalHeight) { const baseHeight = bottomInset + walletConnectModalHeight + keyboardHeight; if (baseHeight < 400) { return [baseHeight - 10]; } else { return [baseHeight + 5]; } } else { const baseHeight = bottomInset + keyboardHeight; return [baseHeight + 435, baseHeight + 600]; } }, [isLoading, walletConnectModalHeight, bottomInset, keyboardHeight]); const bottomSheetRef = React.useRef(); const snapToIndex = bottomSheetRef.current?.snapToIndex; React.useEffect(() => { // When the snapPoints change, always reset to the first one // Without this, when we close the WalletConnect modal we don't resize snapToIndex?.(0); }, [snapToIndex, snapPoints]); const closeBottomSheet = bottomSheetRef.current?.close; const { closing, onSuccessfulWalletSignature } = props; const handleMessage = React.useCallback( async (event: WebViewMessageEvent) => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, message, signature } = data; if (address && signature) { closeBottomSheet?.(); await onSuccessfulWalletSignature({ address, message, signature }); } } else if (data.type === 'siwe_closed') { onClosing(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { const height = data.state === 'open' ? data.height : 0; setWalletConnectModalHeight(height); } }, [onSuccessfulWalletSignature, onClosing, closeBottomSheet], ); const prevClosingRef = React.useRef(); React.useEffect(() => { if (closing && !prevClosingRef.current) { closeBottomSheet?.(); } prevClosingRef.current = closing; }, [closing, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, }, }), [nonce, primaryIdentityPublicKey], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const walletConnectModalOpen = walletConnectModalHeight !== 0; const backgroundStyle = React.useMemo( () => ({ backgroundColor: walletConnectModalOpen ? '#3396ff' : '#242529', }), [walletConnectModalOpen], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const { onClosed } = props; const onBottomSheetChange = React.useCallback( (index: number) => { if (index === -1) { onClosed(); } }, [onClosed], ); let bottomSheet; if (nonce && primaryIdentityPublicKey) { bottomSheet = ( ); } const setLoadingProp = props.setLoading; const loading = !legacyGetSIWENonceCallFailed && !identityGenerateNonceFailed && (isLoading || legacySiweAuthCallLoading); React.useEffect(() => { setLoadingProp(loading); }, [setLoadingProp, loading]); return bottomSheet; } export default SIWEPanel; diff --git a/native/components/farcaster-web-view.react.js b/native/components/farcaster-web-view.react.js new file mode 100644 index 000000000..c244640c9 --- /dev/null +++ b/native/components/farcaster-web-view.react.js @@ -0,0 +1,71 @@ +// @flow + +import * as React from 'react'; +import { View, Linking } from 'react-native'; +import WebView from 'react-native-webview'; + +import type { FarcasterWebViewMessage } from 'lib/types/farcaster-types.js'; + +import type { WebViewMessageEvent } from '../types/web-view-types.js'; +import { defaultLandingURLPrefix } from '../utils/url-utils.js'; + +const commConnectFarcasterURL = `${defaultLandingURLPrefix}/connect-farcaster`; + +const webViewSource = { uri: commConnectFarcasterURL }; + +const webViewOriginWhitelist = ['*']; + +export type FarcasterWebViewState = 'closed' | 'opening'; + +type Props = { + +onSuccess: (fid: string) => mixed, + +webViewState: FarcasterWebViewState, +}; + +function FarcasterWebView(props: Props): React.Node { + const { onSuccess, webViewState } = props; + + const handleMessage = React.useCallback( + (event: WebViewMessageEvent) => { + const data: FarcasterWebViewMessage = JSON.parse(event.nativeEvent.data); + + if (data.type === 'farcaster_url') { + void Linking.openURL(data.url); + } else if (data.type === 'farcaster_data') { + onSuccess(data.fid); + } + }, + [onSuccess], + ); + + const webView = React.useMemo(() => { + if (webViewState === 'closed') { + return null; + } + + return ( + + + + ); + }, [handleMessage, webViewState]); + + return webView; +} + +const styles = { + webViewContainer: { + height: 0, + overflow: 'hidden', + }, + connectButtonContainer: { + marginHorizontal: 16, + }, +}; + +export default FarcasterWebView; diff --git a/native/types/web-view-types.js b/native/types/web-view-types.js new file mode 100644 index 000000000..c091327a9 --- /dev/null +++ b/native/types/web-view-types.js @@ -0,0 +1,9 @@ +// @flow + +export type WebViewMessageEvent = { + +nativeEvent: { + +data: string, + ... + }, + ... +};