diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 43450ec69a16..2363c8d57420 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import {View, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native'; import {runOnJS} from 'react-native-reanimated'; @@ -21,7 +21,7 @@ import compose from '../../../libs/compose'; import PopoverMenu from '../../../components/PopoverMenu'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutside from '../../../libs/willBlurTextInputOnTapOutside'; +import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; import CONST from '../../../CONST'; import reportActionPropTypes from './reportActionPropTypes'; @@ -46,7 +46,6 @@ import reportPropTypes from '../../reportPropTypes'; import EmojiSuggestions from '../../../components/EmojiSuggestions'; import MentionSuggestions from '../../../components/MentionSuggestions'; import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; -import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as Welcome from '../../../libs/actions/Welcome'; @@ -55,12 +54,16 @@ import containerComposeStyles from '../../../styles/containerComposeStyles'; import * as Task from '../../../libs/actions/Task'; import * as Browser from '../../../libs/Browser'; import * as IOU from '../../../libs/actions/IOU'; +import useArrowKeyFocusManager from '../../../hooks/useArrowKeyFocusManager'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; +import usePrevious from '../../../hooks/usePrevious'; import * as KeyDownListener from '../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../components/withAnimatedRef'; import updatePropsPaperWorklet from '../../../libs/updatePropsPaperWorklet'; +const {RNTextInputReset} = NativeModules; + const propTypes = { /** Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), @@ -148,7 +151,17 @@ const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, }; -const {RNTextInputReset} = NativeModules; +const defaultSuggestionsValues = { + suggestedEmojis: [], + suggestedMentions: [], + colonIndex: -1, + atSignIndex: -1, + shouldShowEmojiSuggestionMenu: false, + shouldShowMentionSuggestionMenu: false, + mentionPrefix: '', + isAutoSuggestionPickerLarge: false, +}; + /** * Return the max available index for arrow manager. * @param {Number} numRows @@ -166,1133 +179,1140 @@ const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { return rowCount - 1; }; -class ReportActionCompose extends React.Component { - constructor(props) { - super(props); - this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); - this.calculateMentionSuggestion = _.debounce(this.calculateMentionSuggestion, 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 = focusWithDelay(this.textInput).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); - this.setTextInputRef = this.setTextInputRef.bind(this); - this.getInputPlaceholder = this.getInputPlaceholder.bind(this); - this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); - this.getTaskOption = this.getTaskOption.bind(this); - this.addAttachment = this.addAttachment.bind(this); - this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); - this.insertSelectedMention = this.insertSelectedMention.bind(this); - this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); - this.updateNumberOfLines = this.updateNumberOfLines.bind(this); - this.showPopoverMenu = this.showPopoverMenu.bind(this); - this.debouncedUpdateFrequentlyUsedEmojis = _.debounce(this.debouncedUpdateFrequentlyUsedEmojis.bind(this), 1000, false); - 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(); - - // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will - // prevent auto focus on existing chat for mobile device - this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - - this.shouldAutoFocus = !props.modal.isVisible && (this.shouldFocusInputOnScreenFocus || this.isEmptyChat()) && props.shouldShowComposeInput; - - // These variables are used to decide whether to block the suggestions list from showing to prevent flickering - this.shouldBlockEmojiCalc = false; - this.shouldBlockMentionCalc = false; - - // For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus - // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), - // 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, - textInputShouldClear: false, - isCommentEmpty: props.comment.length === 0, - isMenuVisible: false, - selection: { - start: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, - end: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, - }, - 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)), - composerHeight: 0, - hasExceededMaxCommentLength: false, - isAttachmentPreviewActive: false, - ...this.getDefaultSuggestionsValues(), - }; - - this.actionButtonRef = React.createRef(); - } - - componentDidMount() { - this.unsubscribeNavigationBlur = this.props.navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(this.focusComposerOnKeyPress)); - this.unsubscribeNavigationFocus = this.props.navigation.addListener('focus', () => { - KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress); - this.setUpComposeFocusManager(); - }); - KeyDownListener.addKeyDownPressListner(this.focusComposerOnKeyPress); - this.setUpComposeFocusManager(); - - this.updateComment(this.comment); - - // Shows Popover Menu on Workspace Chat at first sign-in - if (!this.props.disabled) { - Welcome.show({ - routes: lodashGet(this.props.navigation.getState(), 'routes', []), - showPopoverMenu: this.showPopoverMenu, - }); - } - - if (this.props.comment.length !== 0) { - Report.setReportWithDraft(this.props.reportID, true); - } - } - - componentDidUpdate(prevProps) { - // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && this.props.isFocused && (prevProps.modal.isVisible || !prevProps.isFocused)) { - this.focus(); - } - - // 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.preferredLocale === prevProps.preferredLocale && this.props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { - return; - } - - this.updateComment(this.props.comment); - } +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - componentWillUnmount() { - ReportActionComposeFocusManager.clear(); +// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will +// prevent auto focus on existing chat for mobile device +const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - KeyDownListener.removeKeyDownPressListner(this.focusComposerOnKeyPress); - this.unsubscribeNavigationBlur(); - this.unsubscribeNavigationFocus(); - } +/** + * Save draft report comment. Debounced to happen at most once per second. + * @param {String} reportID + * @param {String} comment + */ +const debouncedSaveReportComment = _.debounce((reportID, comment) => { + Report.saveReportComment(reportID, comment || ''); +}, 1000); - onSelectionChange(e) { - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - this.setState({selection: e.nativeEvent.selection}); - this.calculateEmojiSuggestion(); - this.calculateMentionSuggestion(); - } +/** + * Broadcast that the user is typing. Debounced to limit how often we publish client events. + * @param {String} reportID + */ +const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { + Report.broadcastUserIsTyping(reportID); +}, 100); - setUpComposeFocusManager() { - // 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; - } +/** + * Check if this piece of string looks like an emoji + * @param {String} str + * @param {Number} pos + * @returns {Boolean} + */ +const isEmojiCode = (str, pos) => { + const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const leftWord = _.last(leftWords); + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; +}; - this.focus(false); - }); - } - - getDefaultSuggestionsValues() { - return { - suggestedEmojis: [], - suggestedMentions: [], - highlightedEmojiIndex: 0, - highlightedMentionIndex: 0, - colonIndex: -1, - atSignIndex: -1, - shouldShowEmojiSuggestionMenu: false, - shouldShowMentionSuggestionMenu: false, - mentionPrefix: '', - isAutoSuggestionPickerLarge: false, - }; - } +/** + * Check if this piece of string looks like a mention + * @param {String} str + * @returns {Boolean} + */ +const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); +/** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ +const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); + +// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus +// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), +// so we need to ensure that it is only updated after focus. +const isMobileSafari = Browser.isMobileSafari(); + +function ReportActionCompose({ + animatedRef, + betas, + blockedFromConcierge, + comment, + currentUserPersonalDetails, + disabled, + isComposerFullSize, + isFocused: isFocusedProp, + isKeyboardShown, + isMediumScreenWidth, + isSmallScreenWidth, + modal, + navigation, + network, + numberOfLines, + onSubmit, + parentReportActions, + pendingAction, + personalDetails, + preferredLocale, + preferredSkinTone, + report, + reportActions, + reportID, + shouldShowComposeInput, + translate, + windowHeight, +}) { /** * Updates the Highlight state of the composer - * - * @param {Boolean} shouldHighlight */ - setIsFocused(shouldHighlight) { - this.setState({isFocused: shouldHighlight}); - } + const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && !modal.isVisible && !modal.willAlertModalBecomeVisible && shouldShowComposeInput); + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - setIsFullComposerAvailable(isFullComposerAvailable) { - this.setState({isFullComposerAvailable}); - } + const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); + + const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; + + // These variables are used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockEmojiCalc = useRef(false); + const shouldBlockMentionCalc = useRef(false); /** * Updates the should clear state of the composer - * - * @param {Boolean} shouldClear */ - setTextInputShouldClear(shouldClear) { - this.setState({textInputShouldClear: shouldClear}); - } + const [textInputShouldClear, setTextInputShouldClear] = useState(false); + const [isCommentEmpty, setIsCommentEmpty] = useState(comment.length === 0); /** * Updates the visibility state of the menu - * - * @param {Boolean} isMenuVisible */ - setMenuVisibility(isMenuVisible) { - this.setState({isMenuVisible}); - } + const [isMenuVisible, setMenuVisibility] = useState(false); + const [selection, setSelection] = useState({ + start: isMobileSafari && !shouldAutoFocus ? 0 : comment.length, + end: isMobileSafari && !shouldAutoFocus ? 0 : comment.length, + }); + const [value, setValue] = useState(comment); + + const [composerHeight, setComposerHeight] = useState(0); + const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + + // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + + const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; + const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowMentionSuggestionMenu; + + const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({ + isActive: isEmojiSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ + isActive: isMentionSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + const insertedEmojisRef = useRef([]); /** - * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose + * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis + * API is not called too often. */ - setTextInputRef(el) { - ReportActionComposeFocusManager.composerRef.current = el; - this.textInput = el; - if (_.isFunction(this.props.animatedRef)) { - this.props.animatedRef(el); - } - this.focus = focusWithDelay(this.textInput).bind(this); - } + const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => { + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); + insertedEmojisRef.current = []; + }, []); /** - * Get the placeholder to display in the chat input. - * - * @return {String} + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent */ - getInputPlaceholder() { - if (ReportUtils.chatIncludesConcierge(this.props.report)) { - if (User.isBlockedFromConcierge(this.props.blockedFromConcierge)) { - return this.props.translate('reportActionCompose.blockedFromConcierge'); + const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); + + const commentRef = useRef(comment); + const textInputRef = useRef(null); + const actionButtonRef = useRef(null); + + const reportParticipants = useMemo(() => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report]); + const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipants]); + + const shouldShowReportRecipientLocalTime = useMemo( + () => ReportUtils.canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize, + [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], + ); + + const isBlockedFromConcierge = useMemo(() => ReportUtils.chatIncludesConcierge(report) && User.isBlockedFromConcierge(blockedFromConcierge), [report, blockedFromConcierge]); + + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + const conciergePlaceholderRandomIndex = useMemo( + () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // Placeholder to display in the chat input. + const inputPlaceholder = useMemo(() => { + if (ReportUtils.chatIncludesConcierge(report)) { + if (User.isBlockedFromConcierge(blockedFromConcierge)) { + return translate('reportActionCompose.blockedFromConcierge'); } - return this.props.translate('reportActionCompose.conciergePlaceholderOptions')[this.state.conciergePlaceholderRandomIndex]; + return translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; } - return this.props.translate('reportActionCompose.writeSomething'); - } + return translate('reportActionCompose.writeSomething'); + }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); /** - * Returns the list of IOU Options - * - * @param {Array} reportParticipants - * @returns {Array} + * Focus the composer text input + * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer + * @memberof ReportActionCompose */ - getMoneyRequestOptions(reportParticipants) { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: this.props.translate('iou.splitBill'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: this.props.translate('iou.requestMoney'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: this.props.translate('iou.sendMoney'), - }, - }; - return _.map(ReportUtils.getMoneyRequestOptions(this.props.report, reportParticipants, this.props.betas), (option) => ({ - ...options[option], - onSelected: () => IOU.startMoneyRequest(option, this.props.report.reportID), - })); - } + const focus = useCallback((shouldDelay) => { + focusWithDelay(textInputRef.current)(shouldDelay); + }, []); /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent + * Update the value of the comment in Onyx * - * @param {Boolean} hasExceededMaxCommentLength + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment */ - setExceededMaxCommentLength(hasExceededMaxCommentLength) { - this.setState({hasExceededMaxCommentLength}); - } + const updateComment = useCallback( + (commentValue, shouldDebounceSaveComment) => { + const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale); - // eslint-disable-next-line rulesdir/prefer-early-return - setShouldShowSuggestionMenuToFalse() { - if (this.state && this.state.shouldShowEmojiSuggestionMenu) { - this.setState({shouldShowEmojiSuggestionMenu: false}); - } - if (this.state && this.state.shouldShowMentionSuggestionMenu) { - this.setState({shouldShowMentionSuggestionMenu: false}); - } - } + if (!_.isEmpty(emojis)) { + insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; + debouncedUpdateFrequentlyUsedEmojis(); + } + + setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + setValue(newComment); + if (commentValue !== newComment) { + const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment); + setSelection({ + start: newComment.length - remainder, + end: newComment.length - remainder, + }); + } + + // Indicate that draft has been created. + if (commentRef.current.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(reportID, true); + } + + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(reportID, false); + } + + commentRef.current = newComment; + if (shouldDebounceSaveComment) { + debouncedSaveReportComment(reportID, newComment); + } else { + Report.saveReportComment(reportID, newComment || ''); + } + if (newComment) { + debouncedBroadcastUserIsTyping(reportID); + } + }, + [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID], + ); /** - * Determines if we can show the task option - * @param {Array} reportParticipants + * Used to show Popover menu on Workspace chat at first sign-in * @returns {Boolean} */ - getTaskOption() { - // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email - if (!Permissions.canUseTasks(this.props.betas) || ReportUtils.isExpensifyOnlyParticipantInReport(this.props.report)) { - return []; - } + const showPopoverMenu = useCallback(() => { + setMenuVisibility(true); + return true; + }, []); - return [ - { - icon: Expensicons.Task, - text: this.props.translate('newTaskPage.assignTask'), - onSelected: () => Task.clearOutTaskInfoAndNavigate(this.props.reportID), - }, - ]; - } + /** + * 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 + */ + const replaceSelectionWithText = useCallback( + (text, shouldAddTrailSpace = true) => { + const updatedText = shouldAddTrailSpace ? `${text} ` : text; + const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; + updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); + setSelection((prevSelection) => ({ + start: prevSelection.start + text.length + selectionSpaceLength, + end: prevSelection.start + text.length + selectionSpaceLength, + })); + }, + [selection, updateComment], + ); /** - * Build the suggestions for mentions - * @param {Object} personalDetails - * @param {String} [searchValue] - * @returns {Object} + * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. + * @returns {Boolean} */ - getMentionOptions(personalDetails, searchValue = '') { - const suggestions = []; - - if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { - suggestions.push({ - text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, - alternateText: this.props.translate('mentionSuggestions.hereAlternateText'), - icons: [ - { - source: Expensicons.Megaphone, - type: 'avatar', - }, - ], - }); - } + const checkComposerVisibility = useCallback(() => { + const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible; + return !isComposerCoveredUp; + }, [isMenuVisible, modal.isVisible]); - const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => { - // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail.login) { - return false; + const focusComposerOnKeyPress = useCallback( + (e) => { + const isComposerVisible = checkComposerVisibility(); + if (!isComposerVisible) { + return; } - if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { - return false; + + // If the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { + return; } - return true; - }); - const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); - _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { - suggestions.push({ - text: detail.displayName, - alternateText: detail.login, - icons: [ - { - name: detail.login, - source: UserUtils.getAvatar(detail.avatar, detail.accountID), - type: 'avatar', - }, - ], - }); - }); + // If a key is pressed in combination with Meta, Control or Alt do not focus + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + // If the space key is pressed, do not focus + if (e.code === 'Space') { + return; + } + + // if we're typing on another input/text area, do not focus + if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { + return; + } - return suggestions; - } + focus(); + replaceSelectionWithText(e.key, false); + }, + [checkComposerVisibility, focus, replaceSelectionWithText], + ); /** - * Clean data related to EmojiSuggestions and MentionSuggestions + * Clean data related to EmojiSuggestions */ - resetSuggestions() { - this.setState({ - ...this.getDefaultSuggestionsValues(), - }); - } + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); /** * Calculates and cares about the content of an Emoji Suggester */ - calculateEmojiSuggestion() { - if (this.shouldBlockEmojiCalc || !this.state.value) { - this.shouldBlockEmojiCalc = false; - this.resetSuggestions(); - return; - } - - 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 isAutoSuggestionPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockEmojiCalc.current || !value) { + shouldBlockEmojiCalc.current = false; + resetSuggestions(); + return; + } + const leftString = value.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; + const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + + const nextState = { + suggestedEmojis: [], + colonIndex, + shouldShowEmojiSuggestionMenu: false, + isAutoSuggestionPickerLarge, + }; + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); - const nextState = { - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex, - shouldShowEmojiSuggestionMenu: false, - isAutoSuggestionPickerLarge, - }; - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, this.props.preferredLocale); + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + } - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); - } + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedEmojiIndex(0); + }, + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], + ); + + const getMentionOptions = useCallback( + (searchValue = '') => { + const suggestions = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: Expensicons.Megaphone, + type: 'avatar', + }, + ], + }); + } - this.setState(nextState); - } + const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => { + // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned + if (!detail.login) { + return false; + } + if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { + return false; + } + return true; + }); - calculateMentionSuggestion() { - if (this.shouldBlockMentionCalc || this.state.selection.end < 1) { - this.shouldBlockMentionCalc = false; - this.resetSuggestions(); - return; - } + const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); + _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { + suggestions.push({ + text: detail.displayName, + alternateText: detail.login, + icons: [ + { + name: detail.login, + source: UserUtils.getAvatar(detail.avatar, detail.accountID), + type: 'avatar', + }, + ], + }); + }); - const valueAfterTheCursor = this.state.value.substring(this.state.selection.end); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + return suggestions; + }, + [personalDetails, translate], + ); + + const calculateMentionSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockMentionCalc.current || selection.end < 1) { + shouldBlockMentionCalc.current = false; + resetSuggestions(); + return; + } - let indexOfLastNonWhitespaceCharAfterTheCursor; - if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { - // we didn't find a whitespace/emoji after the cursor, so we will use the entire string - indexOfLastNonWhitespaceCharAfterTheCursor = this.state.value.length; - } else { - indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + this.state.selection.end; - } + const valueAfterTheCursor = value.substring(selectionEnd); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - const leftString = this.state.value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const lastWord = _.last(words); + let indexOfLastNonWhitespaceCharAfterTheCursor; + if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { + // we didn't find a whitespace/emoji after the cursor, so we will use the entire string + indexOfLastNonWhitespaceCharAfterTheCursor = value.length; + } else { + indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd; + } - let atSignIndex; - if (lastWord.startsWith('@')) { - atSignIndex = leftString.lastIndexOf(lastWord); - } + const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const lastWord = _.last(words); - const prefix = lastWord.substring(1); + let atSignIndex; + if (lastWord.startsWith('@')) { + atSignIndex = leftString.lastIndexOf(lastWord); + } - const nextState = { - suggestedMentions: [], - highlightedMentionIndex: 0, - atSignIndex, - mentionPrefix: prefix, - }; + const prefix = lastWord.substring(1); - const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); + const nextState = { + suggestedMentions: [], + atSignIndex, + mentionPrefix: prefix, + }; - if (!isCursorBeforeTheMention && this.isMentionCode(lastWord)) { - const suggestions = this.getMentionOptions(this.props.personalDetails, prefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); - } + const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); - this.setState(nextState); - } + if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { + const suggestions = getMentionOptions(prefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); + } - /** - * 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.SPECIAL_CHAR_OR_EMOJI); - const leftWord = _.last(leftWords); + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + setHighlightedMentionIndex(0); + }, + [getMentionOptions, setHighlightedMentionIndex, value, selection, resetSuggestions], + ); + + const onSelectionChange = useCallback( + (e) => { + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + setSelection(e.nativeEvent.selection); + + /** + * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion + * because in other case calculateEmojiSuggestion will have an old calculation value + * of suggestion instead of current one + */ + calculateEmojiSuggestion(e.nativeEvent.selection.end); + calculateMentionSuggestion(e.nativeEvent.selection.end); + }, + [calculateEmojiSuggestion, calculateMentionSuggestion], + ); - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; - } + const setUpComposeFocusManager = useCallback(() => { + // 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 (!willBlurTextInputOnTapOutside || !isFocusedProp) { + return; + } - /** - * Check if this piece of string looks like a mention - * @param {String} str - * @returns {Boolean} - */ - isMentionCode(str) { - return CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); - } + focus(false); + }); + }, [focus, isFocusedProp]); /** - * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} + * Set the TextInput Ref + * + * @param {Element} el + * @memberof ReportActionCompose */ - trimLeadingSpace(str) { - return str.slice(0, 1) === ' ' ? str.slice(1) : str; - } + const setTextInputRef = useCallback( + (el) => { + ReportActionComposeFocusManager.composerRef.current = el; + textInputRef.current = el; + if (_.isFunction(animatedRef)) { + animatedRef(el); + } + }, + [animatedRef], + ); /** - * Replace the code of emoji and update selection - * @param {Number} highlightedEmojiIndex + * Returns the list of IOU Options + * @returns {Array} */ - 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); - - this.updateComment(`${commentBeforeColon}${emojiCode} ${this.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - // In some Android phones keyboard, the text to search for the emoji is not cleared - // will be added after the user starts typing again on the keyboard. This package is - // a workaround to reset the keyboard natively. - if (RNTextInputReset) { - RNTextInputReset.resetKeyboardInput(findNodeHandle(this.textInput)); - } - this.setState((prevState) => ({ - selection: { - start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + const moneyRequestOptions = useMemo(() => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: translate('iou.splitBill'), }, - suggestedEmojis: [], - })); - this.insertedEmojis = [...this.insertedEmojis, emojiObject]; - this.debouncedUpdateFrequentlyUsedEmojis(emojiObject); - } - - /** - * Replace the code of mention and update selection - * @param {Number} highlightedMentionIndex - */ - insertSelectedMention(highlightedMentionIndex) { - const commentBeforeAtSign = this.state.value.slice(0, this.state.atSignIndex); - const mentionObject = this.state.suggestedMentions[highlightedMentionIndex]; - const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; - const commentAfterAtSignWithMentionRemoved = this.state.value.slice(this.state.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - - this.updateComment(`${commentBeforeAtSign}${mentionCode} ${this.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); - this.setState((prevState) => ({ - selection: { - start: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, - end: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: translate('iou.requestMoney'), }, - suggestedMentions: [], - })); - } - - isEmptyChat() { - return _.size(this.props.reportActions) === 1; - } - - /** - * 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 - */ - 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 + text.length + selectionSpaceLength, - end: prevState.selection.start + text.length + selectionSpaceLength, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: translate('iou.sendMoney'), }, + }; + + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ + ...options[option], + onSelected: () => IOU.startMoneyRequest(option, report.reportID), })); - } + }, [betas, report, reportParticipants, translate]); + + // eslint-disable-next-line rulesdir/prefer-early-return + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (suggestionValues.shouldShowEmojiSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); + } + if (suggestionValues.shouldShowMentionSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowMentionSuggestionMenu: false})); + } + }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]); /** - * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. + * Determines if we can show the task option * @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; + const taskOption = useMemo(() => { + // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email + if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + return []; } - // If a key is pressed in combination with Meta, Control or Alt do not focus - if (e.metaKey || e.ctrlKey || e.altKey) { - return; - } + return [ + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), + }, + ]; + }, [betas, report, reportID, translate]); - // If the space key is pressed, do not focus - if (e.code === 'Space') { - return; - } + /** + * Replace the code of emoji and update selection + * @param {Number} selectedEmoji + */ + const insertSelectedEmoji = useCallback( + (highlightedEmojiIndexInner) => { + const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); + const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner]; + const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); + + updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + if (RNTextInputReset) { + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); + } - // if we're typing on another input/text area, do not focus - if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { - return; - } + setSelection({ + start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - this.focus(); - this.replaceSelectionWithText(e.key, false); - } + insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; + debouncedUpdateFrequentlyUsedEmojis(emojiObject); + }, + [debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, selection.end, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + ); /** - * 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 + * Replace the code of mention and update selection + * @param {Number} highlightedMentionIndex */ - debouncedSaveReportComment(comment) { - Report.saveReportComment(this.props.reportID, comment || ''); - } + const insertSelectedMention = useCallback( + (highlightedMentionIndexInner) => { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); + const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; + const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; + const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); + + updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); + setSelection({ + start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + })); + }, + [suggestionValues, value, updateComment], + ); /** - * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish - * client events. + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines */ - debouncedBroadcastUserIsTyping() { - Report.broadcastUserIsTyping(this.props.reportID); - } + const updateNumberOfLines = useCallback( + (newNumberOfLines) => { + Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + }, + [reportID], + ); /** - * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis - * API is not called too often. + * @returns {String} */ - debouncedUpdateFrequentlyUsedEmojis() { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(this.insertedEmojis)); - this.insertedEmojis = []; - } + const prepareCommentAndResetComposer = useCallback(() => { + const trimmedComment = commentRef.current.trim(); + const commentLength = ReportUtils.getCommentLength(trimmedComment); - /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment - */ - updateComment(comment, shouldDebounceSaveComment) { - const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(comment, this.props.preferredSkinTone, this.props.preferredLocale); + // Don't submit empty comments or comments that exceed the character limit + if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { + return ''; + } - if (!_.isEmpty(emojis)) { - this.insertedEmojis = [...this.insertedEmojis, ...emojis]; - this.debouncedUpdateFrequentlyUsedEmojis(); + updateComment(''); + setTextInputShouldClear(true); + if (isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); } + setIsFullComposerAvailable(false); + return trimmedComment; + }, [reportID, updateComment, isComposerFullSize]); - this.setState(() => { - const newState = { - isCommentEmpty: !!newComment.match(/^(\s)*$/), - value: newComment, - }; - if (comment !== newComment) { - const remainder = ComposerUtils.getCommonSuffixLength(comment, newComment); - newState.selection = { - start: newComment.length - remainder, - end: newComment.length - remainder, - }; + /** + * Add a new comment to this chat + * + * @param {SyntheticEvent} [e] + */ + const submitForm = useCallback( + (e) => { + if (e) { + e.preventDefault(); } - return newState; - }); - // Indicate that draft has been created. - if (this.comment.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(this.props.reportID, true); - } + // 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 + debouncedSaveReportComment.cancel(); - // 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(); - } - } + const newComment = prepareCommentAndResetComposer(); + if (!newComment) { + return; + } - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - updateNumberOfLines(numberOfLines) { - Report.saveReportCommentNumberOfLines(this.props.reportID, numberOfLines); - } + onSubmit(newComment); + }, + [onSubmit, prepareCommentAndResetComposer], + ); /** * Listens for keyboard shortcuts and applies the action * * @param {Object} e */ - triggerHotkeyActions(e) { - if (!e || ComposerUtils.canSkipTriggerHotkeys(this.props.isSmallScreenWidth, this.props.isKeyboardShown)) { - return; - } + const triggerHotkeyActions = useCallback( + (e) => { + if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { + return; + } - const suggestionsExist = this.state.suggestedEmojis.length > 0 || this.state.suggestedMentions.length > 0; + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); - if (this.state.suggestedEmojis.length > 0) { - this.insertSelectedEmoji(this.state.highlightedEmojiIndex); - } - if (this.state.suggestedMentions.length > 0) { - this.insertSelectedMention(this.state.highlightedMentionIndex); + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedEmojis.length > 0) { + insertSelectedEmoji(highlightedEmojiIndex); + } + if (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + } + return; } - return; - } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); - if (suggestionsExist) { - this.resetSuggestions(); - } - return; - } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); - // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); - this.submitForm(); - } + if (suggestionsExist) { + resetSuggestions(); + } - // 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.value.length === 0 && - !ReportUtils.chatIncludesChronos(this.props.report) - ) { - e.preventDefault(); - - const parentReportActionID = lodashGet(this.props.report, 'parentReportActionID', ''); - const parentReportAction = lodashGet(this.props.parentReportActions, [parentReportActionID], {}); - const lastReportAction = _.find([...this.props.reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action)); - - if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(this.props.reportID, lastReportAction, _.last(lastReportAction.message).html); + return; } - } - } - /** - * @returns {String} - */ - prepareCommentAndResetComposer() { - const trimmedComment = this.comment.trim(); - const commentLength = ReportUtils.getCommentLength(trimmedComment); - // Don't submit empty comments or comments that exceed the character limit - if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { - return ''; - } + // Submit the form when Enter is pressed + if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { + e.preventDefault(); + submitForm(); + } - this.updateComment(''); - this.setTextInputShouldClear(true); - if (this.props.isComposerFullSize) { - Report.setIsComposerFullSize(this.props.reportID, false); - } - this.setState({isFullComposerAvailable: false}); + // 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 && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) { + e.preventDefault(); - return trimmedComment; - } + const parentReportActionID = lodashGet(report, 'parentReportActionID', ''); + const parentReportAction = lodashGet(parentReportActions, [parentReportActionID], {}); + const lastReportAction = _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action)); + + if (lastReportAction !== -1 && lastReportAction) { + Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); + } + } + }, + [ + highlightedEmojiIndex, + highlightedMentionIndex, + insertSelectedEmoji, + insertSelectedMention, + isKeyboardShown, + isSmallScreenWidth, + parentReportActions, + report, + reportActions, + reportID, + resetSuggestions, + submitForm, + suggestionValues.suggestedEmojis.length, + suggestionValues.suggestedMentions.length, + value.length, + ], + ); /** * @param {Object} file */ - addAttachment(file) { - // 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(); - Report.addAttachment(this.props.reportID, file, comment); - this.setTextInputShouldClear(false); - } + const addAttachment = useCallback( + (file) => { + // 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 + debouncedSaveReportComment.cancel(); + const newComment = prepareCommentAndResetComposer(); + Report.addAttachment(reportID, file, newComment); + setTextInputShouldClear(false); + }, + [reportID, prepareCommentAndResetComposer], + ); /** - * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] + * Event handler to update the state after the attachment preview is closed. */ - submitForm(e) { - if (e) { - e.preventDefault(); + const onAttachmentPreviewClose = useCallback(() => { + shouldBlockEmojiCalc.current = false; + shouldBlockMentionCalc.current = false; + setIsAttachmentPreviewActive(false); + }, []); + + useEffect(() => { + const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); + const unsubscribeNavigationFocus = navigation.addListener('focus', () => { + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + setUpComposeFocusManager(); + }); + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + + setUpComposeFocusManager(); + + updateComment(commentRef.current); + + // Shows Popover Menu on Workspace Chat at first sign-in + if (!disabled) { + Welcome.show({ + routes: lodashGet(navigation.getState(), 'routes', []), + showPopoverMenu, + }); } - // 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(); + if (comment.length !== 0) { + Report.setReportWithDraft(reportID, true); + } + + return () => { + ReportActionComposeFocusManager.clear(); + + KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); + unsubscribeNavigationBlur(); + unsubscribeNavigationFocus(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const comment = this.prepareCommentAndResetComposer(); - if (!comment) { + const prevIsModalVisible = usePrevious(modal.isVisible); + const prevIsFocused = usePrevious(isFocusedProp); + useEffect(() => { + // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { return; } - this.props.onSubmit(comment); - } + focus(); + }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - showPopoverMenu() { - this.setMenuVisibility(true); - return true; - } - - render() { - const reportParticipants = _.without(lodashGet(this.props.report, 'participantAccountIDs', []), this.props.currentUserPersonalDetails.accountID); - const participantsWithoutExpensifyAccountIDs = _.difference(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS); - const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyAccountIDs[0]]; - const shouldShowReportRecipientLocalTime = - ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report, this.props.currentUserPersonalDetails.accountID) && !this.props.isComposerFullSize; - - // Prevents focusing and showing the keyboard while the drawer is covering the chat. - 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; - const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; - const isFullComposerAvailable = this.state.isFullComposerAvailable && !_.isEmpty(this.state.value); - const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); - const maxComposerLines = this.props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const submit = this.submitForm; - const animatedRef = this.props.animatedRef; - const setCommentEmpty = () => this.setState({isCommentEmpty: true}); - const Tap = Gesture.Tap() - .enabled(!(this.state.isCommentEmpty || isBlockedFromConcierge || this.props.disabled || hasExceededMaxCommentLength)) - .onEnd(() => { - 'worklet'; - - const viewTag = animatedRef(); - const viewName = 'RCTMultilineTextInputView'; - const updates = {text: ''}; - // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setCommentEmpty)(); - updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread - runOnJS(submit)(); - }); + const prevCommentProp = usePrevious(comment); + const prevPreferredLocale = usePrevious(preferredLocale); + const prevReportId = usePrevious(report.reportID); + useEffect(() => { + // 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 = prevCommentProp !== comment && value !== 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 (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { + return; + } + + updateComment(comment); + }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value]); + + const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; + const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; + const isFullSizeComposerAvailable = isFullComposerAvailable && !_.isEmpty(value); + const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); + const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + + const Tap = Gesture.Tap() + .enabled(!(isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength)) + .onEnd(() => { + 'worklet'; + + const viewTag = animatedRef(); + const viewName = 'RCTMultilineTextInputView'; + const updates = {text: ''}; + // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state + runOnJS(setIsCommentEmpty)(true); + updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread + runOnJS(submitForm)(); + }); - return ( - + - } + - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} > - this.setState({isAttachmentPreviewActive: true})} - onModalHide={() => { - this.shouldBlockEmojiCalc = false; - this.shouldBlockMentionCalc = false; - this.setState({isAttachmentPreviewActive: false}); - }} - > - {({displayFileInModal}) => ( - <> - - {({openPicker}) => { - const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (this.willBlurTextInputOnTapOutside) { - this.shouldBlockEmojiCalc = true; - this.shouldBlockMentionCalc = true; - } - openPicker({ - onPicked: displayFileInModal, - }); - }; - const menuItems = [ - ...this.getMoneyRequestOptions(reportParticipants), - ...this.getTaskOption(), - { - icon: Expensicons.Paperclip, - text: this.props.translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, + {({displayFileInModal}) => ( + <> + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + shouldBlockEmojiCalc.current = true; + shouldBlockMentionCalc.current = true; + } + openPicker({ + onPicked: displayFileInModal, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); }, - ]; - return ( - <> - - {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} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={this.props.translate('reportActionCompose.collapse')} - > - - - - )} - {!this.props.isComposerFullSize && 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} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={this.props.translate('reportActionCompose.expand')} - > - - - - )} - + }, + ]; + return ( + <> + + {isComposerFullSize && ( + { e.preventDefault(); - - // Drop focus to avoid blue focus ring. - this.actionButtonRef.current.blur(); - this.setMenuVisibility(!this.state.isMenuVisible); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + > + + + + )} + {!isComposerFullSize && isFullSizeComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, true); }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} + disabled={isBlockedFromConcierge || disabled} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={this.props.translate('reportActionCompose.addAction')} + accessibilityLabel={translate('reportActionCompose.expand')} > - + - - this.setMenuVisibility(false)} - onItemSelected={(item, index) => { - this.setMenuVisibility(false); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1 && Browser.isSafari()) { - triggerAttachmentPicker(); - } - }} - anchorPosition={styles.createMenuPositionReportActionCompose(this.props.windowHeight)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} - menuItems={menuItems} - withoutOverlay - anchorRef={this.actionButtonRef} - /> - - ); + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButtonRef.current.blur(); + setMenuVisibility(!isMenuVisible); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.addAction')} + > + + + + + setMenuVisibility(false)} + onItemSelected={(item, index) => { + setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1 && Browser.isSafari()) { + triggerAttachmentPicker(); + } + }} + anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + menuItems={menuItems} + withoutOverlay + anchorRef={actionButtonRef} + /> + + ); + }} + + + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxComposerLines} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + resetSuggestions(); }} - - - this.checkComposerVisibility()} - autoFocus={this.shouldAutoFocus} - multiline - ref={this.setTextInputRef} - textAlignVertical="top" - placeholder={inputPlaceholder} - placeholderTextColor={themeColors.placeholderText} - onChangeText={(comment) => this.updateComment(comment, true)} - onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={maxComposerLines} - onFocus={() => this.setIsFocused(true)} - onBlur={() => { - this.setIsFocused(false); - this.resetSuggestions(); - }} - onClick={() => { - this.shouldBlockEmojiCalc = false; - this.shouldBlockMentionCalc = false; - }} - onPasteFile={displayFileInModal} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isBlockedFromConcierge || this.props.disabled} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={isFullComposerAvailable} - setIsFullComposerAvailable={this.setIsFullComposerAvailable} - isComposerFullSize={this.props.isComposerFullSize} - value={this.state.value} - numberOfLines={this.props.numberOfLines} - onNumberOfLinesChange={this.updateNumberOfLines} - shouldCalculateCaretPosition - onLayout={(e) => { - const composerHeight = e.nativeEvent.layout.height; - if (this.state.composerHeight === composerHeight) { - return; - } - this.setState({composerHeight}); - }} - onScroll={() => this.setShouldShowSuggestionMenuToFalse()} - /> - - { - if (this.state.isAttachmentPreviewActive) { + onClick={() => { + shouldBlockEmojiCalc.current = false; + shouldBlockMentionCalc.current = false; + }} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isBlockedFromConcierge || disabled} + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullSizeComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + value={value} + numberOfLines={numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + shouldCalculateCaretPosition + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { return; } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); + setComposerHeight(composerLayoutHeight); }} + onScroll={() => updateShouldShowSuggestionMenuToFalse()} /> - - )} - - {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( - { - this.focus(true); - }} - onEmojiSelected={this.replaceSelectionWithText} - /> + + { + if (isAttachmentPreviewActive) { + return; + } + const data = lodashGet(e, ['dataTransfer', 'items', 0]); + displayFileInModal(data); + }} + /> + )} - e.preventDefault()} - > - - - [ - styles.chatItemSubmitButton, - this.state.isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess, - (this.state.isCommentEmpty || isBlockedFromConcierge || this.props.disabled || hasExceededMaxCommentLength) && styles.cursorDisabled, - ]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={this.props.translate('common.send')} - > - {({pressed}) => ( - - )} - - - - - + + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( + focus(true)} + onEmojiSelected={replaceSelectionWithText} + /> + )} e.preventDefault()} > - {!this.props.isSmallScreenWidth && } - - + + + [ + styles.chatItemSubmitButton, + isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess, + isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength ? styles.cursorDisabled : undefined, + ]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + + - - {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowEmojiSuggestionMenu && ( - 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, this.state.selection.start)} - onSelect={this.insertSelectedEmoji} - isComposerFullSize={this.props.isComposerFullSize} - preferredSkinToneIndex={this.props.preferredSkinTone} - isEmojiPickerLarge={this.state.isAutoSuggestionPickerLarge} - composerHeight={this.state.composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - - )} - {!_.isEmpty(this.state.suggestedMentions) && this.state.shouldShowMentionSuggestionMenu && ( - this.setState({highlightedMentionIndex: index})} - > - this.setState({suggestedMentions: []})} - highlightedMentionIndex={this.state.highlightedMentionIndex} - mentions={this.state.suggestedMentions} - comment={this.state.value} - updateComment={(newComment) => this.setState({value: newComment})} - colonIndex={this.state.colonIndex} - prefix={this.state.mentionPrefix} - onSelect={this.insertSelectedMention} - isComposerFullSize={this.props.isComposerFullSize} - isMentionPickerLarge={this.state.isAutoSuggestionPickerLarge} - composerHeight={this.state.composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - - )} - - ); - } + + + {!isSmallScreenWidth && } + + + + + {isEmojiSuggestionsMenuVisible && ( + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestionValues.suggestedEmojis} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={isComposerFullSize} + preferredSkinToneIndex={preferredSkinTone} + isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + )} + {isMentionSuggestionsMenuVisible && ( + setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))} + highlightedMentionIndex={highlightedMentionIndex} + mentions={suggestionValues.suggestedMentions} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={suggestionValues.mentionPrefix} + onSelect={insertSelectedMention} + isComposerFullSize={isComposerFullSize} + isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + )} + + ); } ReportActionCompose.propTypes = propTypes;