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