diff --git a/src/ROUTES.js b/src/ROUTES.js index c5a085502b19..9f2866610eb4 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -171,11 +171,17 @@ export default { * @returns {Object} */ parseReportRouteParams: (route) => { - if (!route.startsWith(Url.addTrailingForwardSlash(REPORT))) { + let parsingRoute = route; + if (parsingRoute.at(0) === '/') { + // remove the first slash + parsingRoute = parsingRoute.slice(1); + } + + if (!parsingRoute.startsWith(Url.addTrailingForwardSlash(REPORT))) { return {reportID: '', isSubReportPageRoute: false}; } - const pathSegments = route.split('/'); + const pathSegments = parsingRoute.split('/'); return { reportID: lodashGet(pathSegments, 1), isSubReportPageRoute: Boolean(lodashGet(pathSegments, 2)), diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index eab4abe8a6fe..af350ae48336 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -16,6 +16,7 @@ import styles from '../../styles/styles'; import Text from '../Text'; import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition'; import CONST from '../../CONST'; +import withNavigation from '../withNavigation'; const propTypes = { /** Maximum number of lines in the text input */ @@ -77,6 +78,9 @@ const propTypes = { /** Should we calculate the caret position */ shouldCalculateCaretPosition: PropTypes.bool, + /** Function to check whether composer is covered up or not */ + checkComposerVisibility: PropTypes.func, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -104,6 +108,7 @@ const defaultProps = { setIsFullComposerAvailable: () => {}, isComposerFullSize: false, shouldCalculateCaretPosition: false, + checkComposerVisibility: () => false, }; const IMAGE_EXTENSIONS = { @@ -143,6 +148,8 @@ class Composer extends React.Component { this.shouldCallUpdateNumberOfLines = this.shouldCallUpdateNumberOfLines.bind(this); this.addCursorPositionToSelectionChange = this.addCursorPositionToSelectionChange.bind(this); this.textRef = React.createRef(null); + this.unsubscribeBlur = () => null; + this.unsubscribeFocus = () => null; } componentDidMount() { @@ -159,8 +166,15 @@ class Composer extends React.Component { // There is no onPaste or onDrag for TextInput in react-native so we will add event // listeners here and unbind when the component unmounts if (this.textInput) { - this.textInput.addEventListener('paste', this.handlePaste); this.textInput.addEventListener('wheel', this.handleWheel); + + // we need to handle listeners on navigation focus/blur as Composer is not unmounting + // when navigating away to different report + this.unsubscribeFocus = this.props.navigation.addListener('focus', () => document.addEventListener('paste', this.handlePaste)); + this.unsubscribeBlur = this.props.navigation.addListener('blur', () => document.removeEventListener('paste', this.handlePaste)); + + // We need to add paste listener manually as well as navigation focus event is not triggered on component mount + document.addEventListener('paste', this.handlePaste); } } @@ -193,7 +207,9 @@ class Composer extends React.Component { return; } - this.textInput.removeEventListener('paste', this.handlePaste); + document.removeEventListener('paste', this.handlePaste); + this.unsubscribeFocus(); + this.unsubscribeBlur(); this.textInput.removeEventListener('wheel', this.handleWheel); } @@ -262,6 +278,7 @@ class Composer extends React.Component { */ paste(text) { try { + this.textInput.focus(); document.execCommand('insertText', false, text); this.updateNumberOfLines(); @@ -289,6 +306,10 @@ class Composer extends React.Component { * @param {ClipboardEvent} event */ handlePaste(event) { + if (!this.props.checkComposerVisibility() && !this.state.isFocused) { + return; + } + event.preventDefault(); const {files, types} = event.clipboardData; @@ -474,6 +495,7 @@ Composer.defaultProps = defaultProps; export default compose( withLocalize, withWindowDimensions, + withNavigation, )( React.forwardRef((props, ref) => ( { */ const isActiveReportAction = (actionID) => Boolean(actionID) && reportAction.reportActionID === actionID; - useImperativeHandle(ref, () => ({showEmojiPicker, isActiveReportAction, hideEmojiPicker})); + useImperativeHandle(ref, () => ({showEmojiPicker, isActiveReportAction, hideEmojiPicker, isEmojiPickerVisible})); // There is no way to disable animations, and they are really laggy, because there are so many // emojis. The best alternative is to set it to 1ms so it just "pops" in and out diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.js b/src/libs/KeyboardShortcut/KeyDownPressListener/index.js new file mode 100644 index 000000000000..4401beef1c59 --- /dev/null +++ b/src/libs/KeyboardShortcut/KeyDownPressListener/index.js @@ -0,0 +1,9 @@ +function addKeyDownPressListner(callbackFunction) { + document.addEventListener('keydown', callbackFunction); +} + +function removeKeyDownPressListner(callbackFunction) { + document.removeEventListener('keydown', callbackFunction); +} + +export {addKeyDownPressListner, removeKeyDownPressListner}; diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js b/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js new file mode 100644 index 000000000000..aa1ded824d22 --- /dev/null +++ b/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js @@ -0,0 +1,4 @@ +function addKeyDownPressListner() {} +function removeKeyDownPressListner() {} + +export {addKeyDownPressListner, removeKeyDownPressListner}; diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js index 9d4ef0b2e98c..458154913e3c 100644 --- a/src/libs/actions/EmojiPickerAction.js +++ b/src/libs/actions/EmojiPickerAction.js @@ -45,4 +45,11 @@ function isActiveReportAction(actionID) { return emojiPickerRef.current.isActiveReportAction(actionID); } -export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActiveReportAction}; +function isEmojiPickerVisible() { + if (!emojiPickerRef.current) { + return; + } + return emojiPickerRef.current.isEmojiPickerVisible; +} + +export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActiveReportAction, isEmojiPickerVisible}; diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 46d615402511..a90c062500fc 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -52,6 +52,8 @@ import * as Task from '../../../libs/actions/Task'; import * as Browser from '../../../libs/Browser'; import * as IOU from '../../../libs/actions/IOU'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; +import * as KeyDownListener from '../../../libs/KeyboardShortcut/KeyDownPressListener'; +import * as EmojiPickerActions from '../../../libs/actions/EmojiPickerAction'; const propTypes = { /** Beta features list */ @@ -168,7 +170,9 @@ class ReportActionCompose extends React.Component { 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.replaceSelectionWithText = this.replaceSelectionWithText.bind(this); + this.focusComposerOnKeyPress = this.focusComposerOnKeyPress.bind(this); + this.checkComposerVisibility = this.checkComposerVisibility.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.isEmojiCode = this.isEmojiCode.bind(this); this.isMentionCode = this.isMentionCode.bind(this); @@ -186,6 +190,8 @@ class ReportActionCompose extends React.Component { this.comment = props.comment; this.insertedEmojis = []; + this.attachmentModalRef = React.createRef(); + // React Native will retain focus on an input for native devices but web/mWeb behave differently so we have some focus management // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager this.willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutside(); @@ -205,6 +211,9 @@ class ReportActionCompose extends React.Component { // so we need to ensure that it is only updated after focus. const isMobileSafari = Browser.isMobileSafari(); + this.unsubscribeNavigationBlur = () => null; + this.unsubscribeNavigationFocus = () => null; + this.state = { isFocused: this.shouldFocusInputOnScreenFocus && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible && this.props.shouldShowComposeInput, isFullComposerAvailable: props.isComposerFullSize, @@ -238,6 +247,10 @@ class ReportActionCompose extends React.Component { this.focus(false); }); + this.unsubscribeNavigationBlur = this.props.navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(this.focusComposerOnKeyPress)); + this.unsubscribeNavigationFocus = this.props.navigation.addListener('focus', () => KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress)); + KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress); + this.updateComment(this.comment); // Shows Popover Menu on Workspace Chat at first sign-in @@ -276,6 +289,10 @@ class ReportActionCompose extends React.Component { componentWillUnmount() { ReportActionComposeFocusManager.clear(); + + KeyDownListener.removeKeyDownPressListner(this.focusComposerOnKeyPress); + this.unsubscribeNavigationBlur(); + this.unsubscribeNavigationFocus(); } onSelectionChange(e) { @@ -664,20 +681,56 @@ class ReportActionCompose extends React.Component { } /** - * Callback for the emoji picker to add whatever emoji is chosen into the main input - * - * @param {String} emoji + * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) + * @param {String} text + * @param {Boolean} shouldAddTrailSpace */ - addEmojiToTextBox(emoji) { - this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, `${emoji} `)); + replaceSelectionWithText(text, shouldAddTrailSpace = true) { + const updatedText = shouldAddTrailSpace ? `${text} ` : text; + const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; + this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, updatedText)); this.setState((prevState) => ({ selection: { - start: prevState.selection.start + emoji.length + CONST.SPACE_LENGTH, - end: prevState.selection.start + emoji.length + CONST.SPACE_LENGTH, + start: prevState.selection.start + text.length + selectionSpaceLength, + end: prevState.selection.start + text.length + selectionSpaceLength, }, })); } + /** + * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. + * @returns {Boolean} + */ + checkComposerVisibility() { + const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || this.state.isMenuVisible || this.props.modal.isVisible; + return !isComposerCoveredUp; + } + + focusComposerOnKeyPress(e) { + const isComposerVisible = this.checkComposerVisibility(); + if (!isComposerVisible) { + return; + } + + // If the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { + return; + } + + // If a key is pressed in combination with Meta, Control or Alt do not focus + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + // if we're typing on another input/text area, do not focus + if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { + return; + } + + this.focus(); + this.replaceSelectionWithText(e.key, false); + } + /** * Focus the composer text input * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer @@ -1084,6 +1137,7 @@ class ReportActionCompose extends React.Component { disabled={this.props.disabled} > this.checkComposerVisibility()} autoFocus={this.shouldAutoFocus} multiline ref={this.setTextInputRef} @@ -1136,7 +1190,7 @@ class ReportActionCompose extends React.Component { onModalHide={() => { this.focus(true); }} - onEmojiSelected={this.addEmojiToTextBox} + onEmojiSelected={this.replaceSelectionWithText} /> )}