diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index 273778d95..769c0c9d6 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,803 +1,810 @@ // @flow import type { DispatchActionPayload } from 'lib/utils/action-utils'; import type { Dispatch } from 'lib/types/redux-types'; import type { AppState } from '../redux/redux-setup'; import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; import type { LogInState } from './log-in-panel.react'; import type { RegisterState } from './register-panel.react'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import type { ImageStyle } from '../types/styles'; import * as React from 'react'; import { View, StyleSheet, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; import invariant from 'invariant'; import Icon from 'react-native-vector-icons/FontAwesome'; import OnePassword from 'react-native-onepassword'; import PropTypes from 'prop-types'; import _isEqual from 'lodash/fp/isEqual'; import { SafeAreaView } from 'react-native-safe-area-context'; import Animated, { Easing } from 'react-native-reanimated'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import { appStartNativeCredentialsAutoLogIn, appStartReduxLoggedInButInvalidCookie, } from 'lib/actions/user-actions'; import { connect } from 'lib/utils/redux-utils'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import LogInPanelContainer from './log-in-panel-container.react'; import RegisterPanel from './register-panel.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { resetUserStateActionType } from '../redux/action-types'; import { splashBackgroundURI } from './background-info'; import { splashStyleSelector } from '../splash'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container'; import { LoggedOutModalRouteName } from '../navigation/route-names'; import { connectNav, type NavContextType, navContextPropType, } from '../navigation/navigation-context'; -import { runTiming } from '../utils/animation-utils'; +import { + runTiming, + ratchetAlongWithKeyboardHeight, +} from '../utils/animation-utils'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, greaterThan, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register'; type Props = { // Navigation state isForeground: boolean, navContext: ?NavContextType, // Redux state rehydrateConcluded: boolean, cookie: ?string, urlPrefix: string, loggedIn: boolean, dimensions: DimensionsInfo, splashStyle: ImageStyle, // Redux dispatch functions dispatch: Dispatch, dispatchActionPayload: DispatchActionPayload, }; type State = {| mode: LoggedOutMode, onePasswordSupported: boolean, logInState: StateContainer, registerState: StateContainer, |}; class LoggedOutModal extends React.PureComponent { static propTypes = { isForeground: PropTypes.bool.isRequired, navContext: navContextPropType, rehydrateConcluded: PropTypes.bool.isRequired, cookie: PropTypes.string, urlPrefix: PropTypes.string.isRequired, loggedIn: PropTypes.bool.isRequired, dimensions: dimensionsInfoPropType.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; keyboardShowListener: ?EmitterSubscription; keyboardHideListener: ?EmitterSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; logInPanelContainer: ?LogInPanelContainer = null; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; hideForgotPasswordLink = new Value(0); buttonOpacity: Value; panelPaddingTopValue: Value; footerPaddingTopValue: Value; panelOpacityValue: Value; forgotPasswordLinkOpacityValue: Value; constructor(props: Props) { super(props); this.determineOnePasswordSupport(); // 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 }, }, }), ); this.state = { mode: props.rehydrateConcluded ? 'prompt' : 'loading', onePasswordSupported: false, logInState: { state: { usernameOrEmailInputText: '', passwordInputText: '', }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', emailInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; if (props.rehydrateConcluded) { this.nextMode = 'prompt'; } const { height: windowHeight, topInset, bottomInset } = props.dimensions; this.contentHeight = new Value(windowHeight - topInset - bottomInset); this.modeValue = new Value(LoggedOutModal.getModeNumber(this.nextMode)); this.buttonOpacity = new Value(props.rehydrateConcluded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.footerPaddingTopValue = this.footerPaddingTop(); this.panelOpacityValue = this.panelOpacity(); this.forgotPasswordLinkOpacityValue = this.forgotPasswordLinkOpacity(); } guardedSetState = (change: StateChange) => { if (this.mounted) { this.setState(change); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; this.guardedSetState({ mode: newMode }); this.modeValue.setValue(LoggedOutModal.getModeNumber(newMode)); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; static getModeNumber(mode: LoggedOutMode) { if (mode === 'loading') { return 0; } else if (mode === 'prompt') { return 1; } else if (mode === 'log-in') { return 2; } else if (mode === 'register') { return 3; } invariant(false, `${mode} is not a valid LoggedOutMode`); } async determineOnePasswordSupport() { let onePasswordSupported; try { onePasswordSupported = await OnePassword.isSupported(); } catch (e) { onePasswordSupported = false; } this.guardedSetState({ onePasswordSupported }); } 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.rehydrateConcluded && this.props.rehydrateConcluded) { this.setMode('prompt'); 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: Easing.out(Easing.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.height - this.props.dimensions.topInset - this.props.dimensions.bottomInset; const oldContentHeight = prevProps.dimensions.height - prevProps.dimensions.topInset - prevProps.dimensions.bottomInset; 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 ? appStartReduxLoggedInButInvalidCookie : appStartNativeCredentialsAutoLogIn; 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; } } if (loggedIn || hasUserCookie) { this.props.dispatchActionPayload(resetUserStateActionType, null); } } hardwareBack = () => { if (this.nextMode === 'log-in') { invariant(this.logInPanelContainer, 'ref should be set'); const returnValue = this.logInPanelContainer.backFromLogInMode(); if (returnValue) { return true; } } if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const containerSize = add( headerHeight, cond( eq(this.modeValue, 2), // We make space for the reset password button on smaller devices cond(lessThan(this.contentHeight, 600), 195, 165), 0, ), cond( eq(this.modeValue, 3), // We make space for the password manager on smaller devices cond(lessThan(this.contentHeight, 600), 261, 246), 0, ), cond(lessThan(this.modeValue, 2), Platform.OS === 'ios' ? 40 : 61, 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( LoggedOutModal.getModeNumber(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(greaterThan(prevModeValue, 1), greaterThan(this.modeValue, 1)), 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, ]); } footerPaddingTop() { const textHeight = Platform.OS === 'ios' ? 17 : 19; const targetFooterPaddingTop = max( sub(this.contentHeight, max(this.keyboardHeightValue, 0), textHeight, 15), 0, ); const footerPaddingTop = new Value(-1); const prevTargetFooterPaddingTop = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(footerPaddingTop, 0), [ set(footerPaddingTop, targetFooterPaddingTop), set(prevTargetFooterPaddingTop, targetFooterPaddingTop), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetFooterPaddingTop, prevTargetFooterPaddingTop), [ stopClock(clock), set(prevTargetFooterPaddingTop, targetFooterPaddingTop), ]), cond( neq(footerPaddingTop, targetFooterPaddingTop), set( footerPaddingTop, runTiming(clock, footerPaddingTop, targetFooterPaddingTop), ), ), ]), footerPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = greaterThan(this.modeValue, 1); 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, ]); } forgotPasswordLinkOpacity() { const targetForgotPasswordLinkOpacity = and( eq(this.modeValue, 2), not(this.hideForgotPasswordLink), ); const forgotPasswordLinkOpacity = new Value(-1); const prevTargetForgotPasswordLinkOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(forgotPasswordLinkOpacity, 0), [ set(forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity), set( prevTargetForgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetForgotPasswordLinkOpacity, prevTargetForgotPasswordLinkOpacity, ), [ stopClock(clock), set( prevTargetForgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ], ), cond( neq(forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity), set( forgotPasswordLinkOpacity, runTiming( clock, forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ), ), ]), forgotPasswordLinkOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if (_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.keyboardHeightValue.setValue(0); this.modeValue.setValue(LoggedOutModal.getModeNumber('prompt')); Keyboard.dismiss(); }; render() { let panel = null; let buttons = null; 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 = ( LOG IN SIGN UP ); } else if (this.state.mode === 'loading') { panel = ( ); } let forgotPasswordLink = null; if (this.state.mode === 'log-in') { const reanimatedStyle = { top: this.footerPaddingTopValue, opacity: this.forgotPasswordLinkOpacityValue, }; forgotPasswordLink = ( Forgot password? ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( SquadCal {panel} ); const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} {forgotPasswordLink} ); } logInPanelContainerRef = (logInPanelContainer: ?LogInPanelContainer) => { this.logInPanelContainer = logInPanelContainer; }; onPressLogIn = () => { this.keyboardHeightValue.setValue(-1); this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; onPressForgotPassword = () => { invariant(this.logInPanelContainer, 'ref should be set'); this.logInPanelContainer.onPressForgotPassword(); }; } const styles = StyleSheet.create({ animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginBottom: 10, marginLeft: 40, marginRight: 40, marginTop: 10, paddingBottom: 6, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, buttonContainer: { bottom: 0, left: 0, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { color: '#000000FF', fontFamily: 'OpenSans-Semibold', fontSize: 22, textAlign: 'center', }, container: { backgroundColor: 'transparent', flex: 1, }, forgotPasswordText: { color: '#8899FF', }, forgotPasswordTextContainer: { alignSelf: 'flex-end', position: 'absolute', right: 20, }, header: { color: 'white', fontFamily: 'Anaheim-Regular', fontSize: 48, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); export default connectNav((context: ?NavContextType) => ({ isForeground: isForegroundSelector(context), navContext: context, }))( connect( (state: AppState, ownProps: { navContext: ?NavContextType }) => ({ rehydrateConcluded: !!( state._persist && state._persist.rehydrated && ownProps.navContext ), cookie: state.cookie, urlPrefix: state.urlPrefix, loggedIn: isLoggedIn(state), dimensions: state.dimensions, splashStyle: splashStyleSelector(state), }), null, true, )(LoggedOutModal), ); diff --git a/native/account/verification-modal.react.js b/native/account/verification-modal.react.js index 1c1b193dd..36d1a07c9 100644 --- a/native/account/verification-modal.react.js +++ b/native/account/verification-modal.react.js @@ -1,587 +1,594 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { type VerifyField, verifyField, type HandleVerificationCodeResult, } from 'lib/types/verify-types'; import type { KeyboardEvent } from '../keyboard/keyboard'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import type { ImageStyle } from '../types/styles'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import { Image, Text, View, StyleSheet, ActivityIndicator, Platform, Keyboard, TouchableHighlight, } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import invariant from 'invariant'; import OnePassword from 'react-native-onepassword'; import PropTypes from 'prop-types'; import { SafeAreaView } from 'react-native-safe-area-context'; import Animated from 'react-native-reanimated'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { connect } from 'lib/utils/redux-utils'; import { handleVerificationCodeActionTypes, handleVerificationCode, } from 'lib/actions/user-actions'; import sleep from 'lib/utils/sleep'; import ConnectedStatusBar from '../connected-status-bar.react'; import ResetPasswordPanel from './reset-password-panel.react'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { splashBackgroundURI } from './background-info'; import { splashStyleSelector } from '../splash'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { VerificationModalRouteName } from '../navigation/route-names'; import { connectNav, type NavContextType, } from '../navigation/navigation-context'; -import { runTiming } from '../utils/animation-utils'; +import { + runTiming, + ratchetAlongWithKeyboardHeight, +} from '../utils/animation-utils'; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, greaterThan, lessThan, greaterOrEq, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ export type VerificationModalParams = {| verifyCode: string, |}; type VerificationModalMode = 'simple-text' | 'reset-password'; type Props = { navigation: RootNavigationProp<'VerificationModal'>, route: NavigationRoute<'VerificationModal'>, // Navigation state isForeground: boolean, // Redux state dimensions: DimensionsInfo, splashStyle: ImageStyle, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs handleVerificationCode: ( code: string, ) => Promise, }; type State = {| mode: VerificationModalMode, verifyField: ?VerifyField, errorMessage: ?string, resetPasswordUsername: ?string, onePasswordSupported: boolean, |}; class VerificationModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ clearRootModals: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ verifyCode: PropTypes.string.isRequired, }).isRequired, }).isRequired, isForeground: PropTypes.bool.isRequired, dimensions: dimensionsInfoPropType.isRequired, dispatchActionPromise: PropTypes.func.isRequired, handleVerificationCode: PropTypes.func.isRequired, }; keyboardShowListener: ?Object; keyboardHideListener: ?Object; activeAlert = false; nextMode: VerificationModalMode = 'simple-text'; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; paddingTopValue: Value; resetPasswordPanelOpacityValue: Value; constructor(props: Props) { super(props); this.state = { mode: 'simple-text', verifyField: null, errorMessage: null, resetPasswordUsername: null, onePasswordSupported: false, }; const { height: windowHeight, topInset, bottomInset } = props.dimensions; this.contentHeight = new Value(windowHeight - topInset - bottomInset); this.modeValue = new Value(VerificationModal.getModeNumber(this.nextMode)); this.paddingTopValue = this.paddingTop(); this.resetPasswordPanelOpacityValue = this.resetPasswordPanelOpacity(); } proceedToNextMode = () => { this.setState({ mode: this.nextMode }); }; static getModeNumber(mode: VerificationModalMode) { if (mode === 'simple-text') { return 0; } else if (mode === 'reset-password') { return 1; } invariant(false, `${mode} is not a valid VerificationModalMode`); } paddingTop() { const potentialPaddingTop = divide( max( sub( this.contentHeight, cond(eq(this.modeValue, 0), 90), cond(eq(this.modeValue, 1), 165), this.keyboardHeightValue, ), 0, ), 2, ); const paddingTop = new Value(-1); const targetPaddingTop = new Value(-1); const prevModeValue = new Value( VerificationModal.getModeNumber(this.nextMode), ); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(paddingTop, 0), [ set(paddingTop, potentialPaddingTop), set(targetPaddingTop, potentialPaddingTop), ]), 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), set(targetPaddingTop, potentialPaddingTop), set(prevModeValue, this.modeValue), ], ), + ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ + stopClock(clock), + set(targetPaddingTop, potentialPaddingTop), + ]), cond( neq(paddingTop, targetPaddingTop), set(paddingTop, runTiming(clock, paddingTop, targetPaddingTop)), ), paddingTop, ]); } resetPasswordPanelOpacity() { const targetResetPasswordPanelOpacity = greaterThan(this.modeValue, 0); const resetPasswordPanelOpacity = new Value(-1); const prevResetPasswordPanelOpacity = new Value(-1); const prevTargetResetPasswordPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(resetPasswordPanelOpacity, 0), [ set(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set(prevResetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetResetPasswordPanelOpacity, prevTargetResetPasswordPanelOpacity, ), [ stopClock(clock), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ], ), cond( neq(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( resetPasswordPanelOpacity, runTiming( clock, resetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ), ), ]), cond( and( eq(resetPasswordPanelOpacity, 0), neq(prevResetPasswordPanelOpacity, 0), ), call([], this.proceedToNextMode), ), set(prevResetPasswordPanelOpacity, resetPasswordPanelOpacity), resetPasswordPanelOpacity, ]); } async determineOnePasswordSupport() { let onePasswordSupported; try { onePasswordSupported = await OnePassword.isSupported(); } catch (e) { onePasswordSupported = false; } this.setState({ onePasswordSupported }); } componentDidMount() { this.determineOnePasswordSupport(); this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); Keyboard.dismiss(); if (this.props.isForeground) { this.onForeground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.verifyField === verifyField.EMAIL && prevState.verifyField !== verifyField.EMAIL ) { sleep(1500).then(this.dismiss); } const prevCode = prevProps.route.params.verifyCode; const code = this.props.route.params.verifyCode; if (code !== prevCode) { Keyboard.dismiss(); this.nextMode = 'simple-text'; this.modeValue.setValue(VerificationModal.getModeNumber(this.nextMode)); this.keyboardHeightValue.setValue(0); this.setState({ mode: this.nextMode, verifyField: null, errorMessage: null, resetPasswordUsername: null, }); this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); } if (this.props.isForeground && !prevProps.isForeground) { this.onForeground(); } else if (!this.props.isForeground && prevProps.isForeground) { this.onBackground(); } const newContentHeight = this.props.dimensions.height - this.props.dimensions.topInset - this.props.dimensions.bottomInset; const oldContentHeight = prevProps.dimensions.height - prevProps.dimensions.topInset - prevProps.dimensions.bottomInset; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } } dismiss = () => { this.props.navigation.clearRootModals([this.props.route.key]); }; onResetPasswordSuccess = async () => { this.nextMode = 'simple-text'; this.modeValue.setValue(VerificationModal.getModeNumber(this.nextMode)); this.keyboardHeightValue.setValue(0); Keyboard.dismiss(); // Wait a couple seconds before letting the SUCCESS action propagate and // clear VerificationModal await sleep(1750); this.dismiss(); }; async handleVerificationCodeAction() { const code = this.props.route.params.verifyCode; try { const result = await this.props.handleVerificationCode(code); if (result.verifyField === verifyField.EMAIL) { this.setState({ verifyField: result.verifyField }); } else if (result.verifyField === verifyField.RESET_PASSWORD) { this.nextMode = 'reset-password'; this.modeValue.setValue(VerificationModal.getModeNumber(this.nextMode)); this.keyboardHeightValue.setValue(-1); this.setState({ verifyField: result.verifyField, mode: 'reset-password', resetPasswordUsername: result.resetPasswordUsername, }); } } catch (e) { if (e.message === 'invalid_code') { this.setState({ errorMessage: 'Invalid verification code' }); } else { this.setState({ errorMessage: 'Unknown error occurred' }); } throw e; } } keyboardShow = (event: KeyboardEvent) => { 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; }; render() { const statusBar = ; const background = ( ); const closeButton = ( ); let content; if (this.state.mode === 'reset-password') { const code = this.props.route.params.verifyCode; invariant(this.state.resetPasswordUsername, 'should be set'); content = ( ); } else if (this.state.errorMessage) { content = ( {this.state.errorMessage} ); } else if (this.state.verifyField !== null) { let message; if (this.state.verifyField === verifyField.EMAIL) { message = 'Thanks for verifying your email!'; } else { message = 'Your password has been reset.'; } content = ( {message} ); } else { content = ( Verifying code... ); } const padding = { paddingTop: this.paddingTopValue }; const animatedContent = ( {content} ); return ( {background} {statusBar} {animatedContent} {closeButton} ); } } const styles = StyleSheet.create({ closeButton: { backgroundColor: '#D0D0D055', borderRadius: 3, height: 36, position: 'absolute', right: 15, top: 15, width: 36, }, closeButtonIcon: { left: 10, position: 'absolute', top: 8, }, container: { backgroundColor: 'transparent', flex: 1, }, contentContainer: { height: 90, }, icon: { textAlign: 'center', }, loadingText: { bottom: 0, color: 'white', fontSize: 20, left: 0, position: 'absolute', right: 0, textAlign: 'center', }, modalBackground: { height: ('100%': number | string), position: 'absolute', width: ('100%': number | string), }, }); registerFetchKey(handleVerificationCodeActionTypes); const isForegroundSelector = createIsForegroundSelector( VerificationModalRouteName, ); export default connectNav((context: ?NavContextType) => ({ isForeground: isForegroundSelector(context), }))( connect( (state: AppState) => ({ dimensions: state.dimensions, splashStyle: splashStyleSelector(state), }), { handleVerificationCode }, )(VerificationModal), ); diff --git a/native/utils/animation-utils.js b/native/utils/animation-utils.js index 62b78b6d2..a1b410742 100644 --- a/native/utils/animation-utils.js +++ b/native/utils/animation-utils.js @@ -1,96 +1,135 @@ // @flow import Animated, { Easing } from 'react-native-reanimated'; import { State as GestureState } from 'react-native-gesture-handler'; +import { Platform } from 'react-native'; /* eslint-disable import/no-named-as-default-member */ const { Clock, Value, + block, cond, not, + and, greaterThan, + lessThan, eq, sub, set, + max, startClock, stopClock, clockRunning, timing, } = Animated; /* eslint-enable import/no-named-as-default-member */ function clamp(value: Value, minValue: Value, maxValue: Value): Value { return cond( greaterThan(value, maxValue), maxValue, cond(greaterThan(minValue, value), minValue, value), ); } function delta(value: Value) { const prevValue = new Value(0); const deltaValue = new Value(0); return [ set(deltaValue, cond(eq(prevValue, 0), 0, sub(value, prevValue))), set(prevValue, value), deltaValue, ]; } function gestureJustStarted(state: Value) { const prevValue = new Value(-1); return cond(eq(prevValue, state), 0, [ set(prevValue, state), eq(state, GestureState.ACTIVE), ]); } function gestureJustEnded(state: Value) { const prevValue = new Value(-1); return cond(eq(prevValue, state), 0, [ set(prevValue, state), eq(state, GestureState.END), ]); } const defaultTimingConfig = { duration: 250, easing: Easing.out(Easing.ease), }; type TimingConfig = $Shape; function runTiming( clock: Clock, initialValue: Value | number, finalValue: Value | number, startStopClock: boolean = true, config: TimingConfig = defaultTimingConfig, ): Value { const state = { finished: new Value(0), position: new Value(0), frameTime: new Value(0), time: new Value(0), }; const timingConfig = { ...defaultTimingConfig, ...config, toValue: new Value(0), }; return [ cond(not(clockRunning(clock)), [ set(state.finished, 0), set(state.frameTime, 0), set(state.time, 0), set(state.position, initialValue), set(timingConfig.toValue, finalValue), startStopClock && startClock(clock), ]), timing(clock, state, timingConfig), cond(state.finished, startStopClock && stopClock(clock)), state.position, ]; } -export { clamp, delta, gestureJustStarted, gestureJustEnded, runTiming }; +// You provide a node that performs a "ratchet", +// and this function will call it as keyboard height increases +function ratchetAlongWithKeyboardHeight( + keyboardHeight: Animated.Node, + ratchetFunction: Animated.Node, +) { + const prevKeyboardHeightValue = new Value(-1); + const whenToUpdate = Platform.select({ + // In certain situations, iOS will send multiple keyboardShows in rapid + // succession with increasing height values. Only the final value has any + // semblance of reality. I've encountered this when using the native + // password management integration + ios: greaterThan(keyboardHeight, max(prevKeyboardHeightValue, 0)), + // Android's keyboard can resize due to user interaction sometimes. In these + // cases it can get quite big, in which case we don't want to update + defaut: and(eq(prevKeyboardHeightValue, 0), greaterThan(keyboardHeight, 0)), + }); + return block([ + cond( + lessThan(prevKeyboardHeightValue, 0), + set(prevKeyboardHeightValue, keyboardHeight), + ), + cond(whenToUpdate, ratchetFunction), + set(prevKeyboardHeightValue, keyboardHeight), + ]); +} + +export { + clamp, + delta, + gestureJustStarted, + gestureJustEnded, + runTiming, + ratchetAlongWithKeyboardHeight, +};