diff --git a/assets/images/collapse.svg b/assets/images/collapse.svg new file mode 100644 index 0000000000000..92b9619924f06 --- /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 0000000000000..cdd1d712fd6a6 --- /dev/null +++ b/assets/images/expand.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/CONST.js b/src/CONST.js index 5b1b82da59048..124369fc19ea7 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', diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 9e198d07aad7e..1f47b414ee230 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/Composer/index.android.js b/src/components/Composer/index.android.js index 687ac17ff91b9..76b63eb7f972a 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 d61339f18f3ff..9b1e6680dea3f 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 740d28757a6f3..3d0b4f351f7c3 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/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 0e8d598ad703a..0b11a5602b131 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/languages/en.js b/src/languages/en.js index ec3b1554a9a98..0bc2f39406660 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 b2c86f47e3108..6e21739200c30 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 0000000000000..a469da7516bbc --- /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 0000000000000..783fbac9a4269 --- /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 0000000000000..00b12d1742e3e --- /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/actions/Report.js b/src/libs/actions/Report.js index 3abd3ba123568..618d38bcf824f 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -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/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index d03dca98fcd53..5d32a341c7256 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 d7ebdb896a051..a48ef025fa0ab 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 ef0520b143f9e..1e919028393f5 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/styles/styles.js b/src/styles/styles.js index 58efa6b4cf733..493333605ff19 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1328,6 +1328,10 @@ const styles = { minHeight: 90, }, + chatItemFullComposeRow: { + ...sizing.h100, + }, + chatItemComposeBoxColor: { borderColor: themeColors.border, }, @@ -1343,6 +1347,12 @@ const styles = { minHeight: variables.componentSizeNormal, }, + chatItemFullComposeBox: { + ...flex.flex1, + ...spacing.mt4, + ...sizing.h100, + }, + chatFooter: { minHeight: 65, marginBottom: 5, @@ -1352,6 +1362,12 @@ const styles = { backgroundColor: themeColors.appBG, }, + chatFooterFullCompose: { + flex: 1, + flexShrink: 1, + flexBasis: '100%', + }, + textInputCompose: addOutlineWidth({ backgroundColor: themeColors.componentBG, borderColor: themeColors.border, @@ -1368,12 +1384,23 @@ const styles = { // paddingVertical: 0, alignSelf: 'center', and textAlignVertical: 'center' paddingHorizontal: 8, - marginVertical: 5, paddingVertical: 0, ...textInputAlignSelf.center, textAlignVertical: 'center', }, 0), + textInputFullCompose: { + alignSelf: 'flex-end', + flex: 1, + maxHeight: '100%', + }, + + textInputComposeSpacing: { + paddingVertical: 6, + ...flex.flexRow, + flex: 1, + }, + chatItemSubmitButton: { alignSelf: 'flex-end', borderRadius: 6, @@ -1464,6 +1491,16 @@ const styles = { width: 39, }, + composerSizeButton: { + alignItems: 'center', + alignSelf: 'flex-end', + height: 26, + marginBottom: 6, + marginTop: 6, + justifyContent: 'center', + width: 39, + }, + chatItemAttachmentPlaceholder: { backgroundColor: themeColors.sidebar, borderColor: themeColors.border,