diff --git a/android/app/build.gradle b/android/app/build.gradle index 2e29243103bf..8cde13d0a07e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -152,8 +152,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001017602 - versionName "1.1.76-2" + versionCode 1001017702 + versionName "1.1.77-2" } splits { abi { diff --git a/assets/images/collapse.svg b/assets/images/collapse.svg new file mode 100644 index 000000000000..92b9619924f0 --- /dev/null +++ b/assets/images/collapse.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/assets/images/expand.svg b/assets/images/expand.svg new file mode 100644 index 000000000000..cdd1d712fd6a --- /dev/null +++ b/assets/images/expand.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 6365cb1bff99..78a0e58a423a 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.76 + 1.1.77 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.1.76.2 + 1.1.77.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 813b7066dbde..499b1cbf5940 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.1.76 + 1.1.77 CFBundleSignature ???? CFBundleVersion - 1.1.76.2 + 1.1.77.2 diff --git a/package-lock.json b/package-lock.json index b2d705895b8d..46b406f41988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.76-2", + "version": "1.1.77-2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index e5b8ee551901..d80cb29623e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.76-2", + "version": "1.1.77-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.js b/src/CONST.js index 5b1b82da5904..e5b6657ebddd 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -273,6 +273,14 @@ const CONST = { MAX_ROOM_NAME_LENGTH: 80, LAST_MESSAGE_TEXT_MAX_LENGTH: 80, }, + COMPOSER: { + MAX_LINES: 16, + MAX_LINES_SMALL_SCREEN: 6, + MAX_LINES_FULL: -1, + + // The minimum number of typed lines needed to enable the full screen composer + FULL_COMPOSER_MIN_LINES: 3, + }, MODAL: { MODAL_TYPE: { CONFIRM: 'confirm', @@ -330,6 +338,7 @@ const CONST = { IOS_NETWORK_CONNECTION_LOST: 'The network connection was lost.', IOS_NETWORK_CONNECTION_LOST_RUSSIAN: 'Сетевое соединение потеряно.', IOS_NETWORK_CONNECTION_LOST_SWEDISH: 'Nätverksanslutningen förlorades.', + IOS_NETWORK_CONNECTION_LOST_SPANISH: 'La conexión a Internet parece estar desactivada.', IOS_LOAD_FAILED: 'Load failed', SAFARI_CANNOT_PARSE_RESPONSE: 'cannot parse response', GATEWAY_TIMEOUT: 'Gateway Timeout', @@ -384,6 +393,7 @@ const CONST = { PUSHER: { PRIVATE_USER_CHANNEL_PREFIX: 'private-encrypted-user-accountID-', + PRIVATE_REPORT_CHANNEL_PREFIX: 'private-report-reportID-', }, EMOJI_SPACER: 'SPACER', diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 9e198d07aad7..1f47b414ee23 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -105,6 +105,7 @@ export default { REPORT_IOUS: 'reportIOUs_', POLICY: 'policy_', REPORTS_WITH_DRAFT: 'reportWithDraft_', + REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', }, // Indicates which locale should be used diff --git a/src/components/Button.js b/src/components/Button.js index d5bf5c94da6f..e330fccf1a82 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -233,6 +233,10 @@ class Button extends Component { return ( { + if (e && e.type === 'click') { + e.currentTarget.blur(); + } + if (this.props.shouldEnableHapticFeedback) { HapticFeedback.trigger(); } @@ -253,36 +257,39 @@ class Button extends Component { ]} nativeID={this.props.nativeID} > - {({pressed, hovered}) => ( - - {this.renderContent()} - {this.props.isLoading && ( - - )} - - )} + {({pressed, hovered}) => { + const activeAndHovered = !this.props.isDisabled && hovered; + return ( + + {this.renderContent()} + {this.props.isLoading && ( + + )} + + ); + }} ); } diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js index 687ac17ff91b..76b63eb7f972 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -1,16 +1,11 @@ import React from 'react'; +import {StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; import CONST from '../../CONST'; - -/** - * On native layers we like to have the Text Input not focused so the user can read new chats without they keyboard in - * the way of the view - * On Android, the selection prop is required on the TextInput but this prop has issues on IOS - * https://github.com/facebook/react-native/issues/29063 - */ +import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { /** If the input should clear, it actually gets intercepted instead of .clear() */ @@ -29,6 +24,25 @@ const propTypes = { /** Prevent edits and interactions like focus for this input. */ isDisabled: PropTypes.bool, + /** Selection Object */ + selection: PropTypes.shape({ + start: PropTypes.number, + end: PropTypes.number, + }), + + /** Whether the full composer can be opened */ + isFullComposerAvailable: PropTypes.bool, + + /** Allow the full composer to be opened */ + setIsFullComposerAvailable: PropTypes.func, + + /** Whether the composer is full size */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** General styles to apply to the text input */ + // eslint-disable-next-line react/forbid-prop-types + style: PropTypes.any, + }; const defaultProps = { @@ -37,9 +51,24 @@ const defaultProps = { autoFocus: false, isDisabled: false, forwardedRef: null, + selection: { + start: 0, + end: 0, + }, + isFullComposerAvailable: false, + setIsFullComposerAvailable: () => {}, + style: null, }; class Composer extends React.Component { + constructor(props) { + super(props); + + this.state = { + propStyles: StyleSheet.flatten(this.props.style), + }; + } + componentDidMount() { // This callback prop is used by the parent component using the constructor to // get a ref to the inner textInput element e.g. if we do @@ -67,17 +96,19 @@ class Composer extends React.Component { autoComplete="off" placeholderTextColor={themeColors.placeholderText} ref={el => this.textInput = el} - maxHeight={CONST.COMPOSER_MAX_HEIGHT} + maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} + onContentSizeChange={e => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} - editable={!this.props.isDisabled} + textAlignVertical="center" + style={this.state.propStyles} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...this.props} + editable={!this.props.isDisabled} /> ); } } -Composer.displayName = 'Composer'; Composer.propTypes = propTypes; Composer.defaultProps = defaultProps; diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js index d61339f18f3f..9b1e6680dea3 100644 --- a/src/components/Composer/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -1,16 +1,11 @@ import React from 'react'; +import {StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; import CONST from '../../CONST'; - -/** - * On native layers we like to have the Text Input not focused so the user can read new chats without they keyboard in - * the way of the view - * On Android, the selection prop is required on the TextInput but this prop has issues on IOS - * https://github.com/facebook/react-native/issues/29063 - */ +import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { /** If the input should clear, it actually gets intercepted instead of .clear() */ @@ -35,6 +30,19 @@ const propTypes = { end: PropTypes.number, }), + /** Whether the full composer can be opened */ + isFullComposerAvailable: PropTypes.bool, + + /** Allow the full composer to be opened */ + setIsFullComposerAvailable: PropTypes.func, + + /** Whether the composer is full size */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** General styles to apply to the text input */ + // eslint-disable-next-line react/forbid-prop-types + style: PropTypes.any, + }; const defaultProps = { @@ -47,9 +55,20 @@ const defaultProps = { start: 0, end: 0, }, + isFullComposerAvailable: false, + setIsFullComposerAvailable: () => {}, + style: null, }; class Composer extends React.Component { + constructor(props) { + super(props); + + this.state = { + propStyles: StyleSheet.flatten(this.props.style), + }; + } + componentDidMount() { // This callback prop is used by the parent component using the constructor to // get a ref to the inner textInput element e.g. if we do @@ -72,18 +91,24 @@ class Composer extends React.Component { } render() { - // Selection Property not worked in IOS properly, So removed from props. + // On native layers we like to have the Text Input not focused so the + // user can read new chats without the keyboard in the way of the view. + // On Android, the selection prop is required on the TextInput but this prop has issues on IOS + // https://github.com/facebook/react-native/issues/29063 const propsToPass = _.omit(this.props, 'selection'); return ( this.textInput = el} - maxHeight={CONST.COMPOSER_MAX_HEIGHT} + maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} + onContentSizeChange={e => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} - editable={!this.props.isDisabled} + textAlignVertical="center" + style={this.state.propStyles} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...propsToPass} + editable={!this.props.isDisabled} /> ); } diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 740d28757a6f..3d0b4f351f7c 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -8,6 +8,8 @@ import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import Growl from '../../libs/Growl'; import themeColors from '../../styles/themes/default'; import CONST from '../../CONST'; +import updateIsFullComposerAvailable from '../../libs/ComposerUtils/updateIsFullComposerAvailable'; +import getNumberOfLines from '../../libs/ComposerUtils/index'; const propTypes = { /** Maximum number of lines in the text input */ @@ -63,6 +65,12 @@ const propTypes = { end: PropTypes.number, }), + /** Whether the full composer can be opened */ + isFullComposerAvailable: PropTypes.bool, + + /** Allow the full composer to be opened */ + setIsFullComposerAvailable: PropTypes.func, + ...withLocalizePropTypes, }; @@ -86,6 +94,8 @@ const defaultProps = { start: 0, end: 0, }, + isFullComposerAvailable: false, + setIsFullComposerAvailable: () => {}, }; const IMAGE_EXTENSIONS = { @@ -155,7 +165,8 @@ class Composer extends React.Component { this.setState({numberOfLines: 1}); this.props.onClear(); } - if (prevProps.defaultValue !== this.props.defaultValue) { + if (prevProps.defaultValue !== this.props.defaultValue + || prevProps.isComposerFullSize !== this.props.isComposerFullSize) { this.updateNumberOfLines(); } @@ -178,22 +189,6 @@ class Composer extends React.Component { this.textInput.removeEventListener('wheel', this.handleWheel); } - /** - * Calculates the max number of lines the text input can have - * - * @param {Number} lineHeight - * @param {Number} paddingTopAndBottom - * @param {Number} scrollHeight - * - * @returns {Number} - */ - getNumberOfLines(lineHeight, paddingTopAndBottom, scrollHeight) { - const maxLines = this.props.maxLines; - let newNumberOfLines = Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight); - newNumberOfLines = maxLines <= 0 ? newNumberOfLines : Math.min(newNumberOfLines, maxLines); - return newNumberOfLines; - } - /** * Handles all types of drag-N-drop events on the composer * @@ -328,16 +323,21 @@ class Composer extends React.Component { * divide by line height to get the total number of rows for the textarea. */ updateNumberOfLines() { - const computedStyle = window.getComputedStyle(this.textInput); - const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20; - const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) - + parseInt(computedStyle.paddingTop, 10); + // Hide the composer expand button so we can get an accurate reading of + // the height of the text input + this.props.setIsFullComposerAvailable(false); // We have to reset the rows back to the minimum before updating so that the scrollHeight is not // affected by the previous row setting. If we don't, rows will be added but not removed on backspace/delete. this.setState({numberOfLines: 1}, () => { + const computedStyle = window.getComputedStyle(this.textInput); + const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20; + const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + + parseInt(computedStyle.paddingTop, 10); + const numberOfLines = getNumberOfLines(this.props.maxLines, lineHeight, paddingTopAndBottom, this.textInput.scrollHeight); + updateIsFullComposerAvailable(this.props, numberOfLines); this.setState({ - numberOfLines: this.getNumberOfLines(lineHeight, paddingTopAndBottom, this.textInput.scrollHeight), + numberOfLines, }); }); } diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index 339e36afd6eb..639742efa00d 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -7,6 +7,7 @@ import Icon from './Icon'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import getButtonState from '../libs/getButtonState'; +import withDelayToggleButtonState, {withDelayToggleButtonStatePropTypes} from './withDelayToggleButtonState'; const propTypes = { /** Icon Component */ @@ -32,6 +33,8 @@ const propTypes = { /** A description text to show under the title */ description: PropTypes.string, + + ...withDelayToggleButtonStatePropTypes, }; const defaultProps = { @@ -45,25 +48,15 @@ const defaultProps = { class ContextMenuItem extends Component { constructor(props) { super(props); - this.state = { - success: false, - }; - this.triggerPressAndUpdateSuccess = this.triggerPressAndUpdateSuccess.bind(this); - } - componentWillUnmount() { - if (!this.successResetTimer) { - return; - } - - clearTimeout(this.successResetTimer); + this.triggerPressAndUpdateSuccess = this.triggerPressAndUpdateSuccess.bind(this); } /** - * Called on button press and mark the run + * Method to call parent onPress and toggleDelayButtonState */ triggerPressAndUpdateSuccess() { - if (this.state.success) { + if (this.props.isDelayButtonStateComplete) { return; } this.props.onPress(); @@ -71,18 +64,13 @@ class ContextMenuItem extends Component { // We only set the success state when we have icon or text to represent the success state // We may want to replace this check by checking the Result from OnPress Callback in future. if (this.props.successIcon || this.props.successText) { - this.setState({ - success: true, - }); - if (this.props.autoReset) { - this.successResetTimer = setTimeout(() => this.setState({success: false}), 1800); - } + this.props.toggleDelayButtonState(this.props.autoReset); } } render() { - const icon = this.state.success ? this.props.successIcon || this.props.icon : this.props.icon; - const text = this.state.success ? this.props.successText || this.props.text : this.props.text; + const icon = this.props.isDelayButtonStateComplete ? this.props.successIcon || this.props.icon : this.props.icon; + const text = this.props.isDelayButtonStateComplete ? this.props.successText || this.props.text : this.props.text; return ( this.props.isMini ? ( @@ -94,14 +82,14 @@ class ContextMenuItem extends Component { style={ ({hovered, pressed}) => [ styles.reportActionContextMenuMiniButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, this.state.success)), + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, this.props.isDelayButtonStateComplete)), ] } > {({hovered, pressed}) => ( )} @@ -112,7 +100,7 @@ class ContextMenuItem extends Component { icon={icon} onPress={this.triggerPressAndUpdateSuccess} wrapperStyle={styles.pr9} - success={this.state.success} + success={this.props.isDelayButtonStateComplete} description={this.props.description} /> ) @@ -123,4 +111,4 @@ class ContextMenuItem extends Component { ContextMenuItem.propTypes = propTypes; ContextMenuItem.defaultProps = defaultProps; -export default ContextMenuItem; +export default withDelayToggleButtonState(ContextMenuItem); diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js index ef8588709c34..8b83e61ac279 100755 --- a/src/components/HeaderWithCloseButton.js +++ b/src/components/HeaderWithCloseButton.js @@ -1,7 +1,7 @@ -import React from 'react'; +import React, {Component} from 'react'; import PropTypes from 'prop-types'; import { - View, TouchableOpacity, Keyboard, + View, Keyboard, Pressable, } from 'react-native'; import styles from '../styles/styles'; import Header from './Header'; @@ -13,6 +13,10 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import Tooltip from './Tooltip'; import ThreeDotsMenu, {ThreeDotsMenuItemPropTypes} from './ThreeDotsMenu'; import VirtualKeyboard from '../libs/VirtualKeyboard'; +import getButtonState from '../libs/getButtonState'; +import * as StyleUtils from '../styles/StyleUtils'; +import withDelayToggleButtonState, {withDelayToggleButtonStatePropTypes} from './withDelayToggleButtonState'; +import compose from '../libs/compose'; const propTypes = { /** Title of the Header */ @@ -75,6 +79,8 @@ const propTypes = { }), ...withLocalizePropTypes, + + ...withDelayToggleButtonStatePropTypes, }; const defaultProps = { @@ -100,94 +106,123 @@ const defaultProps = { }, }; -const HeaderWithCloseButton = props => ( - - - {props.shouldShowBackButton && ( - - { - if (VirtualKeyboard.isOpen()) { - Keyboard.dismiss(); - } - props.onBackButtonPress(); - }} - style={[styles.touchableButtonImage]} - > - - - - )} -
- - { - props.shouldShowDownloadButton && ( - - - - - - - ) - } - - {props.shouldShowGetAssistanceButton - && ( - - Navigation.navigate(ROUTES.getGetAssistanceRoute(props.guidesCallTaskID))} - style={[styles.touchableButtonImage, styles.mr0]} - accessibilityRole="button" - accessibilityLabel={props.translate('getAssistancePage.questionMarkButtonTooltip')} - > - - - - )} - - {props.shouldShowThreeDotsButton && ( - + + {this.props.shouldShowBackButton && ( + + { + if (VirtualKeyboard.isOpen()) { + Keyboard.dismiss(); + } + this.props.onBackButtonPress(); + }} + style={[styles.touchableButtonImage]} + > + + + + )} +
- )} - - {props.shouldShowCloseButton - && ( - - - - - - )} + + { + this.props.shouldShowDownloadButton && ( + + + + + + + ) + } + + {this.props.shouldShowGetAssistanceButton + && ( + + Navigation.navigate(ROUTES.getGetAssistanceRoute(this.props.guidesCallTaskID))} + style={[styles.touchableButtonImage, styles.mr0]} + accessibilityRole="button" + accessibilityLabel={this.props.translate('getAssistancePage.questionMarkButtonTooltip')} + > + + + + )} + + {this.props.shouldShowThreeDotsButton && ( + + )} + + {this.props.shouldShowCloseButton + && ( + + + + + + )} + + - - -); + ); + } +} HeaderWithCloseButton.propTypes = propTypes; HeaderWithCloseButton.defaultProps = defaultProps; HeaderWithCloseButton.displayName = 'HeaderWithCloseButton'; -export default withLocalize(HeaderWithCloseButton); +export default compose( + withLocalize, + withDelayToggleButtonState, +)(HeaderWithCloseButton); diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js index 7468e2138ca4..91eaebc3914c 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/IOUConfirmationList.js @@ -1,7 +1,6 @@ import React, {Component} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import {ScrollView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import styles from '../styles/styles'; @@ -354,7 +353,7 @@ class IOUConfirmationList extends Component { const canModifyParticipants = this.props.isIOUAttachedToExistingChatReport && this.props.hasMultipleParticipants; return ( <> - + - + this.textInput = el} diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 0e8d598ad703..0b11a5602b13 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -16,6 +16,7 @@ import CircleHourglass from '../../../assets/images/circle-hourglass.svg'; import Clipboard from '../../../assets/images/clipboard.svg'; import Close from '../../../assets/images/close.svg'; import ClosedSign from '../../../assets/images/closed-sign.svg'; +import Collapse from '../../../assets/images/collapse.svg'; import Concierge from '../../../assets/images/concierge.svg'; import CreditCard from '../../../assets/images/creditcard.svg'; import DownArrow from '../../../assets/images/down.svg'; @@ -23,6 +24,7 @@ import Download from '../../../assets/images/download.svg'; import Emoji from '../../../assets/images/emoji.svg'; import Exclamation from '../../../assets/images/exclamation.svg'; import Exit from '../../../assets/images/exit.svg'; +import Expand from '../../../assets/images/expand.svg'; import Eye from '../../../assets/images/eye.svg'; import EyeDisabled from '../../../assets/images/eye-disabled.svg'; import ExpensifyCard from '../../../assets/images/expensifycard.svg'; @@ -102,6 +104,7 @@ export { Clipboard, Close, ClosedSign, + Collapse, Concierge, Connect, CreditCard, @@ -112,6 +115,7 @@ export { Emoji, Exclamation, Exit, + Expand, Eye, EyeDisabled, ExpensifyCard, diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 004fd45ad092..0be18a83a916 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -49,6 +49,10 @@ const MenuItem = props => ( return; } + if (e && e.type === 'click') { + e.currentTarget.blur(); + } + props.onPress(e); }} style={({hovered, pressed}) => ([ diff --git a/src/components/withDelayToggleButtonState.js b/src/components/withDelayToggleButtonState.js new file mode 100644 index 000000000000..c312d18fba74 --- /dev/null +++ b/src/components/withDelayToggleButtonState.js @@ -0,0 +1,82 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; + +const withDelayToggleButtonStatePropTypes = { + /** A value whether the button state is complete */ + isDelayButtonStateComplete: PropTypes.bool.isRequired, + + /** A function to call to change the complete state */ + toggleDelayButtonState: PropTypes.func.isRequired, +}; + +export default function (WrappedComponent) { + class WithDelayToggleButtonState extends Component { + constructor(props) { + super(props); + + this.state = { + isDelayButtonStateComplete: false, + }; + this.toggleDelayButtonState = this.toggleDelayButtonState.bind(this); + } + + componentWillUnmount() { + if (!this.resetButtonStateCompleteTimer) { + return; + } + + clearTimeout(this.resetButtonStateCompleteTimer); + } + + /** + * @param {Boolean} resetAfterDelay Impose delay before toggling state + */ + toggleDelayButtonState(resetAfterDelay) { + this.setState({ + isDelayButtonStateComplete: true, + }); + + if (!resetAfterDelay) { + return; + } + + this.resetButtonStateCompleteTimer = setTimeout(() => { + this.setState({ + isDelayButtonStateComplete: false, + }); + }, 1800); + } + + render() { + return ( + + ); + } + } + + WithDelayToggleButtonState.displayName = `WithDelayToggleButtonState(${getComponentDisplayName(WrappedComponent)})`; + WithDelayToggleButtonState.propTypes = { + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({current: PropTypes.instanceOf(React.Component)}), + ]), + }; + WithDelayToggleButtonState.defaultProps = { + forwardedRef: undefined, + }; + + return React.forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + )); +} + +export { + withDelayToggleButtonStatePropTypes, +}; diff --git a/src/languages/en.js b/src/languages/en.js index ec3b1554a9a9..0bc2f3940666 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -164,6 +164,8 @@ export default { localTime: ({user, time}) => `It's ${time} for ${user}`, edited: '(edited)', emoji: 'Emoji', + collapse: 'Collapse', + expand: 'Expand', }, reportActionContextMenu: { copyToClipboard: 'Copy to clipboard', diff --git a/src/languages/es.js b/src/languages/es.js index b2c86f47e310..6e21739200c3 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -164,6 +164,8 @@ export default { localTime: ({user, time}) => `Son las ${time} para ${user}`, edited: '(editado)', emoji: 'Emoji', + collapse: 'Colapsar', + expand: 'Expandir', }, reportActionContextMenu: { copyToClipboard: 'Copiar al portapapeles', diff --git a/src/libs/ComposerUtils/index.js b/src/libs/ComposerUtils/index.js new file mode 100644 index 000000000000..a469da7516bb --- /dev/null +++ b/src/libs/ComposerUtils/index.js @@ -0,0 +1,17 @@ +/** + * Get the current number of lines in the composer + * + * @param {Number} maxLines + * @param {Number} lineHeight + * @param {Number} paddingTopAndBottom + * @param {Number} scrollHeight + * + * @returns {Number} + */ +function getNumberOfLines(maxLines, lineHeight, paddingTopAndBottom, scrollHeight) { + let newNumberOfLines = Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight); + newNumberOfLines = maxLines <= 0 ? newNumberOfLines : Math.min(newNumberOfLines, maxLines); + return newNumberOfLines; +} + +export default getNumberOfLines; diff --git a/src/libs/ComposerUtils/index.native.js b/src/libs/ComposerUtils/index.native.js new file mode 100644 index 000000000000..783fbac9a426 --- /dev/null +++ b/src/libs/ComposerUtils/index.native.js @@ -0,0 +1,38 @@ +import lodashGet from 'lodash/get'; +import styles from '../../styles/styles'; +import updateIsFullComposerAvailable from './updateIsFullComposerAvailable'; + +/** + * Get the current number of lines in the composer + * + * @param {Number} lineHeight + * @param {Number} paddingTopAndBottom + * @param {Number} scrollHeight + * + * @returns {Number} + */ +function getNumberOfLines(lineHeight, paddingTopAndBottom, scrollHeight) { + return Math.ceil((scrollHeight - paddingTopAndBottom) / lineHeight); +} + +/** + * Check the current scrollHeight of the textarea (minus any padding) and + * divide by line height to get the total number of rows for the textarea. + * @param {Object} props + * @param {Event} e + */ +function updateNumberOfLines(props, e) { + const lineHeight = styles.textInputCompose.lineHeight; + const paddingTopAndBottom = styles.textInputComposeSpacing.paddingVertical * 2; + const inputHeight = lodashGet(e, 'nativeEvent.contentSize.height', null); + if (!inputHeight) { + return; + } + const numberOfLines = getNumberOfLines(lineHeight, paddingTopAndBottom, inputHeight); + updateIsFullComposerAvailable(props, numberOfLines); +} + +export { + getNumberOfLines, + updateNumberOfLines, +}; diff --git a/src/libs/ComposerUtils/updateIsFullComposerAvailable.js b/src/libs/ComposerUtils/updateIsFullComposerAvailable.js new file mode 100644 index 000000000000..00b12d1742e3 --- /dev/null +++ b/src/libs/ComposerUtils/updateIsFullComposerAvailable.js @@ -0,0 +1,15 @@ +import CONST from '../../CONST'; + +/** + * Update isFullComposerAvailable if needed + * @param {Object} props + * @param {Number} numberOfLines The number of lines in the text input + */ +function updateIsFullComposerAvailable(props, numberOfLines) { + const isFullComposerAvailable = numberOfLines >= CONST.COMPOSER.FULL_COMPOSER_MIN_LINES; + if (isFullComposerAvailable !== props.isFullComposerAvailable) { + props.setIsFullComposerAvailable(isFullComposerAvailable); + } +} + +export default updateIsFullComposerAvailable; diff --git a/src/libs/Errors/HttpsError.js b/src/libs/Errors/HttpsError.js index dcdcc5727d2b..ed4ef479628c 100644 --- a/src/libs/Errors/HttpsError.js +++ b/src/libs/Errors/HttpsError.js @@ -5,15 +5,11 @@ export default class HttpsError extends Error { constructor({ message, status = '', - type = '', title = '', - jsonCode = '', }) { super(message); this.name = 'HttpsError'; this.status = status; this.title = title; - this.type = type; - this.jsonCode = jsonCode; } } diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js index 4bc3bf19c333..3e93e318232e 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.js @@ -46,6 +46,15 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) } if (!response.ok) { + // Expensify site is down or something temporary like a Bad Gateway or unknown error occurred + if (response.status === 504 || response.status === 502 || response.status === 520) { + throw new HttpsError({ + message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, + status: response.status, + title: 'Issue connecting to Expensify site', + }); + } + throw new HttpsError({ message: response.statusText, status: response.status, @@ -59,9 +68,8 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) { throw new HttpsError({ message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, - type: CONST.ERROR_TYPE.SOCKET, + status: CONST.JSON_CODE.EXP_ERROR, title: CONST.ERROR_TITLE.SOCKET, - jsonCode: CONST.JSON_CODE.EXP_ERROR, }); } return response; diff --git a/src/libs/Middleware/Logging.js b/src/libs/Middleware/Logging.js index af58c6d9e0e4..45af93e5ef5b 100644 --- a/src/libs/Middleware/Logging.js +++ b/src/libs/Middleware/Logging.js @@ -72,7 +72,11 @@ function Logging(response, request) { // incorrect url, bad cors headers returned by the server, DNS lookup failure etc. Log.hmmm('[Network] Error: Failed to fetch', {message: error.message, status: error.status}); } else if (_.contains([ - CONST.ERROR.IOS_NETWORK_CONNECTION_LOST, CONST.ERROR.NETWORK_REQUEST_FAILED, CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN, CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH, + CONST.ERROR.IOS_NETWORK_CONNECTION_LOST, + CONST.ERROR.NETWORK_REQUEST_FAILED, + CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN, + CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH, + CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SPANISH, ], error.message)) { // These errors seem to happen for native devices with interrupted connections. Often we will see logs about Pusher disconnecting together with these. // This type of error may also indicate a problem with SSL certs. @@ -97,8 +101,9 @@ function Logging(response, request) { // we can get about these requests. Log.hmmm('[Network] Error: Push_Authenticate', {message: error.message, status: error.status}); } else if (error.message === CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED) { - // Auth (database connection) is down or bedrock has timed out while making a request. We currently can't tell the difference between these two states. - Log.hmmm('[Network] Error: Expensify service interrupted or timed out', {type: error.type, title: error.title, jsonCode: error.jsonCode}); + // Expensify site is down completely OR + // Auth (database connection) is down / bedrock has timed out while making a request. We currently can't tell the difference between Auth down and bedrock timing out. + Log.hmmm('[Network] Error: Expensify service interrupted or timed out', {error: error.title, status: error.status}); } else { // If we get any error that is not known log an alert so we can learn more about it and document it here. Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} unknown error caught while processing request - ${error.message}`, { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 3abd3ba12356..11e48b87a3b6 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -589,7 +589,7 @@ function updateReportWithNewAction( * @returns {String} */ function getReportChannelName(reportID) { - return `private-report-reportID-${reportID}${CONFIG.PUSHER.SUFFIX}`; + return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; } /** @@ -601,7 +601,7 @@ function subscribeToUserEvents() { return; } - const pusherChannelName = `private-encrypted-user-accountID-${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; + const pusherChannelName = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; if (Pusher.isSubscribed(pusherChannelName) || Pusher.isAlreadySubscribing(pusherChannelName)) { return; } @@ -1414,6 +1414,14 @@ function renameReport(reportID, reportName) { .finally(() => Onyx.set(ONYXKEYS.IS_LOADING_RENAME_POLICY_ROOM, false)); } +/** + * @param {Number} reportID + * @param {Boolean} isComposerFullSize + */ +function setIsComposerFullSize(reportID, isComposerFullSize) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`, isComposerFullSize); +} + /** * @param {Number} reportID * @param {Object} action @@ -1551,4 +1559,5 @@ export { createPolicyRoom, renameReport, getLastReadSequenceNumber, + setIsComposerFullSize, }; diff --git a/src/libs/actions/Timing.js b/src/libs/actions/Timing.js index ce4e0b5f8b3d..1c681dfea25e 100644 --- a/src/libs/actions/Timing.js +++ b/src/libs/actions/Timing.js @@ -1,7 +1,7 @@ import getPlatform from '../getPlatform'; -import * as DeprecatedAPI from '../deprecatedAPI'; import * as Environment from '../Environment/Environment'; import Firebase from '../Firebase'; +import * as API from '../API'; let timestampData = {}; @@ -51,7 +51,7 @@ function end(eventName, secondaryName = '') { return; } - DeprecatedAPI.Graphite_Timer({ + API.write('SendPerformanceTiming', { name: grafanaEventName, value: eventTime, platform: `${getPlatform()}`, diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 777bd268b9f3..863c75886cb5 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -302,7 +302,7 @@ function subscribeToUserEvents() { return; } - const pusherChannelName = `private-encrypted-user-accountID-${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; + const pusherChannelName = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; // Receive any relevant Onyx updates from the server PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.ONYX_API_UPDATE, currentUserAccountID, (pushJSON) => { @@ -355,7 +355,7 @@ function subscribeToExpensifyCardUpdates() { return; } - const pusherChannelName = `private-encrypted-user-accountID-${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; + const pusherChannelName = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${currentUserAccountID}${CONFIG.PUSHER.SUFFIX}`; // Handle Expensify Card approval flow updates Pusher.subscribe(pusherChannelName, Pusher.TYPE.EXPENSIFY_CARD_UPDATE, (pushJSON) => { diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index d03dca98fcd5..5d32a341c725 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -2,6 +2,7 @@ import React from 'react'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import {Keyboard, View} from 'react-native'; +import lodashGet from 'lodash/get'; import _ from 'underscore'; import lodashFindLast from 'lodash/findLast'; import styles from '../../styles/styles'; @@ -15,7 +16,7 @@ import Permissions from '../../libs/Permissions'; import * as ReportUtils from '../../libs/ReportUtils'; import ReportActionsView from './report/ReportActionsView'; import ReportActionCompose from './report/ReportActionCompose'; -import KeyboardSpacer from '../../components/KeyboardSpacer'; +import KeyboardAvoidingView from '../../components/KeyboardAvoidingView'; import SwipeableView from '../../components/SwipeableView'; import CONST from '../../CONST'; import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; @@ -59,6 +60,9 @@ const propTypes = { /** Array of report actions for this report */ reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + /** Whether the composer is full size */ + isComposerFullSize: PropTypes.bool, + /** Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), }; @@ -74,6 +78,7 @@ const defaultProps = { maxSequenceNumber: 0, hasOutstandingIOU: false, }, + isComposerFullSize: false, betas: [], }; @@ -95,15 +100,20 @@ class ReportScreen extends React.Component { super(props); this.onSubmitComment = this.onSubmitComment.bind(this); + this.viewportOffsetTop = this.updateViewportOffsetTop.bind(this); this.state = { isLoading: true, + viewportOffsetTop: 0, }; } componentDidMount() { this.prepareTransition(); this.storeCurrentlyViewedReport(); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', this.viewportOffsetTop); + } } componentDidUpdate(prevProps) { @@ -117,6 +127,9 @@ class ReportScreen extends React.Component { componentWillUnmount() { clearTimeout(this.loadingTimerId); + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', this.viewportOffsetTop); + } } /** @@ -126,6 +139,14 @@ class ReportScreen extends React.Component { Report.addAction(getReportID(this.props.route), text); } + /** + * @param {SyntheticEvent} e + */ + updateViewportOffsetTop(e) { + const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0); + this.setState({viewportOffsetTop}); + } + /** * When reports change there's a brief time content is not ready to be displayed * @@ -181,27 +202,29 @@ class ReportScreen extends React.Component { } return ( - - Navigation.navigate(ROUTES.HOME)} - /> - - - {this.shouldShowLoader() && } - {!this.shouldShowLoader() && ( - - )} - {(isArchivedRoom || this.props.session.shouldShowComposeInput) && ( - + + + Navigation.navigate(ROUTES.HOME)} + /> + + + {this.shouldShowLoader() && } + {!this.shouldShowLoader() && ( + + )} + {(isArchivedRoom || this.props.session.shouldShowComposeInput) && ( + { isArchivedRoom ? ( @@ -216,14 +239,15 @@ class ReportScreen extends React.Component { reportID={reportID} reportActions={this.props.reportActions} report={this.props.report} + isComposerFullSize={this.props.isComposerFullSize} /> ) } - )} - - + )} + + ); } @@ -246,6 +270,9 @@ export default withOnyx({ report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, }, + isComposerFullSize: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, + }, betas: { key: ONYXKEYS.BETAS, }, diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index d7ebdb896a05..a48ef025fa0a 100755 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -87,6 +87,9 @@ const propTypes = { /** Is composer screen focused */ isFocused: PropTypes.bool.isRequired, + /** Is the composer full size */ + isComposerFullSize: PropTypes.bool.isRequired, + // The NVP describing a user's block status blockedFromConcierge: PropTypes.shape({ // The date that the user will be unblocked @@ -123,6 +126,7 @@ class ReportActionCompose extends React.Component { this.triggerHotkeyActions = this.triggerHotkeyActions.bind(this); this.submitForm = this.submitForm.bind(this); this.setIsFocused = this.setIsFocused.bind(this); + this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this); this.focus = this.focus.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); this.comment = props.comment; @@ -134,6 +138,7 @@ class ReportActionCompose extends React.Component { this.state = { isFocused: this.shouldFocusInputOnScreenFocus, + isFullComposerAvailable: props.isComposerFullSize, textInputShouldClear: false, isCommentEmpty: props.comment.length === 0, isMenuVisible: false, @@ -141,6 +146,7 @@ class ReportActionCompose extends React.Component { start: props.comment.length, end: props.comment.length, }, + maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, }; } @@ -152,6 +158,7 @@ class ReportActionCompose extends React.Component { this.focus(false); }); + this.setMaxLines(); this.updateComment(this.comment); } @@ -169,6 +176,10 @@ class ReportActionCompose extends React.Component { this.focus(); } + if (this.props.isComposerFullSize !== prevProps.isComposerFullSize) { + this.setMaxLines(); + } + // As the report IDs change, make sure to update the composer comment as we need to make sure // we do not show incorrect data in there (ie. draft of message from other report). if (this.props.report.reportID === prevProps.report.reportID) { @@ -195,6 +206,10 @@ class ReportActionCompose extends React.Component { this.setState({isFocused: shouldHighlight}); } + setIsFullComposerAvailable(isFullComposerAvailable) { + this.setState({isFullComposerAvailable}); + } + /** * Updates the should clear state of the composer * @@ -280,6 +295,17 @@ class ReportActionCompose extends React.Component { return iouOptions; } + /** + * Set the maximum number of lines for the composer + */ + setMaxLines() { + let maxLines = this.props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + if (this.props.isComposerFullSize) { + maxLines = CONST.COMPOSER.MAX_LINES_FULL; + } + this.setState({maxLines}); + } + /** * Callback for the emoji picker to add whatever emoji is chosen into the main input * @@ -427,6 +453,10 @@ class ReportActionCompose extends React.Component { this.props.onSubmit(trimmedComment); this.updateComment(''); this.setTextInputShouldClear(true); + if (this.props.isComposerFullSize) { + Report.setIsComposerFullSize(this.props.reportID, false); + } + this.setState({isFullComposerAvailable: false}); // Important to reset the selection on Submit action this.textInput.setNativeProps({selection: {start: 0, end: 0}}); @@ -440,7 +470,9 @@ class ReportActionCompose extends React.Component { const reportParticipants = lodashGet(this.props.report, 'participants', []); const reportRecipient = this.props.personalDetails[reportParticipants[0]]; - const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report); + + const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report) + && !this.props.isComposerFullSize; // Prevents focusing and showing the keyboard while the drawer is covering the chat. const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth; @@ -449,15 +481,16 @@ class ReportActionCompose extends React.Component { const hasExceededMaxCommentLength = this.comment.length > CONST.MAX_COMMENT_LENGTH; return ( - + {shouldShowReportRecipientLocalTime && } @@ -474,7 +507,42 @@ class ReportActionCompose extends React.Component { {({openPicker}) => ( <> - + + {this.props.isComposerFullSize && ( + + { + e.preventDefault(); + Report.setIsComposerFullSize(this.props.reportID, false); + }} + style={styles.composerSizeButton} + underlayColor={themeColors.componentBG} + disabled={isBlockedFromConcierge} + > + + + + + )} + {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( + + { + e.preventDefault(); + Report.setIsComposerFullSize(this.props.reportID, true); + }} + style={styles.composerSizeButton} + underlayColor={themeColors.componentBG} + disabled={isBlockedFromConcierge} + > + + + + )} { @@ -511,53 +579,58 @@ class ReportActionCompose extends React.Component { )} - { - if (!isOriginComposer) { - return; - } - - this.setState({isDraggingOver: true}); - }} - onDragOver={(e, isOriginComposer) => { - if (!isOriginComposer) { - return; - } - - this.setState({isDraggingOver: true}); - }} - onDragLeave={() => this.setState({isDraggingOver: false})} - onDrop={(e) => { - e.preventDefault(); - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - if (!file) { - return; - } - - displayFileInModal({file}); - this.setState({isDraggingOver: false}); - }} - style={[styles.textInputCompose, styles.flex4]} - defaultValue={this.props.comment} - maxLines={this.props.isSmallScreenWidth ? 6 : 16} // This is the same that slack has - onFocus={() => this.setIsFocused(true)} - onBlur={() => this.setIsFocused(false)} - onPasteFile={file => displayFileInModal({file})} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - /> + + { + if (!isOriginComposer) { + return; + } + + this.setState({isDraggingOver: true}); + }} + onDragOver={(e, isOriginComposer) => { + if (!isOriginComposer) { + return; + } + + this.setState({isDraggingOver: true}); + }} + onDragLeave={() => this.setState({isDraggingOver: false})} + onDrop={(e) => { + e.preventDefault(); + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + if (!file) { + return; + } + + displayFileInModal({file}); + this.setState({isDraggingOver: false}); + }} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + defaultValue={this.props.comment} + maxLines={this.state.maxLines} + onFocus={() => this.setIsFocused(true)} + onBlur={() => this.setIsFocused(false)} + onPasteFile={file => displayFileInModal({file})} + shouldClear={this.state.textInputShouldClear} + onClear={() => this.setTextInputShouldClear(false)} + isDisabled={isComposeDisabled || isBlockedFromConcierge} + selection={this.state.selection} + onSelectionChange={this.onSelectionChange} + isFullComposerAvailable={this.state.isFullComposerAvailable} + setIsFullComposerAvailable={this.setIsFullComposerAvailable} + isComposerFullSize={this.props.isComposerFullSize} + /> + )} diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index ef0520b143f9..1e919028393f 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -62,6 +62,9 @@ const propTypes = { email: PropTypes.string, }), + /** Whether the composer is full size */ + isComposerFullSize: PropTypes.bool.isRequired, + /** Are we loading more report actions? */ isLoadingReportActions: PropTypes.bool, @@ -189,6 +192,10 @@ class ReportActionsView extends React.Component { return true; } + if (this.props.isComposerFullSize !== nextProps.isComposerFullSize) { + return true; + } + return !_.isEqual(lodashGet(this.props.report, 'icons', []), lodashGet(nextProps.report, 'icons', [])); } @@ -405,22 +412,26 @@ class ReportActionsView extends React.Component { return ( <> - - - + {!this.props.isComposerFullSize && ( + <> + + + + + )} diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index 237892d28287..2f9d7f5360e2 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -86,7 +86,7 @@ class CloseAccountPage extends Component { {' '} {this.props.translate('closeAccountPage.closeAccountPermanentlyDeleteData')} - + {this.props.translate('closeAccountPage.defaultContact')} diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index 9469828a21e6..8d48d708bc9e 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -32,9 +32,9 @@ const ChangeExpensifyLoginLink = props => ( {props.translate('common.not')}   - {Str.isSMSLogin(props.credentials.login) - ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login)) - : Str.removeSMSDomain(props.credentials.login)} + {Str.isSMSLogin(props.credentials.login || '') + ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login || '')) + : Str.removeSMSDomain(props.credentials.login || '')} {'? '} { afterEach(() => { // Unsubscribe from account channel after each test since we subscribe in the function // subscribeToUserEvents and we don't want duplicate event subscriptions. - Pusher.unsubscribe(`private-encrypted-user-accountID-1${CONFIG.PUSHER.SUFFIX}`); + Pusher.unsubscribe(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`); }); it('should store a new report action in Onyx when reportComment event is handled via Pusher', () => { @@ -106,7 +106,7 @@ describe('actions/Report', () => { .then(() => { // We subscribed to the Pusher channel above and now we need to simulate a reportComment action // Pusher event so we can verify that action was handled correctly and merged into the reportActions. - const channel = Pusher.getChannel(`private-encrypted-user-accountID-1${CONFIG.PUSHER.SUFFIX}`); + const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`); channel.emit(Pusher.TYPE.REPORT_COMMENT, { reportID: REPORT_ID, reportAction: {...REPORT_ACTION, clientID}, diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.js index 1e4208f5e770..cd7ee2dc3caa 100644 --- a/tests/unit/NetworkTest.js +++ b/tests/unit/NetworkTest.js @@ -461,10 +461,27 @@ test(`persisted request should be retried up to ${CONST.NETWORK.MAX_REQUEST_RETR }); }); -test('test bad response will log alert', () => { +test('test Bad Gateway status will log hmmm', () => { global.fetch = jest.fn() .mockResolvedValueOnce({ok: false, status: 502, statusText: 'Bad Gateway'}); + const logHmmmSpy = jest.spyOn(Log, 'hmmm'); + + // Given we have a request made while online + return Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}) + .then(() => { + Network.post('MockBadNetworkResponse', {param1: 'value1'}); + return waitForPromisesToResolve(); + }) + .then(() => { + expect(logHmmmSpy).toHaveBeenCalled(); + }); +}); + +test('test unknown status will log alert', () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ok: false, status: 418, statusText: 'I\'m a teapot'}); + const logAlertSpy = jest.spyOn(Log, 'alert'); // Given we have a request made while online