diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index 5e267950d..a72b80c81 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,823 +1,817 @@ // @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 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, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoPropType, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; 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, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, }; function isPastPrompt(modeValue: Animated.Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state isForeground: boolean, navContext: ?NavContextType, // Redux state rehydrateConcluded: boolean, cookie: ?string, urlPrefix: string, loggedIn: boolean, - dimensions: DimensionsInfo, + dimensions: DerivedDimensionsInfo, splashStyle: ImageStyle, // Redux dispatch functions dispatch: Dispatch, dispatchActionPayload: DispatchActionPayload, }; type State = {| mode: LoggedOutMode, 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, + dimensions: derivedDimensionsInfoPropType.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); // 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', 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.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[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(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.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; + 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 ? 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 promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 165; const registerPanelSize = 246; // On large enough devices, we want to properly center the panels on screen. // But on smaller devices, this can lead to some issues: // - We need enough space below the log-in panel to render the // "Forgot password?" link // - On Android, ratchetAlongWithKeyboardHeight won't adjust the panel's // position when the keyboard size changes // To address these issues, we artifically increase the panel sizes so that // they get positioned a little higher than center on small devices. const smallDeviceThreshold = 600; const smallDeviceLogInContainerSize = 195; const smallDeviceRegisterPanelSize = 261; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond( eq(this.modeValue, modeNumbers['log-in']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceLogInContainerSize, logInContainerSize, ), 0, ), cond( eq(this.modeValue, modeNumbers['register']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceRegisterPanelSize, 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, ]); } footerPaddingTop() { const textHeight = Platform.OS === 'ios' ? 17 : 19; const spacingAboveKeyboard = 15; const targetFooterPaddingTop = max( sub( this.contentHeight, max(this.keyboardHeightValue, 0), textHeight, spacingAboveKeyboard, ), 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 = 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, ]); } forgotPasswordLinkOpacity() { const targetForgotPasswordLinkOpacity = and( eq(this.modeValue, modeNumbers['log-in']), not(this.hideForgotPasswordLink), ); const forgotPasswordLinkOpacity = new Value(0); const prevTargetForgotPasswordLinkOpacity = new Value(0); const clock = new Clock(); return block([ 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(modeNumbers['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 = () => { 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'); }; 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, + dimensions: derivedDimensionsInfoSelector(state), splashStyle: splashStyleSelector(state), }), null, true, )(LoggedOutModal), ); diff --git a/native/account/verification-modal.react.js b/native/account/verification-modal.react.js index 7a766981e..6a78b55c0 100644 --- a/native/account/verification-modal.react.js +++ b/native/account/verification-modal.react.js @@ -1,582 +1,576 @@ // @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 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, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoPropType, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, 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'; const modeNumbers: { [VerificationModalMode]: number } = { 'simple-text': 0, 'reset-password': 1, }; type Props = { navigation: RootNavigationProp<'VerificationModal'>, route: NavigationRoute<'VerificationModal'>, // Navigation state isForeground: boolean, // Redux state - dimensions: DimensionsInfo, + dimensions: DerivedDimensionsInfo, 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, |}; 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, + dimensions: derivedDimensionsInfoPropType.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, }; - const { height: windowHeight, topInset, bottomInset } = props.dimensions; - this.contentHeight = new Value(windowHeight - topInset - bottomInset); + this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.paddingTopValue = this.paddingTop(); this.resetPasswordPanelOpacityValue = this.resetPasswordPanelOpacity(); } proceedToNextMode = () => { this.setState({ mode: this.nextMode }); }; paddingTop() { const simpleTextHeight = 90; const resetPasswordPanelHeight = 165; const potentialPaddingTop = divide( max( sub( this.contentHeight, cond( eq(this.modeValue, modeNumbers['simple-text']), simpleTextHeight, ), cond( eq(this.modeValue, modeNumbers['reset-password']), resetPasswordPanelHeight, ), this.keyboardHeightValue, ), 0, ), 2, ); const paddingTop = new Value(-1); const targetPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[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 = eq( this.modeValue, modeNumbers['reset-password'], ); 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, ]); } componentDidMount() { 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(modeNumbers[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; + 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); } 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(modeNumbers[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(modeNumbers[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, + dimensions: derivedDimensionsInfoSelector(state), splashStyle: splashStyleSelector(state), }), { handleVerificationCode }, )(VerificationModal), ); diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index 9f1df4aa6..5093ef9c3 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1132 +1,1128 @@ // @flow import { type EntryInfo, entryInfoPropType, type CalendarQuery, type CalendarQueryUpdateResult, } from 'lib/types/entry-types'; import type { AppState } from '../redux/redux-setup'; import type { CalendarItem, SectionHeaderItem, SectionFooterItem, LoaderItem, } from '../selectors/calendar-selectors'; import type { ViewToken } from '../types/react-native'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { KeyboardEvent } from '../keyboard/keyboard'; import { type CalendarFilter, calendarFilterPropType, } from 'lib/types/filter-types'; -import { - type DimensionsInfo, - dimensionsInfoPropType, -} from '../redux/dimensions-updater.react'; import { type LoadingStatus, loadingStatusPropType, } from 'lib/types/loading-types'; import { type ConnectionStatus, connectionStatusPropType, } from 'lib/types/socket-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import { View, Text, FlatList, AppState as NativeAppState, Platform, LayoutAnimation, TouchableWithoutFeedback, } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import _findIndex from 'lodash/fp/findIndex'; import _map from 'lodash/fp/map'; import _find from 'lodash/fp/find'; import _filter from 'lodash/fp/filter'; import _sum from 'lodash/fp/sum'; import _pickBy from 'lodash/fp/pickBy'; import _size from 'lodash/fp/size'; import _throttle from 'lodash/throttle'; import SafeAreaView from 'react-native-safe-area-view'; import { entryKey } from 'lib/shared/entry-utils'; import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { connect } from 'lib/utils/redux-utils'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import sleep from 'lib/utils/sleep'; import { Entry, InternalEntry, dummyNodeForEntryHeightMeasurement, } from './entry.react'; import { calendarListData } from '../selectors/calendar-selectors'; import { createIsForegroundSelector, createActiveTabSelector, } from '../navigation/nav-selectors'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import SectionFooter from './section-footer.react'; import CalendarInputBar from './calendar-input-bar.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import DisconnectedBar from '../navigation/disconnected-bar.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, type IndicatorStyle, indicatorStylePropType, indicatorStyleSelector, } from '../themes/colors'; import ContentLoading from '../components/content-loading.react'; import { connectNav, type NavContextType, } from '../navigation/navigation-context'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoPropType, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; export type EntryInfoWithHeight = {| ...EntryInfo, textHeight: number, |}; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | {| itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, |}; type ExtraData = $ReadOnly<{| activeEntries: { [key: string]: boolean }, visibleEntries: { [key: string]: boolean }, |}>; const safeAreaViewForceInset = { top: 'always', bottom: 'never', }; type Props = { navigation: TabNavigationProp<'Calendar'>, route: NavigationRoute<'Calendar'>, // Redux state listData: ?$ReadOnlyArray, calendarActive: boolean, startDate: string, endDate: string, calendarFilters: $ReadOnlyArray, - dimensions: DimensionsInfo, + dimensions: DerivedDimensionsInfo, loadingStatus: LoadingStatus, connectionStatus: ConnectionStatus, colors: Colors, styles: typeof styles, indicatorStyle: IndicatorStyle, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; type State = {| listDataWithHeights: ?$ReadOnlyArray, readyToShowList: boolean, extraData: ExtraData, currentlyEditing: $ReadOnlyArray, |}; class Calendar extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, addListener: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, isFocused: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ key: PropTypes.string.isRequired, }).isRequired, listData: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']), key: PropTypes.string.isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['header']), dateString: PropTypes.string.isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['entryInfo']), entryInfo: entryInfoPropType.isRequired, threadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['footer']), dateString: PropTypes.string.isRequired, }), ]), ), calendarActive: PropTypes.bool.isRequired, startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, calendarFilters: PropTypes.arrayOf(calendarFilterPropType).isRequired, - dimensions: dimensionsInfoPropType.isRequired, + dimensions: derivedDimensionsInfoPropType.isRequired, loadingStatus: loadingStatusPropType.isRequired, connectionStatus: connectionStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, indicatorStyle: indicatorStylePropType.isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateCalendarQuery: PropTypes.func.isRequired, }; flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?{ +remove: () => void }; keyboardDismissListener: ?{ +remove: () => void }; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { NativeAppState.addEventListener('change', this.handleAppStateChange); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { NativeAppState.removeEventListener('change', this.handleAppStateChange); if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate, } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); let offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } renderItem = (row: { item: CalendarItemWithHeight }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: CalendarItemWithHeight): number { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(Calendar.itemHeight)); } render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { - const { - height: windowHeight, - topInset, - bottomInset, - tabBarHeight, - } = this.props.dimensions; - return windowHeight - topInset - bottomInset - tabBarHeight; + const { safeAreaHeight, tabBarHeight } = this.props.dimensions; + return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( k => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = _findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, )(data); if (index === null || index === undefined) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; for (let token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( item => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const styles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'listSeparator', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); export default connectNav((context: ?NavContextType) => ({ calendarActive: activeTabSelector(context) || activeThreadPickerSelector(context), }))( connect( (state: AppState) => ({ listData: calendarListData(state), startDate: state.navInfo.startDate, endDate: state.navInfo.endDate, calendarFilters: state.calendarFilters, - dimensions: state.dimensions, + dimensions: derivedDimensionsInfoSelector(state), loadingStatus: loadingStatusSelector(state), connectionStatus: state.connection.status, colors: colorsSelector(state), styles: stylesSelector(state), indicatorStyle: indicatorStyleSelector(state), }), { updateCalendarQuery }, )(Calendar), ); diff --git a/native/media/multimedia-modal.react.js b/native/media/multimedia-modal.react.js index 5f59b4b8d..f37b1fda5 100644 --- a/native/media/multimedia-modal.react.js +++ b/native/media/multimedia-modal.react.js @@ -1,1251 +1,1252 @@ // @flow import { type MediaInfo, mediaInfoPropType, type Dimensions, } from 'lib/types/media-types'; -import { - type DimensionsInfo, - dimensionsInfoPropType, -} from '../redux/dimensions-updater.react'; import type { AppState } from '../redux/redux-setup'; import { type VerticalBounds, verticalBoundsPropType, type LayoutCoordinates, layoutCoordinatesPropType, } from '../types/layout-types'; import type { NativeMethodsMixinType } from '../types/react-native'; import type { ChatMultimediaMessageInfoItem } from '../chat/multimedia-message.react'; import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, } from 'react-native'; import PropTypes from 'prop-types'; import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import Animated from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import Multimedia from './multimedia.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { clamp, gestureJustStarted, gestureJustEnded, runTiming, } from '../utils/animation-utils'; import { intentionalSaveMedia } from './save-media'; import { withOverlayContext, type OverlayContextType, overlayContextPropType, } from '../navigation/overlay-context'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoPropType, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, set, call, cond, not, and, or, eq, neq, greaterThan, lessThan, add, sub, multiply, divide, pow, max, min, round, abs, interpolate, startClock, stopClock, clockRunning, decay, } = Animated; /* eslint-enable import/no-named-as-default-member */ function scaleDelta(value: Value, gestureActive: Value) { const diffThisFrame = new Value(1); const prevValue = new Value(1); return cond( gestureActive, [ set(diffThisFrame, divide(value, prevValue)), set(prevValue, value), diffThisFrame, ], set(prevValue, 1), ); } function panDelta(value: Value, gestureActive: Value) { const diffThisFrame = new Value(0); const prevValue = new Value(0); return cond( gestureActive, [ set(diffThisFrame, sub(value, prevValue)), set(prevValue, value), diffThisFrame, ], set(prevValue, 0), ); } function runDecay( clock: Clock, velocity: Value, initialPosition: Value, startStopClock: boolean = true, ): Value { const state = { finished: new Value(0), velocity: new Value(0), position: new Value(0), time: new Value(0), }; const config = { deceleration: 0.99 }; return [ cond(not(clockRunning(clock)), [ set(state.finished, 0), set(state.velocity, velocity), set(state.position, initialPosition), set(state.time, 0), startStopClock && startClock(clock), ]), decay(clock, state, config), cond(state.finished, startStopClock && stopClock(clock)), state.position, ]; } export type MultimediaModalParams = {| presentedFrom: string, mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, verticalBounds: VerticalBounds, item: ChatMultimediaMessageInfoItem, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethodsMixinType, >; type Props = {| navigation: AppNavigationProp<'MultimediaModal'>, route: NavigationRoute<'MultimediaModal'>, // Redux state - dimensions: DimensionsInfo, + dimensions: DerivedDimensionsInfo, // withOverlayContext overlayContext: ?OverlayContextType, |}; type State = {| closeButtonEnabled: boolean, actionLinksEnabled: boolean, |}; class MultimediaModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ mediaInfo: mediaInfoPropType.isRequired, initialCoordinates: layoutCoordinatesPropType.isRequired, verticalBounds: verticalBoundsPropType.isRequired, item: chatMessageItemPropType.isRequired, }).isRequired, }).isRequired, - dimensions: dimensionsInfoPropType.isRequired, + dimensions: derivedDimensionsInfoPropType.isRequired, overlayContext: overlayContextPropType, }; state = { closeButtonEnabled: true, actionLinksEnabled: true, }; closeButton: ?React.ElementRef; saveButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); closeButtonLastState = new Value(1); saveButtonX = new Value(-1); saveButtonY = new Value(-1); saveButtonWidth = new Value(0); saveButtonHeight = new Value(0); actionLinksLastState = new Value(1); centerX: Value; centerY: Value; frameWidth: Value; frameHeight: Value; imageWidth: Value; imageHeight: Value; pinchHandler = React.createRef(); panHandler = React.createRef(); singleTapHandler = React.createRef(); doubleTapHandler = React.createRef(); handlerRefs = [ this.pinchHandler, this.panHandler, this.singleTapHandler, this.doubleTapHandler, ]; beforeDoubleTapRefs; beforeSingleTapRefs; pinchEvent; panEvent; singleTapEvent; doubleTapEvent; scale: Value; x: Value; y: Value; backdropOpacity: Value; imageContainerOpacity: Value; actionLinksOpacity: Value; closeButtonOpacity: Value; constructor(props: Props) { super(props); this.updateDimensions(); const { imageWidth, imageHeight } = this; const left = sub(this.centerX, divide(imageWidth, 2)); const top = sub(this.centerY, divide(imageHeight, 2)); const { initialCoordinates } = props.route.params; const initialScale = divide(initialCoordinates.width, imageWidth); const initialTranslateX = sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ); const initialTranslateY = sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ); const { overlayContext } = props; invariant(overlayContext, 'MultimediaModal should have OverlayContext'); const navigationProgress = overlayContext.position; // The inputs we receive from PanGestureHandler const panState = new Value(-1); const panTranslationX = new Value(0); const panTranslationY = new Value(0); const panVelocityX = new Value(0); const panVelocityY = new Value(0); const panAbsoluteX = new Value(0); const panAbsoluteY = new Value(0); this.panEvent = event([ { nativeEvent: { state: panState, translationX: panTranslationX, translationY: panTranslationY, velocityX: panVelocityX, velocityY: panVelocityY, absoluteX: panAbsoluteX, absoluteY: panAbsoluteY, }, }, ]); const curPanActive = new Value(0); const panActive = [ cond( and( gestureJustStarted(panState), this.outsideButtons( sub(panAbsoluteX, panTranslationX), sub(panAbsoluteY, panTranslationY), ), ), set(curPanActive, 1), ), cond(gestureJustEnded(panState), set(curPanActive, 0)), curPanActive, ]; const lastPanActive = new Value(0); const panJustEnded = cond(eq(lastPanActive, panActive), 0, [ set(lastPanActive, panActive), eq(panActive, 0), ]); // The inputs we receive from PinchGestureHandler const pinchState = new Value(-1); const pinchScale = new Value(1); const pinchFocalX = new Value(0); const pinchFocalY = new Value(0); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, focalX: pinchFocalX, focalY: pinchFocalY, }, }, ]); const pinchActive = eq(pinchState, GestureState.ACTIVE); // The inputs we receive from single TapGestureHandler const singleTapState = new Value(-1); const singleTapX = new Value(0); const singleTapY = new Value(0); this.singleTapEvent = event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]); // The inputs we receive from double TapGestureHandler const doubleTapState = new Value(-1); const doubleTapX = new Value(0); const doubleTapY = new Value(0); this.doubleTapEvent = event([ { nativeEvent: { state: doubleTapState, x: doubleTapX, y: doubleTapY, }, }, ]); // The all-important outputs const curScale = new Value(1); const curX = new Value(0); const curY = new Value(0); const curBackdropOpacity = new Value(1); const curCloseButtonOpacity = new Value(1); const curActionLinksOpacity = new Value(1); // The centered variables help us know if we need to be recentered const recenteredScale = max(curScale, 1); const horizontalPanSpace = this.horizontalPanSpace(recenteredScale); const verticalPanSpace = this.verticalPanSpace(recenteredScale); const resetXClock = new Clock(); const resetYClock = new Clock(); const zoomClock = new Clock(); const dismissingFromPan = new Value(0); const roundedCurScale = divide(round(multiply(curScale, 1000)), 1000); const gestureActive = or(pinchActive, panActive); const activeInteraction = or( gestureActive, clockRunning(zoomClock), dismissingFromPan, ); const updates = [ this.pinchUpdate( pinchActive, pinchScale, pinchFocalX, pinchFocalY, curScale, curX, curY, ), this.panUpdate(panActive, panTranslationX, panTranslationY, curX, curY), this.singleTapUpdate( singleTapState, singleTapX, singleTapY, roundedCurScale, curCloseButtonOpacity, curActionLinksOpacity, ), this.doubleTapUpdate( doubleTapState, doubleTapX, doubleTapY, roundedCurScale, zoomClock, gestureActive, curScale, curX, curY, ), this.backdropOpacityUpdate( panJustEnded, pinchActive, panVelocityX, panVelocityY, curX, curY, roundedCurScale, curBackdropOpacity, dismissingFromPan, ), this.recenter( resetXClock, resetYClock, activeInteraction, recenteredScale, horizontalPanSpace, verticalPanSpace, curScale, curX, curY, ), this.flingUpdate( resetXClock, resetYClock, activeInteraction, panJustEnded, panVelocityX, panVelocityY, horizontalPanSpace, verticalPanSpace, curX, curY, ), ]; const updatedScale = [updates, curScale]; const updatedCurX = [updates, curX]; const updatedCurY = [updates, curY]; const updatedBackdropOpacity = [updates, curBackdropOpacity]; const updatedCloseButtonOpacity = [updates, curCloseButtonOpacity]; const updatedActionLinksOpacity = [updates, curActionLinksOpacity]; const reverseNavigationProgress = sub(1, navigationProgress); this.scale = add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ); this.x = add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ); this.y = add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ); this.backdropOpacity = multiply(navigationProgress, updatedBackdropOpacity); this.imageContainerOpacity = interpolate(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const buttonOpacity = interpolate(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); this.closeButtonOpacity = multiply( navigationProgress, buttonOpacity, updatedCloseButtonOpacity, ); this.actionLinksOpacity = multiply( navigationProgress, buttonOpacity, updatedActionLinksOpacity, ); this.beforeDoubleTapRefs = Platform.select({ android: [], default: [this.pinchHandler, this.panHandler], }); this.beforeSingleTapRefs = [ ...this.beforeDoubleTapRefs, this.doubleTapHandler, ]; } // How much space do we have to pan the image horizontally? horizontalPanSpace(scale: Value) { const apparentWidth = multiply(this.imageWidth, scale); const horizPop = divide(sub(apparentWidth, this.frameWidth), 2); return max(horizPop, 0); } // How much space do we have to pan the image vertically? verticalPanSpace(scale: Value) { const apparentHeight = multiply(this.imageHeight, scale); const vertPop = divide(sub(apparentHeight, this.frameHeight), 2); return max(vertPop, 0); } pinchUpdate( // Inputs pinchActive: Value, pinchScale: Value, pinchFocalX: Value, pinchFocalY: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const deltaScale = scaleDelta(pinchScale, pinchActive); const deltaPinchX = multiply( sub(1, deltaScale), sub(pinchFocalX, curX, this.centerX), ); const deltaPinchY = multiply( sub(1, deltaScale), sub(pinchFocalY, curY, this.centerY), ); return cond( [deltaScale, pinchActive], [ set(curX, add(curX, deltaPinchX)), set(curY, add(curY, deltaPinchY)), set(curScale, multiply(curScale, deltaScale)), ], ); } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, closeButtonLastState, saveButtonX, saveButtonY, saveButtonWidth, saveButtonHeight, actionLinksLastState, } = this; return and( or( eq(closeButtonLastState, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(actionLinksLastState, 0), lessThan(x, saveButtonX), greaterThan(x, add(saveButtonX, saveButtonWidth)), lessThan(y, saveButtonY), greaterThan(y, add(saveButtonY, saveButtonHeight)), ), ); } panUpdate( // Inputs panActive: Value, panTranslationX: Value, panTranslationY: Value, // Outputs curX: Value, curY: Value, ): Value { const deltaX = panDelta(panTranslationX, panActive); const deltaY = panDelta(panTranslationY, panActive); return cond( [deltaX, deltaY, panActive], [set(curX, add(curX, deltaX)), set(curY, add(curY, deltaY))], ); } singleTapUpdate( // Inputs singleTapState: Value, singleTapX: Value, singleTapY: Value, roundedCurScale: Value, // Outputs curCloseButtonOpacity: Value, curActionLinksOpacity: Value, ): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(singleTapState), this.outsideButtons(lastTapX, lastTapY), ); const wasZoomed = new Value(0); const isZoomed = greaterThan(roundedCurScale, 1); const becameUnzoomed = and(wasZoomed, not(isZoomed)); const closeButtonState = cond( or( fingerJustReleased, and(becameUnzoomed, eq(this.closeButtonLastState, 0)), ), sub(1, this.closeButtonLastState), this.closeButtonLastState, ); const actionLinksState = cond( isZoomed, 0, cond( or(fingerJustReleased, becameUnzoomed), sub(1, this.actionLinksLastState), this.actionLinksLastState, ), ); const closeButtonAppearClock = new Clock(); const closeButtonDisappearClock = new Clock(); const actionLinksAppearClock = new Clock(); const actionLinksDisappearClock = new Clock(); return [ fingerJustReleased, set( curCloseButtonOpacity, cond( eq(closeButtonState, 1), [ stopClock(closeButtonDisappearClock), runTiming(closeButtonAppearClock, curCloseButtonOpacity, 1), ], [ stopClock(closeButtonAppearClock), runTiming(closeButtonDisappearClock, curCloseButtonOpacity, 0), ], ), ), set( curActionLinksOpacity, cond( eq(actionLinksState, 1), [ stopClock(actionLinksDisappearClock), runTiming(actionLinksAppearClock, curActionLinksOpacity, 1), ], [ stopClock(actionLinksAppearClock), runTiming(actionLinksDisappearClock, curActionLinksOpacity, 0), ], ), ), set(this.actionLinksLastState, actionLinksState), set(this.closeButtonLastState, closeButtonState), set(wasZoomed, isZoomed), set(lastTapX, singleTapX), set(lastTapY, singleTapY), call([eq(curCloseButtonOpacity, 1)], this.setCloseButtonEnabled), call([eq(curActionLinksOpacity, 1)], this.setActionLinksEnabled), ]; } doubleTapUpdate( // Inputs doubleTapState: Value, doubleTapX: Value, doubleTapY: Value, roundedCurScale: Value, zoomClock: Clock, gestureActive: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const zoomClockRunning = clockRunning(zoomClock); const zoomActive = and(not(gestureActive), zoomClockRunning); const targetScale = cond(greaterThan(roundedCurScale, 1), 1, 3); const tapXDiff = sub(doubleTapX, this.centerX, curX); const tapYDiff = sub(doubleTapY, this.centerY, curY); const tapXPercent = divide(tapXDiff, this.imageWidth, curScale); const tapYPercent = divide(tapYDiff, this.imageHeight, curScale); const horizPanSpace = this.horizontalPanSpace(targetScale); const vertPanSpace = this.verticalPanSpace(targetScale); const horizPanPercent = divide(horizPanSpace, this.imageWidth, targetScale); const vertPanPercent = divide(vertPanSpace, this.imageHeight, targetScale); const tapXPercentClamped = clamp( tapXPercent, multiply(-1, horizPanPercent), horizPanPercent, ); const tapYPercentClamped = clamp( tapYPercent, multiply(-1, vertPanPercent), vertPanPercent, ); const targetX = multiply(tapXPercentClamped, this.imageWidth, targetScale); const targetY = multiply(tapYPercentClamped, this.imageHeight, targetScale); const targetRelativeScale = divide(targetScale, curScale); const targetRelativeX = multiply(-1, add(targetX, curX)); const targetRelativeY = multiply(-1, add(targetY, curY)); const zoomScale = runTiming(zoomClock, 1, targetRelativeScale); const zoomX = runTiming(zoomClock, 0, targetRelativeX, false); const zoomY = runTiming(zoomClock, 0, targetRelativeY, false); const deltaScale = scaleDelta(zoomScale, zoomActive); const deltaX = panDelta(zoomX, zoomActive); const deltaY = panDelta(zoomY, zoomActive); const fingerJustReleased = and( gestureJustEnded(doubleTapState), this.outsideButtons(doubleTapX, doubleTapY), ); return cond( [fingerJustReleased, deltaX, deltaY, deltaScale, gestureActive], stopClock(zoomClock), cond(or(zoomClockRunning, fingerJustReleased), [ zoomX, zoomY, zoomScale, set(curX, add(curX, deltaX)), set(curY, add(curY, deltaY)), set(curScale, multiply(curScale, deltaScale)), ]), ); } backdropOpacityUpdate( // Inputs panJustEnded: Value, pinchActive: Value, panVelocityX: Value, panVelocityY: Value, curX: Value, curY: Value, roundedCurScale: Value, // Outputs curBackdropOpacity: Value, dismissingFromPan: Value, ): Value { const progressiveOpacity = max( min( sub(1, abs(divide(curX, this.frameWidth))), sub(1, abs(divide(curY, this.frameHeight))), ), 0, ); const resetClock = new Clock(); const velocity = pow(add(pow(panVelocityX, 2), pow(panVelocityY, 2)), 0.5); const shouldGoBack = and( panJustEnded, or(greaterThan(velocity, 50), greaterThan(0.7, progressiveOpacity)), ); const decayClock = new Clock(); const decayItems = [ set(curX, runDecay(decayClock, panVelocityX, curX, false)), set(curY, runDecay(decayClock, panVelocityY, curY)), ]; return cond( [panJustEnded, dismissingFromPan], decayItems, cond( or(pinchActive, greaterThan(roundedCurScale, 1)), set(curBackdropOpacity, runTiming(resetClock, curBackdropOpacity, 1)), [ stopClock(resetClock), set(curBackdropOpacity, progressiveOpacity), set(dismissingFromPan, shouldGoBack), cond(shouldGoBack, [decayItems, call([], this.close)]), ], ), ); } recenter( // Inputs resetXClock: Clock, resetYClock: Clock, activeInteraction: Value, recenteredScale: Value, horizontalPanSpace: Value, verticalPanSpace: Value, // Outputs curScale: Value, curX: Value, curY: Value, ): Value { const resetScaleClock = new Clock(); const recenteredX = clamp( curX, multiply(-1, horizontalPanSpace), horizontalPanSpace, ); const recenteredY = clamp( curY, multiply(-1, verticalPanSpace), verticalPanSpace, ); return cond( activeInteraction, [ stopClock(resetScaleClock), stopClock(resetXClock), stopClock(resetYClock), ], [ cond( or(clockRunning(resetScaleClock), neq(recenteredScale, curScale)), set(curScale, runTiming(resetScaleClock, curScale, recenteredScale)), ), cond( or(clockRunning(resetXClock), neq(recenteredX, curX)), set(curX, runTiming(resetXClock, curX, recenteredX)), ), cond( or(clockRunning(resetYClock), neq(recenteredY, curY)), set(curY, runTiming(resetYClock, curY, recenteredY)), ), ], ); } flingUpdate( // Inputs resetXClock: Clock, resetYClock: Clock, activeInteraction: Value, panJustEnded: Value, panVelocityX: Value, panVelocityY: Value, horizontalPanSpace: Value, verticalPanSpace: Value, // Outputs curX: Value, curY: Value, ): Value { const flingXClock = new Clock(); const flingYClock = new Clock(); const decayX = runDecay(flingXClock, panVelocityX, curX); const recenteredX = clamp( decayX, multiply(-1, horizontalPanSpace), horizontalPanSpace, ); const decayY = runDecay(flingYClock, panVelocityY, curY); const recenteredY = clamp( decayY, multiply(-1, verticalPanSpace), verticalPanSpace, ); return cond( activeInteraction, [stopClock(flingXClock), stopClock(flingYClock)], [ cond( clockRunning(resetXClock), stopClock(flingXClock), cond(or(panJustEnded, clockRunning(flingXClock)), [ set(curX, recenteredX), cond(neq(decayX, recenteredX), stopClock(flingXClock)), ]), ), cond( clockRunning(resetYClock), stopClock(flingYClock), cond(or(panJustEnded, clockRunning(flingYClock)), [ set(curY, recenteredY), cond(neq(decayY, recenteredY), stopClock(flingYClock)), ]), ), ], ); } updateDimensions() { const { width: frameWidth, height: frameHeight } = this.frame; const { topInset } = this.props.dimensions; if (this.frameWidth) { this.frameWidth.setValue(frameWidth); } else { this.frameWidth = new Value(frameWidth); } if (this.frameHeight) { this.frameHeight.setValue(frameHeight); } else { this.frameHeight = new Value(frameHeight); } const centerX = frameWidth / 2; const centerY = frameHeight / 2 + topInset; if (this.centerX) { this.centerX.setValue(centerX); } else { this.centerX = new Value(centerX); } if (this.centerY) { this.centerY.setValue(centerY); } else { this.centerY = new Value(centerY); } const { width, height } = this.imageDimensions; if (this.imageWidth) { this.imageWidth.setValue(width); } else { this.imageWidth = new Value(width); } if (this.imageHeight) { this.imageHeight.setValue(height); } else { this.imageHeight = new Value(height); } } componentDidMount() { if (MultimediaModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (MultimediaModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props) { if (this.props.dimensions !== prevProps.dimensions) { this.updateDimensions(); } const isActive = MultimediaModal.isActive(this.props); const wasActive = MultimediaModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } } get frame(): Dimensions { - const { width, height, topInset, bottomInset } = this.props.dimensions; - return { width, height: height - topInset - bottomInset }; + const { width, safeAreaHeight } = this.props.dimensions; + return { width, height: safeAreaHeight }; } get imageDimensions(): Dimensions { // Make space for the close button let { height: maxHeight, width: maxWidth } = this.frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } const { dimensions } = this.props.route.params.mediaInfo; if (dimensions.height < maxHeight && dimensions.width < maxWidth) { return dimensions; } const heightRatio = maxHeight / dimensions.height; const widthRatio = maxWidth / dimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: dimensions.width * heightRatio, }; } else { return { width: maxWidth, height: dimensions.height * widthRatio, }; } } get imageContainerStyle() { const { height, width } = this.imageDimensions; const { height: frameHeight, width: frameWidth } = this.frame; const top = (frameHeight - height) / 2 + this.props.dimensions.topInset; const left = (frameWidth - width) / 2; const { verticalBounds } = this.props.route.params; return { height, width, marginTop: top - verticalBounds.y, marginLeft: left, opacity: this.imageContainerOpacity, transform: [ { translateX: this.x }, { translateY: this.y }, { scale: this.scale }, ], }; } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'MultimediaModal should have OverlayContext'); return !overlayContext.isDismissing; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; // margin will clip, but padding won't const verticalStyle = MultimediaModal.isActive(this.props) ? { paddingTop: top, paddingBottom: bottom } : { marginTop: top, marginBottom: bottom }; return [styles.contentContainer, verticalStyle]; } render() { const { mediaInfo } = this.props.route.params; const statusBar = MultimediaModal.isActive(this.props) ? (