From 2e3fbf35e8e3f265c265fcffe839984b7af7315a Mon Sep 17 00:00:00 2001 From: tienifr Date: Fri, 7 Apr 2023 22:51:01 +0700 Subject: [PATCH 01/14] fix: listen keydown from user to focus again the composer --- src/pages/home/report/ReportActionCompose.js | 41 ++++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 64af28efbcfd..17dc03f1e8e9 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -159,6 +159,8 @@ class ReportActionCompose extends React.Component { this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this); this.focus = this.focus.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); + this.replaceSelectionWithInput = this.replaceSelectionWithInput.bind(this); + this.keypressListener = this.keypressListener.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.isEmojiCode = this.isEmojiCode.bind(this); this.setTextInputRef = this.setTextInputRef.bind(this); @@ -200,6 +202,8 @@ class ReportActionCompose extends React.Component { } componentDidMount() { + document.addEventListener('keydown', this.keypressListener); + // This callback is used in the contextMenuActions to manage giving focus back to the compose input. // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component ReportActionComposeFocusManager.onComposerFocus(() => { @@ -255,6 +259,7 @@ class ReportActionCompose extends React.Component { } componentWillUnmount() { + document.removeEventListener('keydown', this.keypressListener); ReportActionComposeFocusManager.clear(); if (this.unsubscribeEscapeKey) { @@ -460,25 +465,45 @@ class ReportActionCompose extends React.Component { return _.size(this.props.reportActions) === 1; } + keypressListener(e) { + if (this.state.isFocused) { return; } + + // if the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { return; } + + // if we're typing on another input/text area, do not focus + if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { return; } + + this.focus(); + this.replaceSelectionWithInput(e.key); + } + /** - * Callback for the emoji picker to add whatever emoji is chosen into the main input - * - * @param {String} emoji + * @param {String} text */ - addEmojiToTextBox(emoji) { - const emojiWithSpace = `${emoji} `; + replaceSelectionWithInput(text) { const newComment = this.comment.slice(0, this.state.selection.start) - + emojiWithSpace + + text + this.comment.slice(this.state.selection.end, this.comment.length); this.setState(prevState => ({ selection: { - start: prevState.selection.start + emojiWithSpace.length, - end: prevState.selection.start + emojiWithSpace.length, + start: prevState.selection.start + text.length, + end: prevState.selection.start + text.length, }, })); this.updateComment(newComment); } + /** + * Callback for the emoji picker to add whatever emoji is chosen into the main input + * + * @param {String} emoji + */ + addEmojiToTextBox(emoji) { + const emojiWithSpace = `${emoji} `; + this.replaceSelectionWithInput(emojiWithSpace); + } + /** * Focus the composer text input * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer From e0ed40abeb981a9c7383cdd2b8042e20c36b1c91 Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 8 Apr 2023 00:44:42 +0700 Subject: [PATCH 02/14] fix: rename --- src/pages/home/report/ReportActionCompose.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 17dc03f1e8e9..1ed064057985 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -160,7 +160,7 @@ class ReportActionCompose extends React.Component { this.focus = this.focus.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); this.replaceSelectionWithInput = this.replaceSelectionWithInput.bind(this); - this.keypressListener = this.keypressListener.bind(this); + this.keydownListener = this.keydownListener.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.isEmojiCode = this.isEmojiCode.bind(this); this.setTextInputRef = this.setTextInputRef.bind(this); @@ -202,7 +202,7 @@ class ReportActionCompose extends React.Component { } componentDidMount() { - document.addEventListener('keydown', this.keypressListener); + document.addEventListener('keydown', this.keydownListener); // This callback is used in the contextMenuActions to manage giving focus back to the compose input. // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component @@ -259,7 +259,7 @@ class ReportActionCompose extends React.Component { } componentWillUnmount() { - document.removeEventListener('keydown', this.keypressListener); + document.removeEventListener('keydown', this.keydownListener); ReportActionComposeFocusManager.clear(); if (this.unsubscribeEscapeKey) { @@ -465,7 +465,7 @@ class ReportActionCompose extends React.Component { return _.size(this.props.reportActions) === 1; } - keypressListener(e) { + keydownListener(e) { if (this.state.isFocused) { return; } // if the key pressed is non-character keys like Enter, Shift, ... do not focus @@ -490,8 +490,7 @@ class ReportActionCompose extends React.Component { start: prevState.selection.start + text.length, end: prevState.selection.start + text.length, }, - })); - this.updateComment(newComment); + }), this.updateComment(newComment)); } /** From a5d171b6ccd6c2cbef00285c4d984796bf4b845d Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 8 Apr 2023 01:09:56 +0700 Subject: [PATCH 03/14] fix: move this fix to web only --- .../home/report/ReportActionCompose/index.js | 991 ++++++++++++++++++ .../ReportActionCompose/listenKeyDown.js | 5 + .../listenKeyDown.native.js | 3 + .../removeListenKeyDown.js | 5 + .../removeListenKeyDown.native.js | 3 + 5 files changed, 1007 insertions(+) create mode 100644 src/pages/home/report/ReportActionCompose/index.js create mode 100644 src/pages/home/report/ReportActionCompose/listenKeyDown.js create mode 100644 src/pages/home/report/ReportActionCompose/listenKeyDown.native.js create mode 100644 src/pages/home/report/ReportActionCompose/removeListenKeyDown.js create mode 100644 src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js new file mode 100644 index 000000000000..02327aed692f --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -0,0 +1,991 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + View, + TouchableOpacity, + InteractionManager, + LayoutAnimation, +} from 'react-native'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Composer from '../../../../components/Composer'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../../components/AttachmentPicker'; +import * as Report from '../../../../libs/actions/Report'; +import ReportTypingIndicator from '../ReportTypingIndicator'; +import AttachmentModal from '../../../../components/AttachmentModal'; +import compose from '../../../../libs/compose'; +import PopoverMenu from '../../../../components/PopoverMenu'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; +import withDrawerState from '../../../../components/withDrawerState'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import willBlurTextInputOnTapOutside from '../../../../libs/willBlurTextInputOnTapOutside'; +import CONST from '../../../../CONST'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import ROUTES from '../../../../ROUTES'; +import reportActionPropTypes from '../reportActionPropTypes'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; +import participantPropTypes from '../../../../components/participantPropTypes'; +import ParticipantLocalTime from '../ParticipantLocalTime'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; +import {withNetwork, withPersonalDetails} from '../../../../components/OnyxProvider'; +import * as User from '../../../../libs/actions/User'; +import Tooltip from '../../../../components/Tooltip'; +import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; +import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; +import toggleReportActionComposeView from '../../../../libs/toggleReportActionComposeView'; +import OfflineIndicator from '../../../../components/OfflineIndicator'; +import ExceededCommentLength from '../../../../components/ExceededCommentLength'; +import withNavigationFocus from '../../../../components/withNavigationFocus'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import ReportDropUI from '../ReportDropUI'; +import DragAndDrop from '../../../../components/DragAndDrop'; +import reportPropTypes from '../../../reportPropTypes'; +import EmojiSuggestions from '../../../../components/EmojiSuggestions'; +import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; +import ArrowKeyFocusManager from '../../../../components/ArrowKeyFocusManager'; +import KeyboardShortcut from '../../../../libs/KeyboardShortcut'; +import listenKeyDown from './listenKeyDown'; +import removeListenKeyDown from './removeListenKeyDown'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** A method to call when the form is submitted */ + onSubmit: PropTypes.func.isRequired, + + /** The comment left by the user */ + comment: PropTypes.string, + + /** Number of lines for the comment */ + numberOfLines: PropTypes.number, + + /** The ID of the report actions will be created for */ + reportID: PropTypes.string.isRequired, + + /** Details about any modals being used */ + modal: PropTypes.shape({ + /** Indicates if there is a modal currently visible or not */ + isVisible: PropTypes.bool, + }), + + /** Personal details of all the users */ + personalDetails: PropTypes.objectOf(participantPropTypes), + + /** The report currently being looked at */ + report: reportPropTypes, + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** Is the report view covered by the drawer */ + isDrawerOpen: PropTypes.bool.isRequired, + + /** Is the window width narrow, like on a mobile device */ + isSmallScreenWidth: PropTypes.bool.isRequired, + + /** Is composer screen focused */ + isFocused: PropTypes.bool.isRequired, + + /** Whether user interactions should be disabled */ + disabled: PropTypes.bool, + + // The NVP describing a user's block status + blockedFromConcierge: PropTypes.shape({ + // The date that the user will be unblocked + expiresAt: PropTypes.string, + }), + + /** Stores user's preferred skin tone */ + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** User's frequently used emojis */ + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + keywords: PropTypes.arrayOf(PropTypes.string), + })), + + ...windowDimensionsPropTypes, + ...withLocalizePropTypes, + ...withCurrentUserPersonalDetailsPropTypes, + ...keyboardStatePropTypes, +}; + +const defaultProps = { + betas: [], + comment: '', + numberOfLines: 1, + modal: {}, + report: {}, + reportActions: [], + blockedFromConcierge: {}, + personalDetails: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + frequentlyUsedEmojis: [], + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isEmojiPickerLarge + * @returns {Number} + */ +const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { + // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items + const emojiRowCount = isEmojiPickerLarge + ? Math.max(numRows, CONST.EMOJI_SUGGESTER.MAX_AMOUNT_OF_ITEMS) + : Math.max(numRows, CONST.EMOJI_SUGGESTER.MIN_AMOUNT_OF_ITEMS); + + // -1 because we start at 0 + return emojiRowCount - 1; +}; + +class ReportActionCompose extends React.Component { + constructor(props) { + super(props); + this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); + this.updateComment = this.updateComment.bind(this); + this.debouncedSaveReportComment = _.debounce(this.debouncedSaveReportComment.bind(this), 1000, false); + this.debouncedBroadcastUserIsTyping = _.debounce(this.debouncedBroadcastUserIsTyping.bind(this), 100, true); + 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.replaceSelectionWithInput = this.replaceSelectionWithInput.bind(this); + this.keydownListener = this.keydownListener.bind(this); + this.onSelectionChange = this.onSelectionChange.bind(this); + this.isEmojiCode = this.isEmojiCode.bind(this); + this.setTextInputRef = this.setTextInputRef.bind(this); + this.getInputPlaceholder = this.getInputPlaceholder.bind(this); + this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); + this.addAttachment = this.addAttachment.bind(this); + this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); + this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); + this.updateNumberOfLines = this.updateNumberOfLines.bind(this); + this.comment = props.comment; + + // 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(); + + this.state = { + isFocused: this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible, + isFullComposerAvailable: props.isComposerFullSize, + textInputShouldClear: false, + isCommentEmpty: props.comment.length === 0, + isMenuVisible: false, + selection: { + start: props.comment.length, + end: props.comment.length, + }, + maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, + value: props.comment, + + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + conciergePlaceholderRandomIndex: _.random(this.props.translate('reportActionCompose.conciergePlaceholderOptions').length - (this.props.isSmallScreenWidth ? 4 : 1)), + suggestedEmojis: [], + highlightedEmojiIndex: 0, + colonIndex: -1, + shouldShowSuggestionMenu: false, + isEmojiPickerLarge: false, + composerHeight: 0, + hasExceededMaxCommentLength: false, + }; + } + + componentDidMount() { + listenKeyDown(this.keydownListener); + + // This callback is used in the contextMenuActions to manage giving focus back to the compose input. + // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component + ReportActionComposeFocusManager.onComposerFocus(() => { + if (!this.willBlurTextInputOnTapOutside || !this.props.isFocused) { + return; + } + + this.focus(false); + }); + + const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + if (!this.state.isFocused || this.comment.length === 0) { + return; + } + + this.updateComment('', true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + + this.setMaxLines(); + this.updateComment(this.comment); + } + + componentDidUpdate(prevProps) { + const sidebarOpened = !prevProps.isDrawerOpen && this.props.isDrawerOpen; + if (sidebarOpened) { + toggleReportActionComposeView(true); + } + + // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (this.willBlurTextInputOnTapOutside && this.props.isFocused + && prevProps.modal.isVisible && !this.props.modal.isVisible) { + this.focus(); + } + + if (this.props.isComposerFullSize !== prevProps.isComposerFullSize) { + this.setMaxLines(); + } + + // Value state does not have the same value as comment props when the comment gets changed from another tab. + // In this case, we should synchronize the value between tabs. + const shouldSyncComment = prevProps.comment !== this.props.comment && this.state.value !== this.props.comment; + + // 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 && !shouldSyncComment) { + return; + } + + this.updateComment(this.props.comment); + } + + componentWillUnmount() { + removeListenKeyDown(); + ReportActionComposeFocusManager.clear(); + + if (this.unsubscribeEscapeKey) { + this.unsubscribeEscapeKey(); + } + } + + onSelectionChange(e) { + this.setState({selection: e.nativeEvent.selection}); + this.calculateEmojiSuggestion(); + } + + /** + * Updates the Highlight state of the composer + * + * @param {Boolean} shouldHighlight + */ + setIsFocused(shouldHighlight) { + this.setState({isFocused: shouldHighlight}); + } + + setIsFullComposerAvailable(isFullComposerAvailable) { + this.setState({isFullComposerAvailable}); + } + + /** + * Updates the should clear state of the composer + * + * @param {Boolean} shouldClear + */ + setTextInputShouldClear(shouldClear) { + this.setState({textInputShouldClear: shouldClear}); + } + + /** + * Updates the visibility state of the menu + * + * @param {Boolean} isMenuVisible + */ + setMenuVisibility(isMenuVisible) { + this.setState({isMenuVisible}); + } + + /** + * Set the TextInput Ref + * + * @param {Element} el + * @memberof ReportActionCompose + */ + setTextInputRef(el) { + ReportActionComposeFocusManager.composerRef.current = el; + this.textInput = el; + } + + /** + * Get the placeholder to display in the chat input. + * + * @return {String} + */ + getInputPlaceholder() { + if (ReportUtils.chatIncludesConcierge(this.props.report)) { + if (User.isBlockedFromConcierge(this.props.blockedFromConcierge)) { + return this.props.translate('reportActionCompose.blockedFromConcierge'); + } + + return this.props.translate('reportActionCompose.conciergePlaceholderOptions')[this.state.conciergePlaceholderRandomIndex]; + } + + return this.props.translate('reportActionCompose.writeSomething'); + } + + /** + * Returns the list of IOU Options + * + * @param {Array} reportParticipants + * @returns {Array} + */ + getMoneyRequestOptions(reportParticipants) { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: this.props.translate('iou.splitBill'), + onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(this.props.reportID)), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: this.props.translate('iou.requestMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(this.props.reportID)), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: this.props.translate('iou.sendMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(this.props.reportID)), + }, + }; + return _.map(ReportUtils.getMoneyRequestOptions(this.props.report, reportParticipants, this.props.betas), option => options[option]); + } + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + * + * @param {Boolean} hasExceededMaxCommentLength + */ + setExceededMaxCommentLength(hasExceededMaxCommentLength) { + this.setState({hasExceededMaxCommentLength}); + } + + /** + * 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}); + } + + // eslint-disable-next-line rulesdir/prefer-early-return + setShouldShowSuggestionMenuToFalse() { + if (this.state && this.state.shouldShowSuggestionMenu) { + this.setState({shouldShowSuggestionMenu: false}); + } + } + + /** + * Clean data related to EmojiSuggestions + */ + resetSuggestedEmojis() { + this.setState({ + suggestedEmojis: [], + shouldShowSuggestionMenu: false, + }); + } + + /** + * Calculates and cares about the content of an Emoji Suggester + */ + calculateEmojiSuggestion() { + const leftString = this.state.value.substring(0, this.state.selection.end); + const colonIndex = leftString.lastIndexOf(':'); + const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.selection.end); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = this.props.windowHeight / this.state.composerHeight >= 6.8; + const isEmojiPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + + const nextState = { + suggestedEmojis: [], + highlightedEmojiIndex: 0, + colonIndex, + shouldShowSuggestionMenu: false, + isEmojiPickerLarge, + }; + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); + + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + } + + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + + this.setState(nextState); + } + + /** + * Check if this piece of string looks like an emoji + * @param {String} str + * @param {Number} pos + * @returns {Boolean} + */ + isEmojiCode(str, pos) { + const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE); + const leftWord = _.last(leftWords); + + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; + } + + /** + * Replace the code of emoji and update selection + * @param {Number} highlightedEmojiIndex + */ + insertSelectedEmoji(highlightedEmojiIndex) { + const commentBeforeColon = this.state.value.slice(0, this.state.colonIndex); + const emojiObject = this.state.suggestedEmojis[highlightedEmojiIndex]; + const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + + this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + this.setState(prevState => ({ + selection: { + start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }, + suggestedEmojis: [], + })); + EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject); + } + + isEmptyChat() { + return _.size(this.props.reportActions) === 1; + } + + keydownListener(e) { + if (this.state.isFocused) { return; } + + // if the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { return; } + + // if we're typing on another input/text area, do not focus + if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { return; } + + this.focus(); + this.replaceSelectionWithInput(e.key); + } + + /** + * @param {String} text + */ + replaceSelectionWithInput(text) { + const newComment = this.comment.slice(0, this.state.selection.start) + + text + + this.comment.slice(this.state.selection.end, this.comment.length); + this.setState(prevState => ({ + selection: { + start: prevState.selection.start + text.length, + end: prevState.selection.start + text.length, + }, + }), this.updateComment(newComment)); + } + + /** + * Callback for the emoji picker to add whatever emoji is chosen into the main input + * + * @param {String} emoji + */ + addEmojiToTextBox(emoji) { + const emojiWithSpace = `${emoji} `; + this.replaceSelectionWithInput(emojiWithSpace); + } + + /** + * Focus the composer text input + * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer + * @memberof ReportActionCompose + */ + focus(shouldelay = false) { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + if (!this.textInput) { + return; + } + + if (!shouldelay) { + this.textInput.focus(); + } else { + // Keyboard is not opened after Emoji Picker is closed + // SetTimeout is used as a workaround + // https://github.com/react-native-modal/react-native-modal/issues/114 + // We carefully choose a delay. 100ms is found enough for keyboard to open. + setTimeout(() => this.textInput.focus(), 100); + } + }); + } + + /** + * Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often + * to update Onyx and re-render this component. + * + * @param {String} comment + */ + debouncedSaveReportComment(comment) { + Report.saveReportComment(this.props.reportID, comment || ''); + } + + /** + * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish + * client events. + */ + debouncedBroadcastUserIsTyping() { + Report.broadcastUserIsTyping(this.props.reportID); + } + + /** + * Update the value of the comment in Onyx + * + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment + */ + updateComment(comment, shouldDebounceSaveComment) { + const newComment = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); + this.setState((prevState) => { + const newState = { + isCommentEmpty: !!newComment.match(/^(\s)*$/), + value: newComment, + }; + if (comment !== newComment) { + const remainder = prevState.value.slice(prevState.selection.end).length; + newState.selection = { + start: newComment.length - remainder, + end: newComment.length - remainder, + }; + } + return newState; + }); + + // Indicate that draft has been created. + if (this.comment.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(this.props.reportID, true); + } + + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(this.props.reportID, false); + } + + this.comment = newComment; + if (shouldDebounceSaveComment) { + this.debouncedSaveReportComment(newComment); + } else { + Report.saveReportComment(this.props.reportID, newComment || ''); + } + if (newComment) { + this.debouncedBroadcastUserIsTyping(); + } + } + + /** + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines + */ + updateNumberOfLines(numberOfLines) { + Report.saveReportCommentNumberOfLines(this.props.reportID, numberOfLines); + } + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + triggerHotkeyActions(e) { + // Do not trigger actions for mobileWeb or native clients that have the keyboard open because for those devices, we want the return key to insert newlines rather than submit the form + if (!e || this.props.isSmallScreenWidth || this.props.isKeyboardShown) { + return; + } + + if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && this.state.suggestedEmojis.length) { + e.preventDefault(); + this.insertSelectedEmoji(this.state.highlightedEmojiIndex); + return; + } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && this.state.suggestedEmojis.length) { + e.preventDefault(); + this.resetSuggestedEmojis(); + return; + } + + // Submit the form when Enter is pressed + if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { + e.preventDefault(); + this.submitForm(); + } + + // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants + if ( + e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && this.textInput.selectionStart === 0 && this.state.isCommentEmpty && !ReportUtils.chatIncludesChronos(this.props.report) + ) { + e.preventDefault(); + + const lastReportAction = _.find( + this.props.reportActions, + action => ReportUtils.canEditReportAction(action), + ); + + if (lastReportAction !== -1 && lastReportAction) { + Report.saveReportActionDraft(this.props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + } + } + } + + /** + * @returns {String} + */ + prepareCommentAndResetComposer() { + const trimmedComment = this.comment.trim(); + + // Don't submit empty comments or comments that exceed the character limit + if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { + return ''; + } + + this.updateComment(''); + this.setTextInputShouldClear(true); + if (this.props.isComposerFullSize) { + Report.setIsComposerFullSize(this.props.reportID, false); + } + this.setState({isFullComposerAvailable: false}); + + return trimmedComment; + } + + /** + * @param {Object} file + */ + addAttachment(file) { + const comment = this.prepareCommentAndResetComposer(); + Report.addAttachment(this.props.reportID, file, comment); + this.setTextInputShouldClear(false); + } + + /** + * Add a new comment to this chat + * + * @param {SyntheticEvent} [e] + */ + submitForm(e) { + if (e) { + e.preventDefault(); + } + + // Since we're submitting the form here which should clear the composer + // We don't really care about saving the draft the user was typing + // We need to make sure an empty draft gets saved instead + this.debouncedSaveReportComment.cancel(); + + const comment = this.prepareCommentAndResetComposer(); + if (!comment) { + return; + } + + this.props.onSubmit(comment); + } + + render() { + const reportParticipants = _.without(lodashGet(this.props.report, 'participants', []), this.props.currentUserPersonalDetails.login); + const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); + const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyEmails[0]]; + + 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; + const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); + const inputPlaceholder = this.getInputPlaceholder(); + const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); + const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; + + return ( + + {shouldShowReportRecipientLocalTime && } + + + {({displayFileInModal}) => ( + <> + + {({openPicker}) => ( + <> + + {this.props.isComposerFullSize && ( + + { + e.preventDefault(); + this.setShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(this.props.reportID, false); + }} + + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || this.props.disabled} + > + + + + + )} + {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( + + { + e.preventDefault(); + this.setShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(this.props.reportID, true); + }} + + // Keep focus on the composer when Expand button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || this.props.disabled} + > + + + + )} + + + this.actionButton = el} + onPress={(e) => { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + this.actionButton.blur(); + this.setMenuVisibility(true); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || this.props.disabled} + > + + + + + + this.setMenuVisibility(false)} + onItemSelected={() => this.setMenuVisibility(false)} + anchorPosition={styles.createMenuPositionReportActionCompose} + menuItems={[...this.getMoneyRequestOptions(reportParticipants), + { + icon: Expensicons.Paperclip, + text: this.props.translate('reportActionCompose.addAttachment'), + onSelected: () => { + openPicker({ + onPicked: displayFileInModal, + }); + }, + }, + ]} + /> + + )} + + + { + this.setState({isDraggingOver: true}); + }} + onDragLeave={() => { + this.setState({isDraggingOver: false}); + }} + onDrop={(e) => { + e.preventDefault(); + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + + displayFileInModal(file); + + this.setState({isDraggingOver: false}); + }} + disabled={this.props.disabled} + > + this.updateComment(comment, true)} + onKeyPress={this.triggerHotkeyActions} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={this.state.maxLines} + onFocus={() => this.setIsFocused(true)} + onBlur={() => { + this.setIsFocused(false); + this.resetSuggestedEmojis(); + }} + onPasteFile={displayFileInModal} + shouldClear={this.state.textInputShouldClear} + onClear={() => this.setTextInputShouldClear(false)} + isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} + selection={this.state.selection} + onSelectionChange={this.onSelectionChange} + isFullComposerAvailable={this.state.isFullComposerAvailable} + setIsFullComposerAvailable={this.setIsFullComposerAvailable} + isComposerFullSize={this.props.isComposerFullSize} + value={this.state.value} + numberOfLines={this.props.numberOfLines} + onNumberOfLinesChange={this.updateNumberOfLines} + onLayout={(e) => { + const composerHeight = e.nativeEvent.layout.height; + if (this.state.composerHeight === composerHeight) { + return; + } + this.setState({composerHeight}); + }} + onScroll={() => this.setShouldShowSuggestionMenuToFalse()} + /> + + + + )} + + {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( + this.focus(true)} + onEmojiSelected={this.addEmojiToTextBox} + /> + )} + e.preventDefault()} + > + + + + + + + + + {!this.props.isSmallScreenWidth && } + + + + {this.state.isDraggingOver && } + {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && ( + this.setState({highlightedEmojiIndex: index})} + > + this.setState({suggestedEmojis: []})} + highlightedEmojiIndex={this.state.highlightedEmojiIndex} + emojis={this.state.suggestedEmojis} + comment={this.state.value} + updateComment={newComment => this.setState({value: newComment})} + colonIndex={this.state.colonIndex} + prefix={this.state.value.slice(this.state.colonIndex + 1).split(' ')[0]} + onSelect={this.insertSelectedEmoji} + isComposerFullSize={this.props.isComposerFullSize} + preferredSkinToneIndex={this.props.preferredSkinTone} + isEmojiPickerLarge={this.state.isEmojiPickerLarge} + composerHeight={this.state.composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + + )} + + ); + } +} + +ReportActionCompose.propTypes = propTypes; +ReportActionCompose.defaultProps = defaultProps; + +export default compose( + withWindowDimensions, + withDrawerState, + withNavigationFocus, + withLocalize, + withNetwork(), + withPersonalDetails(), + withCurrentUserPersonalDetails, + withKeyboardState, + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, + numberOfLines: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, + }, + modal: { + key: ONYXKEYS.MODAL, + }, + blockedFromConcierge: { + key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, + }, + frequentlyUsedEmojis: { + key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, + }), +)(ReportActionCompose); diff --git a/src/pages/home/report/ReportActionCompose/listenKeyDown.js b/src/pages/home/report/ReportActionCompose/listenKeyDown.js new file mode 100644 index 000000000000..247dab277a58 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/listenKeyDown.js @@ -0,0 +1,5 @@ +function listenKeyDown(callback) { + document.addEventListener('keydown', callback); +} + +export default listenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js b/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js new file mode 100644 index 000000000000..2ea397b94f38 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js @@ -0,0 +1,3 @@ +function listenKeyDown() {} + +export default listenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js new file mode 100644 index 000000000000..9ccb36b231bf --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js @@ -0,0 +1,5 @@ +function removeListenKeyDown(callback) { + document.removeEventListener('keydown', callback); +} + +export default removeListenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js new file mode 100644 index 000000000000..80104b8bca24 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js @@ -0,0 +1,3 @@ +function removeListenKeyDown() {} + +export default removeListenKeyDown; From c0c962d0efa3b318116fb1c2e5d6f71dffaa490b Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 8 Apr 2023 01:23:24 +0700 Subject: [PATCH 04/14] fix: remove redundant --- src/pages/home/report/ReportActionCompose.js | 989 ------------------- 1 file changed, 989 deletions(-) delete mode 100644 src/pages/home/report/ReportActionCompose.js diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js deleted file mode 100644 index 1ed064057985..000000000000 --- a/src/pages/home/report/ReportActionCompose.js +++ /dev/null @@ -1,989 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - View, - TouchableOpacity, - InteractionManager, - LayoutAnimation, -} from 'react-native'; -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; -import styles from '../../../styles/styles'; -import themeColors from '../../../styles/themes/default'; -import Composer from '../../../components/Composer'; -import ONYXKEYS from '../../../ONYXKEYS'; -import Icon from '../../../components/Icon'; -import * as Expensicons from '../../../components/Icon/Expensicons'; -import AttachmentPicker from '../../../components/AttachmentPicker'; -import * as Report from '../../../libs/actions/Report'; -import ReportTypingIndicator from './ReportTypingIndicator'; -import AttachmentModal from '../../../components/AttachmentModal'; -import compose from '../../../libs/compose'; -import PopoverMenu from '../../../components/PopoverMenu'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; -import withDrawerState from '../../../components/withDrawerState'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutside from '../../../libs/willBlurTextInputOnTapOutside'; -import CONST from '../../../CONST'; -import Navigation from '../../../libs/Navigation/Navigation'; -import ROUTES from '../../../ROUTES'; -import reportActionPropTypes from './reportActionPropTypes'; -import * as ReportUtils from '../../../libs/ReportUtils'; -import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; -import participantPropTypes from '../../../components/participantPropTypes'; -import ParticipantLocalTime from './ParticipantLocalTime'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; -import {withNetwork, withPersonalDetails} from '../../../components/OnyxProvider'; -import * as User from '../../../libs/actions/User'; -import Tooltip from '../../../components/Tooltip'; -import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; -import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; -import toggleReportActionComposeView from '../../../libs/toggleReportActionComposeView'; -import OfflineIndicator from '../../../components/OfflineIndicator'; -import ExceededCommentLength from '../../../components/ExceededCommentLength'; -import withNavigationFocus from '../../../components/withNavigationFocus'; -import * as EmojiUtils from '../../../libs/EmojiUtils'; -import ReportDropUI from './ReportDropUI'; -import DragAndDrop from '../../../components/DragAndDrop'; -import reportPropTypes from '../../reportPropTypes'; -import EmojiSuggestions from '../../../components/EmojiSuggestions'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; -import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; -import KeyboardShortcut from '../../../libs/KeyboardShortcut'; - -const propTypes = { - /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), - - /** A method to call when the form is submitted */ - onSubmit: PropTypes.func.isRequired, - - /** The comment left by the user */ - comment: PropTypes.string, - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - - /** The ID of the report actions will be created for */ - reportID: PropTypes.string.isRequired, - - /** Details about any modals being used */ - modal: PropTypes.shape({ - /** Indicates if there is a modal currently visible or not */ - isVisible: PropTypes.bool, - }), - - /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** The report currently being looked at */ - report: reportPropTypes, - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - - /** Is the report view covered by the drawer */ - isDrawerOpen: PropTypes.bool.isRequired, - - /** Is the window width narrow, like on a mobile device */ - isSmallScreenWidth: PropTypes.bool.isRequired, - - /** Is composer screen focused */ - isFocused: PropTypes.bool.isRequired, - - /** Whether user interactions should be disabled */ - disabled: PropTypes.bool, - - // The NVP describing a user's block status - blockedFromConcierge: PropTypes.shape({ - // The date that the user will be unblocked - expiresAt: PropTypes.string, - }), - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - /** User's frequently used emojis */ - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ - code: PropTypes.string.isRequired, - keywords: PropTypes.arrayOf(PropTypes.string), - })), - - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, - ...withCurrentUserPersonalDetailsPropTypes, - ...keyboardStatePropTypes, -}; - -const defaultProps = { - betas: [], - comment: '', - numberOfLines: 1, - modal: {}, - report: {}, - reportActions: [], - blockedFromConcierge: {}, - personalDetails: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - frequentlyUsedEmojis: [], - ...withCurrentUserPersonalDetailsDefaultProps, -}; - -/** - * Return the max available index for arrow manager. - * @param {Number} numRows - * @param {Boolean} isEmojiPickerLarge - * @returns {Number} - */ -const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { - // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items - const emojiRowCount = isEmojiPickerLarge - ? Math.max(numRows, CONST.EMOJI_SUGGESTER.MAX_AMOUNT_OF_ITEMS) - : Math.max(numRows, CONST.EMOJI_SUGGESTER.MIN_AMOUNT_OF_ITEMS); - - // -1 because we start at 0 - return emojiRowCount - 1; -}; - -class ReportActionCompose extends React.Component { - constructor(props) { - super(props); - this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); - this.updateComment = this.updateComment.bind(this); - this.debouncedSaveReportComment = _.debounce(this.debouncedSaveReportComment.bind(this), 1000, false); - this.debouncedBroadcastUserIsTyping = _.debounce(this.debouncedBroadcastUserIsTyping.bind(this), 100, true); - 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.replaceSelectionWithInput = this.replaceSelectionWithInput.bind(this); - this.keydownListener = this.keydownListener.bind(this); - this.onSelectionChange = this.onSelectionChange.bind(this); - this.isEmojiCode = this.isEmojiCode.bind(this); - this.setTextInputRef = this.setTextInputRef.bind(this); - this.getInputPlaceholder = this.getInputPlaceholder.bind(this); - this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); - this.addAttachment = this.addAttachment.bind(this); - this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); - this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); - this.updateNumberOfLines = this.updateNumberOfLines.bind(this); - this.comment = props.comment; - - // 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(); - - this.state = { - isFocused: this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible, - isFullComposerAvailable: props.isComposerFullSize, - textInputShouldClear: false, - isCommentEmpty: props.comment.length === 0, - isMenuVisible: false, - selection: { - start: props.comment.length, - end: props.comment.length, - }, - maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, - value: props.comment, - - // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions - conciergePlaceholderRandomIndex: _.random(this.props.translate('reportActionCompose.conciergePlaceholderOptions').length - (this.props.isSmallScreenWidth ? 4 : 1)), - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex: -1, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge: false, - composerHeight: 0, - hasExceededMaxCommentLength: false, - }; - } - - componentDidMount() { - document.addEventListener('keydown', this.keydownListener); - - // This callback is used in the contextMenuActions to manage giving focus back to the compose input. - // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component - ReportActionComposeFocusManager.onComposerFocus(() => { - if (!this.willBlurTextInputOnTapOutside || !this.props.isFocused) { - return; - } - - this.focus(false); - }); - - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; - this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { - if (!this.state.isFocused || this.comment.length === 0) { - return; - } - - this.updateComment('', true); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); - - this.setMaxLines(); - this.updateComment(this.comment); - } - - componentDidUpdate(prevProps) { - const sidebarOpened = !prevProps.isDrawerOpen && this.props.isDrawerOpen; - if (sidebarOpened) { - toggleReportActionComposeView(true); - } - - // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (this.willBlurTextInputOnTapOutside && this.props.isFocused - && prevProps.modal.isVisible && !this.props.modal.isVisible) { - this.focus(); - } - - if (this.props.isComposerFullSize !== prevProps.isComposerFullSize) { - this.setMaxLines(); - } - - // Value state does not have the same value as comment props when the comment gets changed from another tab. - // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevProps.comment !== this.props.comment && this.state.value !== this.props.comment; - - // 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 && !shouldSyncComment) { - return; - } - - this.updateComment(this.props.comment); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.keydownListener); - ReportActionComposeFocusManager.clear(); - - if (this.unsubscribeEscapeKey) { - this.unsubscribeEscapeKey(); - } - } - - onSelectionChange(e) { - this.setState({selection: e.nativeEvent.selection}); - this.calculateEmojiSuggestion(); - } - - /** - * Updates the Highlight state of the composer - * - * @param {Boolean} shouldHighlight - */ - setIsFocused(shouldHighlight) { - this.setState({isFocused: shouldHighlight}); - } - - setIsFullComposerAvailable(isFullComposerAvailable) { - this.setState({isFullComposerAvailable}); - } - - /** - * Updates the should clear state of the composer - * - * @param {Boolean} shouldClear - */ - setTextInputShouldClear(shouldClear) { - this.setState({textInputShouldClear: shouldClear}); - } - - /** - * Updates the visibility state of the menu - * - * @param {Boolean} isMenuVisible - */ - setMenuVisibility(isMenuVisible) { - this.setState({isMenuVisible}); - } - - /** - * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose - */ - setTextInputRef(el) { - ReportActionComposeFocusManager.composerRef.current = el; - this.textInput = el; - } - - /** - * Get the placeholder to display in the chat input. - * - * @return {String} - */ - getInputPlaceholder() { - if (ReportUtils.chatIncludesConcierge(this.props.report)) { - if (User.isBlockedFromConcierge(this.props.blockedFromConcierge)) { - return this.props.translate('reportActionCompose.blockedFromConcierge'); - } - - return this.props.translate('reportActionCompose.conciergePlaceholderOptions')[this.state.conciergePlaceholderRandomIndex]; - } - - return this.props.translate('reportActionCompose.writeSomething'); - } - - /** - * Returns the list of IOU Options - * - * @param {Array} reportParticipants - * @returns {Array} - */ - getMoneyRequestOptions(reportParticipants) { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: this.props.translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(this.props.reportID)), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: this.props.translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(this.props.reportID)), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: this.props.translate('iou.sendMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(this.props.reportID)), - }, - }; - return _.map(ReportUtils.getMoneyRequestOptions(this.props.report, reportParticipants, this.props.betas), option => options[option]); - } - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - * - * @param {Boolean} hasExceededMaxCommentLength - */ - setExceededMaxCommentLength(hasExceededMaxCommentLength) { - this.setState({hasExceededMaxCommentLength}); - } - - /** - * 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}); - } - - // eslint-disable-next-line rulesdir/prefer-early-return - setShouldShowSuggestionMenuToFalse() { - if (this.state && this.state.shouldShowSuggestionMenu) { - this.setState({shouldShowSuggestionMenu: false}); - } - } - - /** - * Clean data related to EmojiSuggestions - */ - resetSuggestedEmojis() { - this.setState({ - suggestedEmojis: [], - shouldShowSuggestionMenu: false, - }); - } - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - calculateEmojiSuggestion() { - const leftString = this.state.value.substring(0, this.state.selection.end); - const colonIndex = leftString.lastIndexOf(':'); - const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.selection.end); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = this.props.windowHeight / this.state.composerHeight >= 6.8; - const isEmojiPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - - const nextState = { - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge, - }; - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); - - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis); - } - - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - - this.setState(nextState); - } - - /** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ - isEmojiCode(str, pos) { - const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE); - const leftWord = _.last(leftWords); - - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; - } - - /** - * Replace the code of emoji and update selection - * @param {Number} highlightedEmojiIndex - */ - insertSelectedEmoji(highlightedEmojiIndex) { - const commentBeforeColon = this.state.value.slice(0, this.state.colonIndex); - const emojiObject = this.state.suggestedEmojis[highlightedEmojiIndex]; - const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - - this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); - this.setState(prevState => ({ - selection: { - start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }, - suggestedEmojis: [], - })); - EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject); - } - - isEmptyChat() { - return _.size(this.props.reportActions) === 1; - } - - keydownListener(e) { - if (this.state.isFocused) { return; } - - // if the key pressed is non-character keys like Enter, Shift, ... do not focus - if (e.key.length > 1) { return; } - - // if we're typing on another input/text area, do not focus - if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { return; } - - this.focus(); - this.replaceSelectionWithInput(e.key); - } - - /** - * @param {String} text - */ - replaceSelectionWithInput(text) { - const newComment = this.comment.slice(0, this.state.selection.start) - + text - + this.comment.slice(this.state.selection.end, this.comment.length); - this.setState(prevState => ({ - selection: { - start: prevState.selection.start + text.length, - end: prevState.selection.start + text.length, - }, - }), this.updateComment(newComment)); - } - - /** - * Callback for the emoji picker to add whatever emoji is chosen into the main input - * - * @param {String} emoji - */ - addEmojiToTextBox(emoji) { - const emojiWithSpace = `${emoji} `; - this.replaceSelectionWithInput(emojiWithSpace); - } - - /** - * Focus the composer text input - * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose - */ - focus(shouldelay = false) { - // There could be other animations running while we trigger manual focus. - // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!this.textInput) { - return; - } - - if (!shouldelay) { - this.textInput.focus(); - } else { - // Keyboard is not opened after Emoji Picker is closed - // SetTimeout is used as a workaround - // https://github.com/react-native-modal/react-native-modal/issues/114 - // We carefully choose a delay. 100ms is found enough for keyboard to open. - setTimeout(() => this.textInput.focus(), 100); - } - }); - } - - /** - * Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often - * to update Onyx and re-render this component. - * - * @param {String} comment - */ - debouncedSaveReportComment(comment) { - Report.saveReportComment(this.props.reportID, comment || ''); - } - - /** - * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish - * client events. - */ - debouncedBroadcastUserIsTyping() { - Report.broadcastUserIsTyping(this.props.reportID); - } - - /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment - */ - updateComment(comment, shouldDebounceSaveComment) { - const newComment = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); - this.setState((prevState) => { - const newState = { - isCommentEmpty: !!newComment.match(/^(\s)*$/), - value: newComment, - }; - if (comment !== newComment) { - const remainder = prevState.value.slice(prevState.selection.end).length; - newState.selection = { - start: newComment.length - remainder, - end: newComment.length - remainder, - }; - } - return newState; - }); - - // Indicate that draft has been created. - if (this.comment.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(this.props.reportID, true); - } - - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(this.props.reportID, false); - } - - this.comment = newComment; - if (shouldDebounceSaveComment) { - this.debouncedSaveReportComment(newComment); - } else { - Report.saveReportComment(this.props.reportID, newComment || ''); - } - if (newComment) { - this.debouncedBroadcastUserIsTyping(); - } - } - - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - updateNumberOfLines(numberOfLines) { - Report.saveReportCommentNumberOfLines(this.props.reportID, numberOfLines); - } - - /** - * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e - */ - triggerHotkeyActions(e) { - // Do not trigger actions for mobileWeb or native clients that have the keyboard open because for those devices, we want the return key to insert newlines rather than submit the form - if (!e || this.props.isSmallScreenWidth || this.props.isKeyboardShown) { - return; - } - - if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && this.state.suggestedEmojis.length) { - e.preventDefault(); - this.insertSelectedEmoji(this.state.highlightedEmojiIndex); - return; - } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && this.state.suggestedEmojis.length) { - e.preventDefault(); - this.resetSuggestedEmojis(); - return; - } - - // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); - this.submitForm(); - } - - // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants - if ( - e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && this.textInput.selectionStart === 0 && this.state.isCommentEmpty && !ReportUtils.chatIncludesChronos(this.props.report) - ) { - e.preventDefault(); - - const lastReportAction = _.find( - this.props.reportActions, - action => ReportUtils.canEditReportAction(action), - ); - - if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(this.props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); - } - } - } - - /** - * @returns {String} - */ - prepareCommentAndResetComposer() { - const trimmedComment = this.comment.trim(); - - // Don't submit empty comments or comments that exceed the character limit - if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - this.updateComment(''); - this.setTextInputShouldClear(true); - if (this.props.isComposerFullSize) { - Report.setIsComposerFullSize(this.props.reportID, false); - } - this.setState({isFullComposerAvailable: false}); - - return trimmedComment; - } - - /** - * @param {Object} file - */ - addAttachment(file) { - const comment = this.prepareCommentAndResetComposer(); - Report.addAttachment(this.props.reportID, file, comment); - this.setTextInputShouldClear(false); - } - - /** - * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] - */ - submitForm(e) { - if (e) { - e.preventDefault(); - } - - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - this.debouncedSaveReportComment.cancel(); - - const comment = this.prepareCommentAndResetComposer(); - if (!comment) { - return; - } - - this.props.onSubmit(comment); - } - - render() { - const reportParticipants = _.without(lodashGet(this.props.report, 'participants', []), this.props.currentUserPersonalDetails.login); - const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); - const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyEmails[0]]; - - 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; - const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); - const inputPlaceholder = this.getInputPlaceholder(); - const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); - const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; - - return ( - - {shouldShowReportRecipientLocalTime && } - - - {({displayFileInModal}) => ( - <> - - {({openPicker}) => ( - <> - - {this.props.isComposerFullSize && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, false); - }} - - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - - )} - {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, true); - }} - - // Keep focus on the composer when Expand button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - )} - - - this.actionButton = el} - onPress={(e) => { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - this.actionButton.blur(); - this.setMenuVisibility(true); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - - - this.setMenuVisibility(false)} - onItemSelected={() => this.setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...this.getMoneyRequestOptions(reportParticipants), - { - icon: Expensicons.Paperclip, - text: this.props.translate('reportActionCompose.addAttachment'), - onSelected: () => { - openPicker({ - onPicked: displayFileInModal, - }); - }, - }, - ]} - /> - - )} - - - { - this.setState({isDraggingOver: true}); - }} - onDragLeave={() => { - this.setState({isDraggingOver: false}); - }} - onDrop={(e) => { - e.preventDefault(); - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - this.setState({isDraggingOver: false}); - }} - disabled={this.props.disabled} - > - this.updateComment(comment, true)} - onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={this.state.maxLines} - onFocus={() => this.setIsFocused(true)} - onBlur={() => { - this.setIsFocused(false); - this.resetSuggestedEmojis(); - }} - onPasteFile={displayFileInModal} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={this.state.isFullComposerAvailable} - setIsFullComposerAvailable={this.setIsFullComposerAvailable} - isComposerFullSize={this.props.isComposerFullSize} - value={this.state.value} - numberOfLines={this.props.numberOfLines} - onNumberOfLinesChange={this.updateNumberOfLines} - onLayout={(e) => { - const composerHeight = e.nativeEvent.layout.height; - if (this.state.composerHeight === composerHeight) { - return; - } - this.setState({composerHeight}); - }} - onScroll={() => this.setShouldShowSuggestionMenuToFalse()} - /> - - - - )} - - {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( - this.focus(true)} - onEmojiSelected={this.addEmojiToTextBox} - /> - )} - e.preventDefault()} - > - - - - - - - - - {!this.props.isSmallScreenWidth && } - - - - {this.state.isDraggingOver && } - {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && ( - this.setState({highlightedEmojiIndex: index})} - > - this.setState({suggestedEmojis: []})} - highlightedEmojiIndex={this.state.highlightedEmojiIndex} - emojis={this.state.suggestedEmojis} - comment={this.state.value} - updateComment={newComment => this.setState({value: newComment})} - colonIndex={this.state.colonIndex} - prefix={this.state.value.slice(this.state.colonIndex + 1).split(' ')[0]} - onSelect={this.insertSelectedEmoji} - isComposerFullSize={this.props.isComposerFullSize} - preferredSkinToneIndex={this.props.preferredSkinTone} - isEmojiPickerLarge={this.state.isEmojiPickerLarge} - composerHeight={this.state.composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - - )} - - ); - } -} - -ReportActionCompose.propTypes = propTypes; -ReportActionCompose.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withDrawerState, - withNavigationFocus, - withLocalize, - withNetwork(), - withPersonalDetails(), - withCurrentUserPersonalDetails, - withKeyboardState, - withOnyx({ - betas: { - key: ONYXKEYS.BETAS, - }, - comment: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - }, - numberOfLines: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, - }, - modal: { - key: ONYXKEYS.MODAL, - }, - blockedFromConcierge: { - key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, - }, - frequentlyUsedEmojis: { - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - }), -)(ReportActionCompose); From 0c2f0487e7513bca8220514352bf50ed3237e372 Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 8 Apr 2023 01:24:47 +0700 Subject: [PATCH 05/14] fix: issue --- src/pages/home/report/ReportActionCompose/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 02327aed692f..507cb6e1ae0d 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -261,7 +261,7 @@ class ReportActionCompose extends React.Component { } componentWillUnmount() { - removeListenKeyDown(); + removeListenKeyDown(this.keydownListener); ReportActionComposeFocusManager.clear(); if (this.unsubscribeEscapeKey) { From 5d8e7950f72a0cc1bb43dfee65ce156b8f40fa5c Mon Sep 17 00:00:00 2001 From: tienifr <113963320+tienifr@users.noreply.github.com> Date: Mon, 10 Apr 2023 17:51:01 +0700 Subject: [PATCH 06/14] Update src/pages/home/report/ReportActionCompose/index.js Co-authored-by: Rushat Gabhane --- src/pages/home/report/ReportActionCompose/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 507cb6e1ae0d..8e53b4dbac6a 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -474,7 +474,9 @@ class ReportActionCompose extends React.Component { if (e.key.length > 1) { return; } // if we're typing on another input/text area, do not focus - if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { return; } + if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { + return; + } this.focus(); this.replaceSelectionWithInput(e.key); From 81e7870bf14eca4fd6df04b5e1256b0e84046d1f Mon Sep 17 00:00:00 2001 From: tienifr Date: Mon, 10 Apr 2023 18:01:09 +0700 Subject: [PATCH 07/14] fix: condense keydownAction to single file --- .../home/report/ReportActionCompose/index.js | 21 ++++++++++++------- .../ReportActionCompose/keyDownAction.js | 10 +++++++++ .../keyDownAction.native.js | 5 +++++ .../ReportActionCompose/listenKeyDown.js | 5 ----- .../listenKeyDown.native.js | 3 --- .../removeListenKeyDown.js | 5 ----- .../removeListenKeyDown.native.js | 3 --- 7 files changed, 28 insertions(+), 24 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/keyDownAction.js create mode 100644 src/pages/home/report/ReportActionCompose/keyDownAction.native.js delete mode 100644 src/pages/home/report/ReportActionCompose/listenKeyDown.js delete mode 100644 src/pages/home/report/ReportActionCompose/listenKeyDown.native.js delete mode 100644 src/pages/home/report/ReportActionCompose/removeListenKeyDown.js delete mode 100644 src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 8e53b4dbac6a..f7f7cab422b5 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -51,8 +51,7 @@ import EmojiSuggestions from '../../../../components/EmojiSuggestions'; import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; import ArrowKeyFocusManager from '../../../../components/ArrowKeyFocusManager'; import KeyboardShortcut from '../../../../libs/KeyboardShortcut'; -import listenKeyDown from './listenKeyDown'; -import removeListenKeyDown from './removeListenKeyDown'; +import KeyDownAction from './keyDownAction'; const propTypes = { /** Beta features list */ @@ -204,7 +203,7 @@ class ReportActionCompose extends React.Component { } componentDidMount() { - listenKeyDown(this.keydownListener); + KeyDownAction.listenKeyDown(this.keydownListener); // This callback is used in the contextMenuActions to manage giving focus back to the compose input. // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component @@ -261,7 +260,9 @@ class ReportActionCompose extends React.Component { } componentWillUnmount() { - removeListenKeyDown(this.keydownListener); + if (this.keydownListener) { + KeyDownAction.removeListenKeyDown(this.keydownListener); + } ReportActionComposeFocusManager.clear(); if (this.unsubscribeEscapeKey) { @@ -468,14 +469,18 @@ class ReportActionCompose extends React.Component { } keydownListener(e) { - if (this.state.isFocused) { return; } + if (this.state.isFocused) { + return; + } // if the key pressed is non-character keys like Enter, Shift, ... do not focus - if (e.key.length > 1) { return; } + if (e.key.length > 1) { + return; + } // if we're typing on another input/text area, do not focus - if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { - return; + if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') { + return; } this.focus(); diff --git a/src/pages/home/report/ReportActionCompose/keyDownAction.js b/src/pages/home/report/ReportActionCompose/keyDownAction.js new file mode 100644 index 000000000000..ecd82f79e51d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/keyDownAction.js @@ -0,0 +1,10 @@ +function listenKeyDown(callback) { + document.addEventListener('keydown', callback); +} + +function removeListenKeyDown(callback) { + document.removeEventListener('keydown', callback); +} + +export default {listenKeyDown, removeListenKeyDown}; + diff --git a/src/pages/home/report/ReportActionCompose/keyDownAction.native.js b/src/pages/home/report/ReportActionCompose/keyDownAction.native.js new file mode 100644 index 000000000000..38244160ecb2 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/keyDownAction.native.js @@ -0,0 +1,5 @@ +function listenKeyDown() {} +function removeListenKeyDown() {} + +export default {removeListenKeyDown, listenKeyDown}; + diff --git a/src/pages/home/report/ReportActionCompose/listenKeyDown.js b/src/pages/home/report/ReportActionCompose/listenKeyDown.js deleted file mode 100644 index 247dab277a58..000000000000 --- a/src/pages/home/report/ReportActionCompose/listenKeyDown.js +++ /dev/null @@ -1,5 +0,0 @@ -function listenKeyDown(callback) { - document.addEventListener('keydown', callback); -} - -export default listenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js b/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js deleted file mode 100644 index 2ea397b94f38..000000000000 --- a/src/pages/home/report/ReportActionCompose/listenKeyDown.native.js +++ /dev/null @@ -1,3 +0,0 @@ -function listenKeyDown() {} - -export default listenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js deleted file mode 100644 index 9ccb36b231bf..000000000000 --- a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.js +++ /dev/null @@ -1,5 +0,0 @@ -function removeListenKeyDown(callback) { - document.removeEventListener('keydown', callback); -} - -export default removeListenKeyDown; diff --git a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js b/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js deleted file mode 100644 index 80104b8bca24..000000000000 --- a/src/pages/home/report/ReportActionCompose/removeListenKeyDown.native.js +++ /dev/null @@ -1,3 +0,0 @@ -function removeListenKeyDown() {} - -export default removeListenKeyDown; From 74d52faaf28c8f5195f20955f8fc623a19eb6cf8 Mon Sep 17 00:00:00 2001 From: tienifr Date: Mon, 10 Apr 2023 23:11:32 +0700 Subject: [PATCH 08/14] fix: only listen when report screen is active --- src/pages/home/report/ReportActionCompose/index.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index f7f7cab422b5..4004905d6ea5 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -229,6 +229,15 @@ class ReportActionCompose extends React.Component { } componentDidUpdate(prevProps) { + if (this.props.modal.isVisible !== prevProps.modal.isVisible) { + if (this.props.modal.isVisible) { + if (this.keydownListener) { + KeyDownAction.removeListenKeyDown(this.keydownListener); + } + } else { + KeyDownAction.listenKeyDown(this.keydownListener); + } + } const sidebarOpened = !prevProps.isDrawerOpen && this.props.isDrawerOpen; if (sidebarOpened) { toggleReportActionComposeView(true); From ca06ffb72b477aeaac9c83370aca4c8c3205f0a2 Mon Sep 17 00:00:00 2001 From: tienifr Date: Mon, 10 Apr 2023 23:25:41 +0700 Subject: [PATCH 09/14] fix: only listen when report screen is active --- src/pages/home/report/ReportActionCompose/index.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 4004905d6ea5..c76c933e951e 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -229,15 +229,6 @@ class ReportActionCompose extends React.Component { } componentDidUpdate(prevProps) { - if (this.props.modal.isVisible !== prevProps.modal.isVisible) { - if (this.props.modal.isVisible) { - if (this.keydownListener) { - KeyDownAction.removeListenKeyDown(this.keydownListener); - } - } else { - KeyDownAction.listenKeyDown(this.keydownListener); - } - } const sidebarOpened = !prevProps.isDrawerOpen && this.props.isDrawerOpen; if (sidebarOpened) { toggleReportActionComposeView(true); @@ -482,6 +473,10 @@ class ReportActionCompose extends React.Component { return; } + if (this.props.modal.isVisible) { + return; + } + // if the key pressed is non-character keys like Enter, Shift, ... do not focus if (e.key.length > 1) { return; From d851b0781a292fd8f5f7ea1d1405c98f16750760 Mon Sep 17 00:00:00 2001 From: tienifr Date: Tue, 11 Apr 2023 10:16:56 +0700 Subject: [PATCH 10/14] fix: disable keydown on small screen --- src/pages/home/report/ReportActionCompose/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index c76c933e951e..7662022db8dc 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -477,6 +477,10 @@ class ReportActionCompose extends React.Component { return; } + if (this.props.isSmallScreenWidth) { + return; + } + // if the key pressed is non-character keys like Enter, Shift, ... do not focus if (e.key.length > 1) { return; From b8a87d763bf493853f9478e361570afadfde7772 Mon Sep 17 00:00:00 2001 From: tienifr Date: Tue, 11 Apr 2023 22:58:36 +0700 Subject: [PATCH 11/14] fix: do not focus if the emoji picker is active --- src/pages/home/report/ReportActionCompose/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index cd6c08d1a4a9..b9d308b6fa17 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -481,6 +481,10 @@ class ReportActionCompose extends React.Component { return; } + if (!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu) { + return; + } + // if the key pressed is non-character keys like Enter, Shift, ... do not focus if (e.key.length > 1) { return; From ef7386deeecd3bdf856c34b76d836550d48610d5 Mon Sep 17 00:00:00 2001 From: tienifr Date: Tue, 11 Apr 2023 23:11:56 +0700 Subject: [PATCH 12/14] fix: remove redundant --- src/pages/home/report/ReportActionCompose/index.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index b9d308b6fa17..cd6c08d1a4a9 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -481,10 +481,6 @@ class ReportActionCompose extends React.Component { return; } - if (!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu) { - return; - } - // if the key pressed is non-character keys like Enter, Shift, ... do not focus if (e.key.length > 1) { return; From 31c7aa03395e119129f60057ab42c521821a6961 Mon Sep 17 00:00:00 2001 From: tienifr Date: Tue, 11 Apr 2023 23:37:06 +0700 Subject: [PATCH 13/14] fix: donot focus composer if emoji picker is active --- src/components/EmojiPicker/EmojiPickerButton.js | 2 +- src/pages/home/report/ReportActionCompose/index.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 58958d1fd360..57b391007eae 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -36,7 +36,7 @@ const EmojiPickerButton = (props) => { StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed)), ])} disabled={props.isDisabled} - onPress={() => EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor)} + onPress={() => EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor, undefined, props.onWillShow)} nativeID={props.nativeID} > {({hovered, pressed}) => ( diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index cd6c08d1a4a9..7b72c472bc52 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -199,6 +199,7 @@ class ReportActionCompose extends React.Component { isEmojiPickerLarge: false, composerHeight: 0, hasExceededMaxCommentLength: false, + isEmojiPickerVisible: false, }; } @@ -477,6 +478,10 @@ class ReportActionCompose extends React.Component { return; } + if (this.state.isEmojiPickerVisible) { + return; + } + if (this.props.isSmallScreenWidth) { return; } @@ -902,8 +907,12 @@ class ReportActionCompose extends React.Component { {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( this.focus(true)} + onModalHide={() => { + this.focus(true); + this.setState({isEmojiPickerVisible: false}); + }} onEmojiSelected={this.addEmojiToTextBox} + onWillShow={() => this.setState({isEmojiPickerVisible: true})} /> )} Date: Wed, 12 Apr 2023 00:59:41 +0700 Subject: [PATCH 14/14] fix: clean code --- .../EmojiPicker/EmojiPickerMenu/index.js | 10 +++------- src/pages/home/report/ReportActionCompose/index.js | 14 +------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index ee0cb91890d8..cb0f71cd1d7c 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -520,13 +520,9 @@ class EmojiPickerMenu extends Component { style={[ styles.disabledText, styles.emojiPickerList, - styles.textLabel, - styles.colorMuted, - - // styles.dFlex, - - // styles.alignItemsCenter, - // styles.justifyContentCenter, + styles.dFlex, + styles.alignItemsCenter, + styles.justifyContentCenter, styles.flexGrow1, this.isMobileLandscape() && styles.emojiPickerListLandscape, ]} diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 7b72c472bc52..4067e7e41736 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -470,19 +470,7 @@ class ReportActionCompose extends React.Component { } keydownListener(e) { - if (this.state.isFocused) { - return; - } - - if (this.props.modal.isVisible) { - return; - } - - if (this.state.isEmojiPickerVisible) { - return; - } - - if (this.props.isSmallScreenWidth) { + if (this.state.isFocused || this.state.isEmojiPickerVisible || this.props.modal.isVisible || this.props.isSmallScreenWidth) { return; }