diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index e4ddfc4c0..73c4adf20 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,744 +1,756 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { View, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; import Animated, { EasingNode } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch } from 'react-redux'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { logInActionSources } from 'lib/types/account-types'; import type { Dispatch } from 'lib/types/redux-types'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import EthereumLogo from '../components/ethereum-logo.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { LoggedOutModalRouteName } from '../navigation/route-names'; import { resetUserStateActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { splashStyleSelector } from '../splash'; import { useStyles } from '../themes/colors'; import type { EventSubscription, KeyboardEvent } from '../types/react-native'; import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container'; import { splashBackgroundURI } from './background-info'; import LogInPanel from './log-in-panel.react'; import type { LogInState } from './log-in-panel.react'; import LoggedOutStaffInfo from './logged-out-staff-info.react'; import RegisterPanel from './register-panel.react'; import type { RegisterState } from './register-panel.react'; import SIWEPanel from './siwe-panel.react'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ -type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register' | 'siwe'; +export type LoggedOutMode = + | 'loading' + | 'prompt' + | 'log-in' + | 'register' + | 'siwe'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, 'siwe': 4, }; function isPastPrompt(modeValue: Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state +isForeground: boolean, // Redux state +persistedStateLoaded: boolean, +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, +styles: typeof unboundStyles, // Redux dispatch functions +dispatch: Dispatch, ... }; type State = { +mode: LoggedOutMode, + +nextMode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, }; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EventSubscription; keyboardHideListener: ?EventSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; buttonOpacity: Value; panelPaddingTopValue: Node; panelOpacityValue: Node; constructor(props: Props) { super(props); // Man, this is a lot of boilerplate just to containerize some state. // Mostly due to Flow typing requirements... const setLogInState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); + const initialMode = props.persistedStateLoaded ? 'prompt' : 'loading'; this.state = { - mode: props.persistedStateLoaded ? 'prompt' : 'loading', + mode: initialMode, + nextMode: initialMode, logInState: { state: { usernameInputText: null, passwordInputText: null, }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; - if (props.persistedStateLoaded) { - this.nextMode = 'prompt'; - } + this.nextMode = initialMode; this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.buttonOpacity = new Value(props.persistedStateLoaded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.panelOpacityValue = this.panelOpacity(); } guardedSetState = (change: StateChange, callback?: () => mixed) => { if (this.mounted) { this.setState(change, callback); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; - this.guardedSetState({ mode: newMode }); + this.guardedSetState({ mode: newMode, nextMode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (this.props.isForeground) { this.onForeground(); } } componentWillUnmount() { this.mounted = false; if (this.props.isForeground) { this.onBackground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevProps.persistedStateLoaded && this.props.persistedStateLoaded) { this.setMode('prompt'); } if (!prevProps.rehydrateConcluded && this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (!prevProps.isForeground && this.props.isForeground) { this.onForeground(); } else if (prevProps.isForeground && !this.props.isForeground) { this.onBackground(); } if (this.state.mode === 'prompt' && prevState.mode !== 'prompt') { this.buttonOpacity.setValue(0); Animated.timing(this.buttonOpacity, { easing: EasingNode.out(EasingNode.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } // This gets triggered when an app is killed and restarted // Not when it is returned from being backgrounded async onInitialAppLoad() { if (!initialAppLoad) { return; } initialAppLoad = false; const { loggedIn, cookie, urlPrefix, dispatch } = this.props; const hasUserCookie = cookie && cookie.startsWith('user='); if (loggedIn === !!hasUserCookie) { return; } if (!__DEV__) { const actionSource = loggedIn ? logInActionSources.appStartReduxLoggedInButInvalidCookie : logInActionSources.appStartCookieLoggedInButInvalidRedux; const sessionChange = await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, actionSource, ); if ( sessionChange && sessionChange.cookie && sessionChange.cookie.startsWith('user=') ) { // success! we can expect subsequent actions to fix up the state return; } } this.props.dispatch({ type: resetUserStateActionType }); } hardwareBack = () => { if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 140; const registerPanelSize = Platform.OS === 'ios' ? 181 : 180; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond(eq(this.modeValue, modeNumbers['log-in']), logInContainerSize, 0), cond(eq(this.modeValue, modeNumbers['register']), registerPanelSize, 0), ); const potentialPanelPaddingTop = divide( max(sub(this.contentHeight, this.keyboardHeightValue, containerSize), 0), 2, ); const panelPaddingTop = new Value(-1); const targetPanelPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(panelPaddingTop, 0), [ set(panelPaddingTop, potentialPanelPaddingTop), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), cond( neq(isPastPrompt(prevModeValue), isPastPrompt(this.modeValue)), set(targetPanelPaddingTop, potentialPanelPaddingTop), ), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( neq(panelPaddingTop, targetPanelPaddingTop), set( panelPaddingTop, runTiming(clock, panelPaddingTop, targetPanelPaddingTop), ), ), panelPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = isPastPrompt(this.modeValue); const panelOpacity = new Value(-1); const prevPanelOpacity = new Value(-1); const prevTargetPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelOpacity, 0), [ set(panelOpacity, targetPanelOpacity), set(prevPanelOpacity, targetPanelOpacity), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetPanelOpacity, prevTargetPanelOpacity), [ stopClock(clock), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond( neq(panelOpacity, targetPanelOpacity), set(panelOpacity, runTiming(clock, panelOpacity, targetPanelOpacity)), ), ]), cond( and(eq(panelOpacity, 0), neq(prevPanelOpacity, 0)), call([], this.proceedToNextMode), ), set(prevPanelOpacity, panelOpacity), panelOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if ( event.startCoordinates && _isEqual(event.startCoordinates)(event.endCoordinates) ) { return; } const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; goBackToPrompt = () => { this.nextMode = 'prompt'; + this.guardedSetState({ nextMode: 'prompt' }); this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render() { const { styles } = this.props; let panel = null; let buttons = null; let siweButton = null; if (__DEV__) { siweButton = ( <> Sign in with Ethereum or ); } if (this.state.mode === 'log-in') { panel = ( ); } else if (this.state.mode === 'register') { panel = ( ); } else if (this.state.mode === 'prompt') { const opacityStyle = { opacity: this.buttonOpacity }; buttons = ( {siweButton} Sign in Register ); } else if (this.state.mode === 'loading') { panel = ( ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( Comm {panel} ); let siwePanel; if (this.state.mode === 'siwe') { - siwePanel = ; + siwePanel = ( + + ); } const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} {siwePanel} ); } onPressSIWE = () => { this.setMode('siwe'); }; onPressLogIn = () => { if (Platform.OS !== 'ios') { // For some strange reason, iOS's password management logic doesn't // realize that the username and password fields in LogInPanel are related // if the username field gets focused on mount. To avoid this issue we // need the username and password fields to both appear on-screen before // we focus the username field. However, when we set keyboardHeightValue // to -1 here, we are telling our Reanimated logic to wait until the // keyboard appears before showing LogInPanel. Since we need LogInPanel // to appear before the username field is focused, we need to avoid this // behavior on iOS. this.keyboardHeightValue.setValue(-1); } this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; } const unboundStyles = { animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { borderRadius: 4, marginBottom: 4, marginTop: 4, paddingBottom: 14, paddingLeft: 18, paddingRight: 18, paddingTop: 14, }, buttonContainer: { bottom: 0, left: 0, marginLeft: 30, marginRight: 30, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { fontFamily: 'OpenSans-Semibold', fontSize: 17, textAlign: 'center', }, classicAuthButton: { backgroundColor: 'purpleButton', }, classicAuthButtonText: { color: 'logInText', }, container: { backgroundColor: 'transparent', flex: 1, }, header: { color: 'white', fontFamily: Platform.OS === 'ios' ? 'IBMPlexSans' : 'IBMPlexSans-Medium', fontSize: 56, fontWeight: '500', lineHeight: 66, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, siweButton: { backgroundColor: 'siweButton', flex: 1, flexDirection: 'row', justifyContent: 'center', }, siweButtonText: { color: 'siweButtonText', }, siweOr: { flex: 1, flexDirection: 'row', marginBottom: 18, marginTop: 14, }, siweOrLeftHR: { borderColor: 'logInSpacer', borderTopWidth: 1, flex: 1, marginRight: 18, marginTop: 10, }, siweOrRightHR: { borderColor: 'logInSpacer', borderTopWidth: 1, flex: 1, marginLeft: 18, marginTop: 10, }, siweOrText: { color: 'logInText', fontSize: 17, textAlign: 'center', }, siweIcon: { paddingRight: 10, }, }; const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); const ConnectedLoggedOutModal: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedLoggedOutModal(props: { ... }) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated && navContext), ); const persistedStateLoaded = usePersistedStateLoaded(); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }); export default ConnectedLoggedOutModal; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index ac6f01532..8136940c4 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,209 +1,218 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, } from 'lib/actions/siwe-actions'; import { registerActionTypes, register } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LogInStartingPayload } from 'lib/types/account-types'; import type { SIWEWebViewMessage } from 'lib/types/siwe-types'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { defaultLandingURLPrefix } from '../utils/url-utils'; +import type { LoggedOutMode } from './logged-out-modal.react'; import { setNativeCredentials } from './native-credentials'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); type Props = { +onClose: () => mixed, + +nextMode: LoggedOutMode, }; function SIWEPanel(props: Props): React.Node { const navContext = React.useContext(NavContext); const dispatchActionPromise = useDispatchActionPromise(); const registerAction = useServerCall(register); const getSIWENonceCall = useServerCall(getSIWENonce); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const getSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const [nonce, setNonce] = React.useState(null); React.useEffect(() => { (async () => { dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setNonce(response); })(), ); })(); }, [dispatchActionPromise, getSIWENonceCall]); const [isLoading, setLoading] = React.useState(true); const [isWalletConnectModalOpen, setWalletConnectModalOpen] = React.useState( false, ); const insets = useSafeAreaInsets(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (isWalletConnectModalOpen) { return [bottomInset + 600]; } else { return [bottomInset + 435, bottomInset + 600]; } }, [isLoading, isWalletConnectModalOpen, bottomInset]); 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 handleSIWE = React.useCallback( ({ address, signature }) => { // this is all mocked from register-panel const extraInfo = logInExtraInfo(); dispatchActionPromise( registerActionTypes, registerAction({ username: address, password: signature, ...extraInfo, }), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); setNativeCredentials({ username: address, password: signature }); }, [logInExtraInfo, dispatchActionPromise, registerAction], ); const closeBottomSheet = bottomSheetRef.current?.close; - const { onClose } = props; + const { onClose, nextMode } = props; const handleMessage = React.useCallback( event => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, signature } = data; if (address && signature) { handleSIWE({ address, signature }); } } else if (data.type === 'siwe_closed') { onClose(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { setWalletConnectModalOpen(data.state === 'open'); } }, [handleSIWE, onClose, closeBottomSheet], ); + const prevNextModeRef = React.useRef(); + React.useEffect(() => { + if (nextMode === 'prompt' && prevNextModeRef.current === 'siwe') { + closeBottomSheet?.(); + } + prevNextModeRef.current = nextMode; + }, [nextMode, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, }, }), [nonce], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const handleStyle = React.useMemo( () => ({ backgroundColor: '#242529', }), [], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const onBottomSheetChange = React.useCallback( (index: number) => { if (index === -1) { onClose(); } }, [onClose], ); let bottomSheet; if (nonce) { bottomSheet = ( ); } let activity; if (getSIWENonceCallFailed) { activity = Oops, try again later!; } else if (isLoading) { activity = ; } const activityContainer = React.useMemo( () => ({ flex: 1, }), [], ); return ( <> {activity} {bottomSheet} ); } export default SIWEPanel;