diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index ad3e2babb1c..16040991a3d 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import refPropType from '../refPropTypes'; const propTypes = { /** Array of suggestions */ @@ -29,16 +28,12 @@ const propTypes = { /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - parentContainerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; const defaultProps = { - parentContainerRef: { - current: null, - }, + measureParentContainer: () => {}, }; export {propTypes, defaultProps}; diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index cfde3853784..b06b0cc63eb 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -8,7 +8,6 @@ import * as EmojiUtils from '../libs/EmojiUtils'; import Text from './Text'; import getStyledTextArray from '../libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; -import refPropType from './refPropTypes'; const propTypes = { /** The index of the highlighted emoji */ @@ -47,13 +46,14 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - containerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; -const defaultProps = {highlightedEmojiIndex: 0, containerRef: {current: null}}; +const defaultProps = { + highlightedEmojiIndex: 0, + measureParentContainer: () => {}, +}; /** * Create unique keys for each emoji item @@ -104,7 +104,7 @@ function EmojiSuggestions(props) { isSuggestionPickerLarge={props.isEmojiPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} - parentContainerRef={props.containerRef} + measureParentContainer={props.measureParentContainer} /> ); } diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index c403aa63c17..2aa50779e10 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -1,19 +1,29 @@ import React, {useEffect, useState, useMemo} from 'react'; import PropTypes from 'prop-types'; import {debounce} from 'lodash'; +import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import * as ReportUtils from '../libs/ReportUtils'; import Text from './Text'; import styles from '../styles/styles'; +import ONYXKEYS from '../ONYXKEYS'; const propTypes = { + /** Report ID to get the comment from (used in withOnyx) */ + // eslint-disable-next-line react/no-unused-prop-types + reportID: PropTypes.string.isRequired, + /** Text Comment */ - comment: PropTypes.string.isRequired, + comment: PropTypes.string, /** Update UI on parent when comment length is exceeded */ onExceededMaxCommentLength: PropTypes.func.isRequired, }; +const defaultProps = { + comment: '', +}; + function ExceededCommentLength(props) { const [commentLength, setCommentLength] = useState(0); const updateCommentLength = useMemo( @@ -38,5 +48,11 @@ function ExceededCommentLength(props) { } ExceededCommentLength.propTypes = propTypes; - -export default ExceededCommentLength; +ExceededCommentLength.defaultProps = defaultProps; +ExceededCommentLength.displayName = 'ExceededCommentLength'; + +export default withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, +})(ExceededCommentLength); diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 799fccb74a5..4b012963526 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -10,7 +10,6 @@ import Avatar from './Avatar'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import getStyledTextArray from '../libs/GetStyledTextArray'; import avatarPropTypes from './avatarPropTypes'; -import refPropType from './refPropTypes'; const propTypes = { /** The index of the highlighted mention */ @@ -44,17 +43,13 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - containerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; const defaultProps = { highlightedMentionIndex: 0, - containerRef: { - current: null, - }, + measureParentContainer: () => {}, }; /** @@ -131,7 +126,7 @@ function MentionSuggestions(props) { isSuggestionPickerLarge={props.isMentionPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} - parentContainerRef={props.containerRef} + measureParentContainer={props.measureParentContainer} /> ); } diff --git a/src/libs/ComposerUtils/debouncedSaveReportComment.js b/src/libs/ComposerUtils/debouncedSaveReportComment.js new file mode 100644 index 00000000000..c39da78c2c3 --- /dev/null +++ b/src/libs/ComposerUtils/debouncedSaveReportComment.js @@ -0,0 +1,13 @@ +import _ from 'underscore'; +import * as Report from '../actions/Report'; + +/** + * 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); + +export default debouncedSaveReportComment; diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js new file mode 100644 index 00000000000..ddcb966bb2a --- /dev/null +++ b/src/libs/ComposerUtils/getDraftComment.js @@ -0,0 +1,24 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +const draftCommentMap = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + callback: (value, key) => { + if (!key) return; + + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ''); + draftCommentMap[reportID] = value; + }, +}); + +/** + * Returns a draft comment from the onyx collection. + * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly. + * A valid use case to use this is if the value is only needed once for an initial value. + * @param {String} reportID + * @returns {String|undefined} + */ +export default function getDraftComment(reportID) { + return draftCommentMap[reportID]; +} diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.js new file mode 100644 index 00000000000..aa2640d006c --- /dev/null +++ b/src/libs/SuggestionUtils.js @@ -0,0 +1,29 @@ +import CONST from '../CONST'; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isAutoSuggestionPickerLarge + * @returns {Number} + */ +function getMaxArrowIndex(numRows, isAutoSuggestionPickerLarge) { + // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items + // and for large we show up to 20 items for mentions/emojis + const rowCount = isAutoSuggestionPickerLarge + ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) + : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); + + // -1 because we start at 0 + return rowCount - 1; +} + +/** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ +function trimLeadingSpace(str) { + return str.slice(0, 1) === ' ' ? str.slice(1) : str; +} + +export {getMaxArrowIndex, trimLeadingSpace}; diff --git a/src/libs/getModalState.js b/src/libs/getModalState.js new file mode 100644 index 00000000000..12023a5bdc4 --- /dev/null +++ b/src/libs/getModalState.js @@ -0,0 +1,21 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; + +let modalState = {}; + +Onyx.connect({ + key: ONYXKEYS.MODAL, + callback: (val) => { + modalState = val; + }, +}); + +/** + * Returns the modal state from onyx. + * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly. + * A valid use case to use this is if the value is only needed once for an initial value. + * @returns {Object} + */ +export default function getModalState() { + return modalState; +} diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js deleted file mode 100644 index 4407656bfe0..00000000000 --- a/src/pages/home/report/ReportActionCompose.js +++ /dev/null @@ -1,1394 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import PropTypes from 'prop-types'; -import {View, NativeModules, findNodeHandle} from 'react-native'; -import {runOnJS} from 'react-native-reanimated'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; -import focusWithDelay from '../../../libs/focusWithDelay'; -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 withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; -import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; -import CONST from '../../../CONST'; -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} 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 OfflineIndicator from '../../../components/OfflineIndicator'; -import ExceededCommentLength from '../../../components/ExceededCommentLength'; -import withNavigationFocus from '../../../components/withNavigationFocus'; -import withNavigation from '../../../components/withNavigation'; -import * as EmojiUtils from '../../../libs/EmojiUtils'; -import * as UserUtils from '../../../libs/UserUtils'; -import ReportDropUI from './ReportDropUI'; -import reportPropTypes from '../../reportPropTypes'; -import EmojiSuggestions from '../../../components/EmojiSuggestions'; -import MentionSuggestions from '../../../components/MentionSuggestions'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; -import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; -import * as ComposerUtils from '../../../libs/ComposerUtils'; -import * as Welcome from '../../../libs/actions/Welcome'; -import Permissions from '../../../libs/Permissions'; -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), - - /** 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)), - - /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** Is the window width narrow, like on a mobile device */ - isSmallScreenWidth: PropTypes.bool.isRequired, - - /** Is composer screen focused */ - isFocused: PropTypes.bool.isRequired, - - /** Is composer full size */ - isComposerFullSize: PropTypes.bool, - - /** 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, - }), - - /** Whether the composer input should be shown */ - shouldShowComposeInput: PropTypes.bool, - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - /** The type of action that's pending */ - pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), - - /** animated ref from react-native-reanimated */ - animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, - - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, - ...withCurrentUserPersonalDetailsPropTypes, - ...keyboardStatePropTypes, -}; - -const defaultProps = { - betas: [], - comment: '', - numberOfLines: undefined, - modal: {}, - report: {}, - reportActions: [], - parentReportActions: {}, - blockedFromConcierge: {}, - personalDetails: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - isComposerFullSize: false, - pendingAction: null, - shouldShowComposeInput: true, - ...withCurrentUserPersonalDetailsDefaultProps, -}; - -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 - * @param {Boolean} isAutoSuggestionPickerLarge - * @returns {Number} - */ -const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { - // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items - // and for large we show up to 20 items for mentions/emojis - const rowCount = isAutoSuggestionPickerLarge - ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) - : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); - - // -1 because we start at 0 - return rowCount - 1; -}; - -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - -// 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(); - -/** - * 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); - -/** - * 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); - -/** - * 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; -}; - -/** - * 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 - */ - const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && !modal.isVisible && !modal.willAlertModalBecomeVisible && shouldShowComposeInput); - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - - 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 - */ - const [textInputShouldClear, setTextInputShouldClear] = useState(false); - const [isCommentEmpty, setIsCommentEmpty] = useState(comment.length === 0); - - /** - * Updates the visibility state of the menu - */ - 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([]); - - const containerRef = useRef(null); - - /** - * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis - * API is not called too often. - */ - const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); - insertedEmojisRef.current = []; - }, []); - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - */ - 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 translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; - } - - return translate('reportActionCompose.writeSomething'); - }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); - - /** - * Focus the composer text input - * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose - */ - const focus = useCallback((shouldDelay) => { - focusWithDelay(textInputRef.current)(shouldDelay); - }, []); - - const isNextModalWillOpenRef = useRef(false); - const isKeyboardVisibleWhenShowingModalRef = useRef(false); - - const restoreKeyboardState = useCallback(() => { - if (!isKeyboardVisibleWhenShowingModalRef.current) { - return; - } - focus(true); - isKeyboardVisibleWhenShowingModalRef.current = false; - }, [focus]); - - /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment - */ - const updateComment = useCallback( - (commentValue, shouldDebounceSaveComment) => { - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); - - 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], - ); - - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - const showPopoverMenu = useCallback(() => { - setMenuVisibility(true); - return true; - }, []); - - /** - * 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], - ); - - /** - * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. - * @returns {Boolean} - */ - const checkComposerVisibility = useCallback(() => { - const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible; - return !isComposerCoveredUp; - }, [isMenuVisible, modal.isVisible]); - - const focusComposerOnKeyPress = useCallback( - (e) => { - const isComposerVisible = checkComposerVisibility(); - if (!isComposerVisible) { - return; - } - - // If the key pressed is non-character keys like Enter, Shift, ... do not focus - if (e.key.length > 1) { - return; - } - - // If a key is pressed in combination with Meta, Control or Alt do not focus - if (e.metaKey || e.ctrlKey || e.altKey) { - return; - } - - // If 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; - } - - focus(); - replaceSelectionWithText(e.key, false); - }, - [checkComposerVisibility, focus, replaceSelectionWithText], - ); - - /** - * Clean data related to EmojiSuggestions - */ - const resetSuggestions = useCallback(() => { - setSuggestionValues(defaultSuggestionsValues); - }, []); - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - 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); - - 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', - }, - ], - }); - } - - 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; - }); - - 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', - }, - ], - }); - }); - - return suggestions; - }, - [personalDetails, translate], - ); - - const calculateMentionSuggestion = useCallback( - (selectionEnd) => { - if (shouldBlockMentionCalc.current || selectionEnd < 1) { - shouldBlockMentionCalc.current = false; - resetSuggestions(); - return; - } - - const valueAfterTheCursor = value.substring(selectionEnd); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - - 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; - } - - const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const lastWord = _.last(words); - - let atSignIndex; - if (lastWord.startsWith('@')) { - atSignIndex = leftString.lastIndexOf(lastWord); - } - - const prefix = lastWord.substring(1); - - const nextState = { - suggestedMentions: [], - atSignIndex, - mentionPrefix: prefix, - }; - - const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); - - if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { - const suggestions = getMentionOptions(prefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); - } - - setSuggestionValues((prevState) => ({ - ...prevState, - ...nextState, - })); - setHighlightedMentionIndex(0); - }, - [getMentionOptions, setHighlightedMentionIndex, value, resetSuggestions], - ); - - const onSelectionChange = useCallback((e) => { - setSelection(e.nativeEvent.selection); - }, []); - - 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; - } - - focus(false); - }, true); - }, [focus, isFocusedProp]); - - /** - * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose - */ - const setTextInputRef = useCallback( - (el) => { - ReportActionComposeFocusManager.composerRef.current = el; - textInputRef.current = el; - if (_.isFunction(animatedRef)) { - animatedRef(el); - } - }, - [animatedRef], - ); - - /** - * Returns the list of IOU Options - * @returns {Array} - */ - const moneyRequestOptions = useMemo(() => { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: translate('iou.splitBill'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: translate('iou.requestMoney'), - }, - [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]); - - /** - * Determines if we can show the task option - * @returns {Boolean} - */ - 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 []; - } - - return [ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), - }, - ]; - }, [betas, report, reportID, translate]); - - /** - * 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)); - } - - setSelection({ - start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - - insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; - debouncedUpdateFrequentlyUsedEmojis(emojiObject); - }, - [debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, selection.end, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], - ); - - /** - * Replace the code of mention and update selection - * @param {Number} highlightedMentionIndex - */ - 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], - ); - - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - const updateNumberOfLines = useCallback( - (newNumberOfLines) => { - Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); - }, - [reportID], - ); - - /** - * @returns {String} - */ - const prepareCommentAndResetComposer = useCallback(() => { - const trimmedComment = commentRef.current.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 ''; - } - - updateComment(''); - setTextInputShouldClear(true); - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - return trimmedComment; - }, [reportID, updateComment, isComposerFullSize]); - - /** - * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] - */ - const submitForm = useCallback( - (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 - debouncedSaveReportComment.cancel(); - - const newComment = prepareCommentAndResetComposer(); - if (!newComment) { - return; - } - - onSubmit(newComment); - }, - [onSubmit, prepareCommentAndResetComposer], - ); - - /** - * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e - */ - const triggerHotkeyActions = useCallback( - (e) => { - if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { - return; - } - - 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 (suggestionValues.suggestedEmojis.length > 0) { - insertSelectedEmoji(highlightedEmojiIndex); - } - if (suggestionValues.suggestedMentions.length > 0) { - insertSelectedMention(highlightedMentionIndex); - } - return; - } - - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); - - if (suggestionsExist) { - resetSuggestions(); - } - - return; - } - - // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); - 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 && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) { - e.preventDefault(); - - 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.reportActionID, _.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 - */ - 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], - ); - - /** - * Event handler to update the state after the attachment preview is closed. - */ - const onAttachmentPreviewClose = useCallback(() => { - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; - setIsAttachmentPreviewActive(false); - restoreKeyboardState(); - }, [restoreKeyboardState]); - - 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, - }); - } - - if (comment.length !== 0) { - Report.setReportWithDraft(reportID, true); - } - - return () => { - ReportActionComposeFocusManager.clear(true); - - KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); - unsubscribeNavigationBlur(); - unsubscribeNavigationFocus(); - - if (EmojiPickerActions.isActive(report.reportID)) { - EmojiPickerActions.hideEmojiPicker(); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - calculateEmojiSuggestion(selection.end); - calculateMentionSuggestion(selection.end); - }, [calculateEmojiSuggestion, calculateMentionSuggestion, selection.end]); - - const prevIsModalVisible = usePrevious(modal.isVisible); - const prevIsFocused = usePrevious(isFocusedProp); - useEffect(() => { - if (modal.isVisible && !prevIsModalVisible) { - isNextModalWillOpenRef.current = false; - } - // 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 && !isNextModalWillOpenRef.current && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { - return; - } - - focus(); - }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); - - 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} - > - {({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; - } - isNextModalWillOpenRef.current = true; - openPicker({ - onPicked: displayFileInModal, - onCanceled: restoreKeyboardState, - }); - }; - const menuItems = [ - ...moneyRequestOptions, - ...taskOption, - { - icon: Expensicons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, - }, - ]; - return ( - <> - - {isComposerFullSize && ( - - { - e.preventDefault(); - 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 || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - > - - - - )} - - { - e.preventDefault(); - if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = textInputRef.current.isFocused(); - } - textInputRef.current.blur(); - - // 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); - restoreKeyboardState(); - }} - 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={(e) => { - setIsFocused(false); - resetSuggestions(); - if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } - }} - onClick={() => { - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; - }} - onPasteFile={displayFileInModal} - shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} - isDisabled={isBlockedFromConcierge || disabled} - isReportActionCompose - 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; - } - setComposerHeight(composerLayoutHeight); - }} - onScroll={() => updateShouldShowSuggestionMenuToFalse()} - /> - - { - if (isAttachmentPreviewActive) { - return; - } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); - }} - /> - - )} - - {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( - focus(true)} - onEmojiSelected={replaceSelectionWithText} - emojiPickerID={report.reportID} - /> - )} - e.preventDefault()} - > - - - [ - 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}) => ( - - )} - - - - - - - {!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} - containerRef={containerRef} - /> - )} - {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} - containerRef={containerRef} - /> - )} - - ); -} - -ReportActionCompose.propTypes = propTypes; -ReportActionCompose.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withNavigation, - withNavigationFocus, - withLocalize, - withNetwork(), - withCurrentUserPersonalDetails, - withKeyboardState, - withAnimatedRef, - 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, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - shouldShowComposeInput: { - key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - canEvict: false, - }, - }), -)(ReportActionCompose); diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js new file mode 100644 index 00000000000..5864b9b0502 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -0,0 +1,289 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../../../../styles/styles'; +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 PopoverMenu from '../../../../components/PopoverMenu'; +import CONST from '../../../../CONST'; +import Tooltip from '../../../../components/Tooltip'; +import * as Browser from '../../../../libs/Browser'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import useLocalize from '../../../../hooks/useLocalize'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import * as IOU from '../../../../libs/actions/IOU'; +import * as Task from '../../../../libs/actions/Task'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import Permissions from '../../../../libs/Permissions'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** The report currently being looked at */ + report: PropTypes.shape({ + /** ID of the report */ + reportID: PropTypes.string, + + /** Whether or not the report is in the process of being created */ + loading: PropTypes.bool, + }).isRequired, + + /** The personal details of everyone in the report */ + reportParticipantIDs: PropTypes.arrayOf(PropTypes.number), + + /** Callback to open the file in the modal */ + displayFileInModal: PropTypes.func.isRequired, + + /** Whether or not the full size composer is available */ + isFullComposerAvailable: PropTypes.bool.isRequired, + + /** Whether or not the composer is full size */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Updates the isComposerFullSize value */ + updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, + + /** Whether or not the user is blocked from concierge */ + isBlockedFromConcierge: PropTypes.bool.isRequired, + + /** Whether or not the attachment picker is disabled */ + disabled: PropTypes.bool.isRequired, + + /** Sets the menu visibility */ + setMenuVisibility: PropTypes.func.isRequired, + + /** Whether or not the menu is visible */ + isMenuVisible: PropTypes.bool.isRequired, + + /** Report ID */ + reportID: PropTypes.string.isRequired, + + /** Called when opening the attachment picker */ + onTriggerAttachmentPicker: PropTypes.func.isRequired, + + /** Called when cancelling the attachment picker */ + onCanceledAttachmentPicker: PropTypes.func.isRequired, + + /** Called when the menu with the items is closed after it was open */ + onMenuClosed: PropTypes.func.isRequired, + + /** Called when the add action button is pressed */ + onAddActionPressed: PropTypes.func.isRequired, + + /** A ref for the add action button */ + actionButtonRef: PropTypes.shape({ + // eslint-disable-next-line react/forbid-prop-types + current: PropTypes.object, + }).isRequired, +}; + +const defaultProps = { + betas: [], + reportParticipantIDs: [], +}; + +/** + * This includes the popover of options you see when pressing the + button in the composer. + * It also contains the attachment picker, as the menu items need to be able to open it. + * + * @returns {React.Component} + */ +function AttachmentPickerWithMenuItems({ + betas, + report, + reportParticipantIDs, + displayFileInModal, + isFullComposerAvailable, + isComposerFullSize, + updateShouldShowSuggestionMenuToFalse, + reportID, + isBlockedFromConcierge, + disabled, + setMenuVisibility, + isMenuVisible, + onTriggerAttachmentPicker, + onCanceledAttachmentPicker, + onMenuClosed, + onAddActionPressed, + actionButtonRef, +}) { + const {translate} = useLocalize(); + const {windowHeight} = useWindowDimensions(); + + /** + * Returns the list of IOU Options + * @returns {Array} + */ + const moneyRequestOptions = useMemo(() => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: translate('iou.splitBill'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: translate('iou.requestMoney'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: translate('iou.sendMoney'), + }, + }; + + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipantIDs, betas), (option) => ({ + ...options[option], + onSelected: () => IOU.startMoneyRequest(option, report.reportID), + })); + }, [betas, report, reportParticipantIDs, translate]); + + /** + * Determines if we can show the task option + * @returns {Boolean} + */ + 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 []; + } + + return [ + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), + }, + ]; + }, [betas, report, reportID, translate]); + + const onPopoverMenuClose = () => { + setMenuVisibility(false); + onMenuClosed(); + }; + + return ( + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + onTriggerAttachmentPicker(); + openPicker({ + onPicked: displayFileInModal, + onCanceled: onCanceledAttachmentPicker, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); + }, + }, + ]; + return ( + <> + + {isComposerFullSize && ( + + { + e.preventDefault(); + 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 && isFullComposerAvailable && ( + + { + 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 || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + > + + + + )} + + { + e.preventDefault(); + onAddActionPressed(); + + // 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); + + // 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} + /> + + ); + }} + + ); +} + +AttachmentPickerWithMenuItems.propTypes = propTypes; +AttachmentPickerWithMenuItems.defaultProps = defaultProps; +AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems'; + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, +})(AttachmentPickerWithMenuItems); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js new file mode 100644 index 00000000000..9f6d3cdac76 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -0,0 +1,569 @@ +import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; +import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Composer from '../../../../components/Composer'; +import containerComposeStyles from '../../../../styles/containerComposeStyles'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import CONST from '../../../../CONST'; +import * as Browser from '../../../../libs/Browser'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; +import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; +import * as ComposerUtils from '../../../../libs/ComposerUtils'; +import * as Report from '../../../../libs/actions/Report'; +import usePrevious from '../../../../hooks/usePrevious'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import * as User from '../../../../libs/actions/User'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; +import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment'; +import SilentCommentUpdater from './SilentCommentUpdater'; +import Suggestions from './Suggestions'; +import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; +import useLocalize from '../../../../hooks/useLocalize'; +import compose from '../../../../libs/compose'; +import withKeyboardState from '../../../../components/withKeyboardState'; +import {propTypes, defaultProps} from './composerWithSuggestionsProps'; + +const {RNTextInputReset} = NativeModules; + +// 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(); + +/** + * 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); + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +// 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(); + +/** + * This component holds the value and selection state. + * If a component really needs access to these state values it should be put here. + * However, double check if the component really needs access, as it will re-render + * on every key press. + * @param {Object} props + * @returns {React.Component} + */ +function ComposerWithSuggestions({ + // Onyx + modal, + preferredSkinTone, + parentReportActions, + numberOfLines, + // HOCs + isKeyboardShown, + // Props: Report + reportID, + report, + reportActions, + // Focus + onFocus, + onBlur, + // Composer + isComposerFullSize, + isMenuVisible, + inputPlaceholder, + displayFileInModal, + textInputShouldClear, + setTextInputShouldClear, + isBlockedFromConcierge, + disabled, + isFullComposerAvailable, + setIsFullComposerAvailable, + setIsCommentEmpty, + submitForm, + shouldShowReportRecipientLocalTime, + shouldShowComposeInput, + measureParentContainer, + // Refs + suggestionsRef, + animatedRef, + forwardedRef, + isNextModalWillOpenRef, +}) { + const {preferredLocale} = useLocalize(); + const isFocused = useIsFocused(); + const navigation = useNavigation(); + + const [value, setValue] = useState(() => getDraftComment(reportID) || ''); + const commentRef = useRef(value); + + const {isSmallScreenWidth} = useWindowDimensions(); + const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + + const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); + const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; + + const valueRef = useRef(value); + valueRef.current = value; + + const [selection, setSelection] = useState(() => ({ + start: isMobileSafari && !shouldAutoFocus ? 0 : value.length, + end: isMobileSafari && !shouldAutoFocus ? 0 : value.length, + })); + + const [composerHeight, setComposerHeight] = useState(0); + + const textInputRef = useRef(null); + const insertedEmojisRef = useRef([]); + + /** + * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis + * API is not called too often. + */ + const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => { + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); + insertedEmojisRef.current = []; + }, []); + + const onInsertedEmoji = useCallback( + (emojiObject) => { + insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; + debouncedUpdateFrequentlyUsedEmojis(emojiObject); + }, + [debouncedUpdateFrequentlyUsedEmojis], + ); + + /** + * Set the TextInput Ref + * + * @param {Element} el + * @memberof ReportActionCompose + */ + const setTextInputRef = useCallback( + (el) => { + ReportActionComposeFocusManager.composerRef.current = el; + textInputRef.current = el; + if (_.isFunction(animatedRef)) { + animatedRef(el); + } + }, + [animatedRef], + ); + + const resetKeyboardInput = useCallback(() => { + if (!RNTextInputReset) { + return; + } + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); + }, [textInputRef]); + + /** + * Update the value of the comment in Onyx + * + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment + */ + const updateComment = useCallback( + (commentValue, shouldDebounceSaveComment) => { + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + + 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, setIsCommentEmpty], + ); + + /** + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines + */ + const updateNumberOfLines = useCallback( + (newNumberOfLines) => { + if (newNumberOfLines === numberOfLines) { + return; + } + Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + }, + [reportID, numberOfLines], + ); + + /** + * @returns {String} + */ + const prepareCommentAndResetComposer = useCallback(() => { + const trimmedComment = commentRef.current.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 ''; + } + + updateComment(''); + setTextInputShouldClear(true); + if (isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + setIsFullComposerAvailable(false); + return trimmedComment; + }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, 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], + ); + + const triggerHotkeyActions = useCallback( + (e) => { + if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { + return; + } + + if (suggestionsRef.current.triggerHotkeyActions(e)) { + return; + } + + // Submit the form when Enter is pressed + if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { + e.preventDefault(); + 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 + const valueLength = valueRef.current.length; + if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !ReportUtils.chatIncludesChronos(report)) { + e.preventDefault(); + + 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.reportActionID, _.last(lastReportAction.message).html); + } + } + }, + [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, suggestionsRef, valueRef], + ); + + const onSelectionChange = useCallback( + (e) => { + if (suggestionsRef.current.onSelectionChange(e)) { + return; + } + + setSelection(e.nativeEvent.selection); + }, + [suggestionsRef], + ); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (!suggestionsRef.current) { + return; + } + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + }, [suggestionsRef]); + + const setShouldBlockSuggestionCalc = useCallback(() => { + if (!suggestionsRef.current) { + return false; + } + return suggestionsRef.current.setShouldBlockSuggestionCalc(true); + }, [suggestionsRef]); + + /** + * Focus the composer text input + * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer + * @memberof ReportActionCompose + */ + const focus = useCallback((shouldDelay = false) => { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + if (!textInputRef.current) { + return; + } + + if (!shouldDelay) { + textInputRef.current.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(() => textInputRef.current.focus(), 100); + } + }); + }, []); + + const setUpComposeFocusManager = useCallback(() => { + // This callback is used in the contextMenuActions to manage giving focus back to the compose input. + ReportActionComposeFocusManager.onComposerFocus(() => { + if (!willBlurTextInputOnTapOutside || !isFocused) { + return; + } + + focus(false); + }, true); + }, [focus, isFocused]); + + /** + * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. + * @returns {Boolean} + */ + const checkComposerVisibility = useCallback(() => { + const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible; + return !isComposerCoveredUp; + }, [isMenuVisible, modal.isVisible]); + + const focusComposerOnKeyPress = useCallback( + (e) => { + const isComposerVisible = checkComposerVisibility(); + if (!isComposerVisible) { + return; + } + + // If the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { + return; + } + + // If a key is pressed in combination with Meta, Control or Alt do not focus + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + // If 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; + } + + focus(); + replaceSelectionWithText(e.key, false); + }, + [checkComposerVisibility, focus, replaceSelectionWithText], + ); + + const blur = useCallback(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.blur(); + }, []); + + useEffect(() => { + const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); + const unsubscribeNavigationFocus = navigation.addListener('focus', () => { + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + setUpComposeFocusManager(); + }); + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + + setUpComposeFocusManager(); + + return () => { + ReportActionComposeFocusManager.clear(true); + + KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); + unsubscribeNavigationBlur(); + unsubscribeNavigationFocus(); + }; + }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]); + + const prevIsModalVisible = usePrevious(modal.isVisible); + const prevIsFocused = usePrevious(isFocused); + useEffect(() => { + if (modal.isVisible && !prevIsModalVisible) { + // eslint-disable-next-line no-param-reassign + isNextModalWillOpenRef.current = false; + } + // 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 && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { + return; + } + + focus(); + }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]); + + useEffect(() => { + if (value.length === 0) { + return; + } + Report.setReportWithDraft(reportID, true); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useImperativeHandle( + forwardedRef, + () => ({ + blur, + focus, + replaceSelectionWithText, + prepareCommentAndResetComposer, + isFocused: () => textInputRef.current.isFocused(), + }), + [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], + ); + + return ( + <> + + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxComposerLines} + onFocus={onFocus} + onBlur={onBlur} + onClick={setShouldBlockSuggestionCalc} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isBlockedFromConcierge || disabled} + isReportActionCompose + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + value={value} + numberOfLines={numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + shouldCalculateCaretPosition + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + onScroll={updateShouldShowSuggestionMenuToFalse} + /> + + + + + + + ); +} + +ComposerWithSuggestions.propTypes = propTypes; +ComposerWithSuggestions.defaultProps = defaultProps; +ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; + +export default compose( + withKeyboardState, + withOnyx({ + numberOfLines: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, + }, + modal: { + key: ONYXKEYS.MODAL, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + canEvict: false, + }, + }), +)( + React.forwardRef((props, ref) => ( + + )), +); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js new file mode 100644 index 00000000000..aa4ecfd4218 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -0,0 +1,459 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import {useNavigation} from '@react-navigation/native'; +import {useAnimatedRef} from 'react-native-reanimated'; +import styles from '../../../../styles/styles'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import * as Report from '../../../../libs/actions/Report'; +import ReportTypingIndicator from '../ReportTypingIndicator'; +import AttachmentModal from '../../../../components/AttachmentModal'; +import compose from '../../../../libs/compose'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; +import CONST from '../../../../CONST'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import participantPropTypes from '../../../../components/participantPropTypes'; +import ParticipantLocalTime from '../ParticipantLocalTime'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; +import {withNetwork} from '../../../../components/OnyxProvider'; +import * as User from '../../../../libs/actions/User'; +import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; +import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; +import OfflineIndicator from '../../../../components/OfflineIndicator'; +import ExceededCommentLength from '../../../../components/ExceededCommentLength'; +import ReportDropUI from '../ReportDropUI'; +import reportPropTypes from '../../../reportPropTypes'; +import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; +import * as Welcome from '../../../../libs/actions/Welcome'; +import SendButton from './SendButton'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; +import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment'; +import reportActionPropTypes from '../reportActionPropTypes'; +import useLocalize from '../../../../hooks/useLocalize'; +import getModalState from '../../../../libs/getModalState'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; + +const propTypes = { + /** A method to call when the form is submitted */ + onSubmit: PropTypes.func.isRequired, + + /** The ID of the report actions will be created for */ + reportID: PropTypes.string.isRequired, + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** Personal details of all the users */ + personalDetails: PropTypes.objectOf(participantPropTypes), + + /** The report currently being looked at */ + report: reportPropTypes, + + /** Is composer full size */ + isComposerFullSize: PropTypes.bool, + + /** 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, + }), + + /** Whether the composer input should be shown */ + shouldShowComposeInput: PropTypes.bool, + + /** The type of action that's pending */ + pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + modal: {}, + report: {}, + blockedFromConcierge: {}, + personalDetails: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + isComposerFullSize: false, + pendingAction: null, + shouldShowComposeInput: true, + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +// 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(); + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +function ReportActionCompose({ + blockedFromConcierge, + currentUserPersonalDetails, + disabled, + isComposerFullSize, + network, + onSubmit, + pendingAction, + personalDetails, + report, + reportID, + reportActions, + shouldShowComposeInput, + isCommentEmpty: isCommentEmptyProp, +}) { + const {translate} = useLocalize(); + const navigation = useNavigation(); + const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); + const animatedRef = useAnimatedRef(); + const actionButtonRef = useRef(null); + + /** + * Updates the Highlight state of the composer + */ + const [isFocused, setIsFocused] = useState(() => { + const initialModalState = getModalState(); + return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible; + }); + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); + + /** + * Updates the should clear state of the composer + */ + const [textInputShouldClear, setTextInputShouldClear] = useState(false); + const [isCommentEmpty, setIsCommentEmpty] = useState(isCommentEmptyProp); + + /** + * Updates the visibility state of the menu + */ + const [isMenuVisible, setMenuVisibility] = useState(false); + const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + */ + const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); + + const suggestionsRef = useRef(null); + const composerRef = useRef(null); + + const reportParticipantIDs = useMemo( + () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), + [currentUserPersonalDetails.accountID, report], + ); + const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipantIDs, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipantIDs]); + + 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 translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; + } + + return translate('reportActionCompose.writeSomething'); + }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); + + const isKeyboardVisibleWhenShowingModalRef = useRef(false); + const restoreKeyboardState = useCallback(() => { + if (!isKeyboardVisibleWhenShowingModalRef.current) { + return; + } + composerRef.current.focus(true); + isKeyboardVisibleWhenShowingModalRef.current = false; + }, []); + + const containerRef = useRef(null); + const measureContainer = useCallback((callback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }, []); + + const onAddActionPressed = useCallback(() => { + if (!willBlurTextInputOnTapOutside) { + isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused(); + } + composerRef.current.blur(); + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (!suggestionsRef.current) { + return; + } + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + }, []); + + /** + * @param {Object} file + */ + 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 = composerRef.current.prepareCommentAndResetComposer(); + Report.addAttachment(reportID, file, newComment); + setTextInputShouldClear(false); + }, + [reportID], + ); + + /** + * Event handler to update the state after the attachment preview is closed. + */ + const onAttachmentPreviewClose = useCallback(() => { + updateShouldShowSuggestionMenuToFalse(); + setIsAttachmentPreviewActive(false); + restoreKeyboardState(); + }, [updateShouldShowSuggestionMenuToFalse, restoreKeyboardState]); + + /** + * Add a new comment to this chat + * + * @param {SyntheticEvent} [e] + */ + const submitForm = useCallback( + (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 + debouncedSaveReportComment.cancel(); + + const newComment = composerRef.current.prepareCommentAndResetComposer(); + if (!newComment) { + return; + } + + onSubmit(newComment); + }, + [onSubmit], + ); + + const isNextModalWillOpenRef = useRef(false); + const onTriggerAttachmentPicker = useCallback(() => { + // 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 (willBlurTextInputOnTapOutside) { + suggestionsRef.current.setShouldBlockSuggestionCalc(true); + } + isNextModalWillOpenRef.current = true; + }, []); + + const onBlur = useCallback((e) => { + setIsFocused(false); + suggestionsRef.current.resetSuggestions(); + if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } + }, []); + + const onFocus = useCallback(() => { + setIsFocused(true); + }, []); + + /** + * Used to show Popover menu on Workspace chat at first sign-in + * @returns {Boolean} + */ + const showPopoverMenu = useCallback(() => { + setMenuVisibility(true); + return true; + }, []); + + useEffect(() => { + // Shows Popover Menu on Workspace Chat at first sign-in + if (!disabled) { + Welcome.show({ + routes: lodashGet(navigation.getState(), 'routes', []), + showPopoverMenu, + }); + } + + return () => { + if (!EmojiPickerActions.isActive(report.reportID)) { + return; + } + EmojiPickerActions.hideEmojiPicker(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; + const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; + + const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); + + const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; + + return ( + + + {shouldShowReportRecipientLocalTime && hasReportRecipient && } + + setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} + > + {({displayFileInModal}) => ( + <> + + + { + if (isAttachmentPreviewActive) { + return; + } + const data = lodashGet(e, ['dataTransfer', 'items', 0]); + displayFileInModal(data); + }} + /> + + )} + + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( + composerRef.current.focus(true)} + onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)} + emojiPickerID={report.reportID} + /> + )} + + + + {!isSmallScreenWidth && } + + + + + + ); +} + +ReportActionCompose.propTypes = propTypes; +ReportActionCompose.defaultProps = defaultProps; + +export default compose( + withNetwork(), + withCurrentUserPersonalDetails, + withOnyx({ + isCommentEmpty: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + selector: (comment) => _.isEmpty(comment), + }, + blockedFromConcierge: { + key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + shouldShowComposeInput: { + key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, + }, + }), +)(ReportActionCompose); diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js new file mode 100644 index 00000000000..4f1dc5fff19 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -0,0 +1,80 @@ +import React from 'react'; +import {View} from 'react-native'; +import {runOnJS} from 'react-native-reanimated'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import PropTypes from 'prop-types'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import CONST from '../../../../CONST'; +import Tooltip from '../../../../components/Tooltip'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; +import useLocalize from '../../../../hooks/useLocalize'; + +const propTypes = { + /** Whether the button is disabled */ + isDisabled: PropTypes.bool.isRequired, + + /** Reference to the animated view */ + animatedRef: PropTypes.func.isRequired, + + /** Sets the isCommentEmpty flag to true */ + setIsCommentEmpty: PropTypes.func.isRequired, + + /** Submits the form */ + submitForm: PropTypes.func.isRequired, +}; + +function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, submitForm}) { + const {translate} = useLocalize(); + + const Tap = Gesture.Tap() + .enabled() + .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 ( + e.preventDefault()} + > + + + [ + styles.chatItemSubmitButton, + isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabledProp ? styles.cursorDisabled : undefined, + ]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + + + + ); +} + +SendButton.propTypes = propTypes; +SendButton.displayName = 'SendButton'; + +export default SendButton; diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js new file mode 100644 index 00000000000..da5dc326d42 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js @@ -0,0 +1,72 @@ +import {useEffect} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import usePrevious from '../../../../hooks/usePrevious'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import useLocalize from '../../../../hooks/useLocalize'; + +const propTypes = { + /** The comment of the report */ + comment: PropTypes.string, + + /** The report associated with the comment */ + report: PropTypes.shape({ + /** The ID of the report */ + reportID: PropTypes.string, + }).isRequired, + + /** The value of the comment */ + value: PropTypes.string.isRequired, + + /** The ref of the comment */ + commentRef: PropTypes.shape({ + /** The current value of the comment */ + current: PropTypes.string, + }).isRequired, + + /** Updates the comment */ + updateComment: PropTypes.func.isRequired, +}; + +const defaultProps = { + comment: '', +}; + +/** + * This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions. + * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid + * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. + * @returns {null} + */ +function SilentCommentUpdater({comment, commentRef, report, value, updateComment}) { + const prevCommentProp = usePrevious(comment); + const prevReportId = usePrevious(report.reportID); + const {preferredLocale} = useLocalize(); + const prevPreferredLocale = usePrevious(preferredLocale); + + 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, commentRef]); + + return null; +} + +SilentCommentUpdater.propTypes = propTypes; +SilentCommentUpdater.defaultProps = defaultProps; +SilentCommentUpdater.displayName = 'SilentCommentUpdater'; + +export default withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, +})(SilentCommentUpdater); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js new file mode 100644 index 00000000000..687570af12e --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -0,0 +1,267 @@ +import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import EmojiSuggestions from '../../../../components/EmojiSuggestions'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import useLocalize from '../../../../hooks/useLocalize'; +import * as SuggestionProps from './suggestionProps'; + +/** + * 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; +}; + +const defaultSuggestionsValues = { + suggestedEmojis: [], + colonSignIndex: -1, + shouldShowSuggestionMenu: false, +}; + +const propTypes = { + /** Preferred skin tone */ + preferredSkinTone: PropTypes.number, + + /** A ref to this component */ + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + /** Function to clear the input */ + resetKeyboardInput: PropTypes.func.isRequired, + + /** Callback when a emoji was inserted */ + onInsertedEmoji: PropTypes.func.isRequired, + + /** The current selection */ + selection: PropTypes.shape({ + start: PropTypes.number.isRequired, + end: PropTypes.number.isRequired, + }).isRequired, + + ...SuggestionProps.baseProps, +}; + +const defaultProps = { + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + forwardedRef: null, +}; + +function SuggestionEmoji({ + preferredSkinTone, + value, + setValue, + selection, + setSelection, + updateComment, + isComposerFullSize, + shouldShowReportRecipientLocalTime, + isAutoSuggestionPickerLarge, + forwardedRef, + resetKeyboardInput, + onInsertedEmoji, + measureParentContainer, +}) { + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + + const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; + + const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({ + isActive: isEmojiSuggestionsMenuVisible, + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + const {preferredLocale} = useLocalize(); + + // Used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockCalc = useRef(false); + + /** + * 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} ${SuggestionsUtils.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. + resetKeyboardInput(); + + setSelection({ + start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); + + onInsertedEmoji(emojiObject); + }, + [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + ); + + /** + * Clean data related to suggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + setSuggestionValues((prevState) => { + if (prevState.shouldShowSuggestionMenu) { + return {...prevState, shouldShowSuggestionMenu: false}; + } + return prevState; + }); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback( + (e) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; + + 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); + } + return true; + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [highlightedEmojiIndex, insertSelectedEmoji, resetSuggestions, suggestionValues.suggestedEmojis.length], + ); + + /** + * Calculates and cares about the content of an Emoji Suggester + */ + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockCalc.current || !value) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } + const leftString = value.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); + + const nextState = { + suggestedEmojis: [], + colonIndex, + shouldShowEmojiSuggestionMenu: false, + }; + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); + + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + } + + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedEmojiIndex(0); + }, + [value, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], + ); + + const onSelectionChange = useCallback( + (e) => { + /** + * 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); + }, + [calculateEmojiSuggestion], + ); + + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockCalc], + ); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + ); + + if (!isEmojiSuggestionsMenuVisible) { + return null; + } + + return ( + 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={isAutoSuggestionPickerLarge} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + measureParentContainer={measureParentContainer} + /> + ); +} + +SuggestionEmoji.propTypes = propTypes; +SuggestionEmoji.defaultProps = defaultProps; +SuggestionEmoji.displayName = 'SuggestionEmoji'; + +const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( + +)); + +export default withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, +})(SuggestionEmojiWithRef); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js new file mode 100644 index 00000000000..79b5d1d66e3 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -0,0 +1,312 @@ +import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import MentionSuggestions from '../../../../components/MentionSuggestions'; +import * as UserUtils from '../../../../libs/UserUtils'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; +import useLocalize from '../../../../hooks/useLocalize'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import personalDetailsPropType from '../../../personalDetailsPropType'; +import * as SuggestionProps from './suggestionProps'; + +/** + * 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); + +const defaultSuggestionsValues = { + suggestedMentions: [], + atSignIndex: -1, + shouldShowSuggestionMenu: false, + mentionPrefix: '', +}; + +const propTypes = { + /** Personal details of all users */ + personalDetails: PropTypes.objectOf(personalDetailsPropType), + + /** A ref to this component */ + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + ...SuggestionProps.implementationBaseProps, +}; + +const defaultProps = { + personalDetails: {}, + forwardedRef: null, +}; + +function SuggestionMention({ + value, + setValue, + setSelection, + isComposerFullSize, + personalDetails, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, + isAutoSuggestionPickerLarge, + measureParentContainer, +}) { + const {translate} = useLocalize(); + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + + const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; + + const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ + isActive: isMentionSuggestionsMenuVisible, + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + // Used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockCalc = useRef(false); + + /** + * Replace the code of mention and update selection + * @param {Number} highlightedMentionIndex + */ + 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} ${SuggestionsUtils.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); + setSelection({ + start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + })); + }, + [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, updateComment, setSelection], + ); + + /** + * Clean data related to suggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback( + (e) => { + const suggestionsExist = 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 (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + return true; + } + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length], + ); + + const getMentionOptions = useCallback( + (personalDetailsParam, 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', + }, + ], + }); + } + + const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (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; + }); + + 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', + }, + ], + }); + }); + + return suggestions; + }, + [translate], + ); + + const calculateMentionSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockCalc.current || selectionEnd < 1) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } + + const valueAfterTheCursor = value.substring(selectionEnd); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + + 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; + } + + const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const lastWord = _.last(words); + + let atSignIndex; + if (lastWord.startsWith('@')) { + atSignIndex = leftString.lastIndexOf(lastWord); + } + + const prefix = lastWord.substring(1); + + const nextState = { + suggestedMentions: [], + atSignIndex, + mentionPrefix: prefix, + }; + + const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); + + if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { + const suggestions = getMentionOptions(personalDetails, prefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions); + } + + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + setHighlightedMentionIndex(0); + }, + [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value], + ); + + const onSelectionChange = useCallback( + (e) => { + calculateMentionSuggestion(e.nativeEvent.selection.end); + }, + [calculateMentionSuggestion], + ); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + setSuggestionValues((prevState) => { + if (prevState.shouldShowSuggestionMenu) { + return {...prevState, shouldShowSuggestionMenu: false}; + } + return prevState; + }); + }, []); + + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockCalc], + ); + + const onClose = useCallback(() => { + setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []})); + }, []); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + ); + + if (!isMentionSuggestionsMenuVisible) { + return null; + } + + return ( + setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={suggestionValues.mentionPrefix} + onSelect={insertSelectedMention} + isComposerFullSize={isComposerFullSize} + isMentionPickerLarge={isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + measureParentContainer={measureParentContainer} + /> + ); +} + +SuggestionMention.propTypes = propTypes; +SuggestionMention.defaultProps = defaultProps; +SuggestionMention.displayName = 'SuggestionMention'; + +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, +})( + React.forwardRef((props, ref) => ( + + )), +); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js new file mode 100644 index 00000000000..ed2ab9586d5 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -0,0 +1,142 @@ +import React, {useRef, useCallback, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import SuggestionMention from './SuggestionMention'; +import SuggestionEmoji from './SuggestionEmoji'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as SuggestionProps from './suggestionProps'; + +const propTypes = { + /** A ref to this component */ + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + /** Callback when a emoji was inserted */ + onInsertedEmoji: PropTypes.func.isRequired, + + /** Function to clear the input */ + resetKeyboardInput: PropTypes.func.isRequired, + + ...SuggestionProps.baseProps, +}; + +const defaultProps = { + forwardedRef: null, +}; + +/** + * This component contains the individual suggestion components. + * If you want to add a new suggestion type, add it here. + * + * @returns {React.Component} + */ +function Suggestions({ + isComposerFullSize, + value, + setValue, + selection, + setSelection, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, + onInsertedEmoji, + resetKeyboardInput, + measureParentContainer, +}) { + const suggestionEmojiRef = useRef(null); + const suggestionMentionRef = useRef(null); + + /** + * Clean data related to EmojiSuggestions + */ + const resetSuggestions = useCallback(() => { + suggestionEmojiRef.current.resetSuggestions(); + suggestionMentionRef.current.resetSuggestions(); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback((e) => { + const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e); + const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e); + return emojiHandler || mentionHandler; + }, []); + + const onSelectionChange = useCallback((e) => { + const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e); + const mentionHandler = suggestionMentionRef.current.onSelectionChange(e); + return emojiHandler || mentionHandler; + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse(); + suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); + }, []); + + const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => { + suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock); + suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock); + }, []); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + setShouldBlockSuggestionCalc, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + ); + + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + + // 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 baseProps = { + value, + setValue, + setSelection, + isComposerFullSize, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + isAutoSuggestionPickerLarge, + measureParentContainer, + }; + + return ( + <> + + + + ); +} + +Suggestions.propTypes = propTypes; +Suggestions.defaultProps = defaultProps; +Suggestions.displayName = 'Suggestions'; + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js new file mode 100644 index 00000000000..b8d9f0b6d81 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import reportActionPropTypes from '../reportActionPropTypes'; +import CONST from '../../../../CONST'; + +const propTypes = { + /** Details about any modals being used */ + modal: PropTypes.shape({ + /** Indicates if there is a modal currently visible or not */ + isVisible: PropTypes.bool, + }), + + /** User's preferred skin tone color */ + preferredSkinTone: PropTypes.number, + + /** Number of lines for the composer */ + numberOfLines: PropTypes.number, + + /** Whether the keyboard is open or not */ + isKeyboardShown: PropTypes.bool.isRequired, + + /** The actions from the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** The ID of the report */ + reportID: PropTypes.string.isRequired, + + /** The report currently being looked at */ + report: PropTypes.shape({ + parentReportID: PropTypes.string, + }).isRequired, + + /** Callback when the input is focused */ + onFocus: PropTypes.func.isRequired, + + /** Callback when the input is blurred */ + onBlur: PropTypes.func.isRequired, + + /** Whether the composer is full size or not */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Whether the menu is visible or not */ + isMenuVisible: PropTypes.bool.isRequired, + + /** Placeholder text for the input */ + inputPlaceholder: PropTypes.string.isRequired, + + /** Function to display a file in the modal */ + displayFileInModal: PropTypes.func.isRequired, + + /** Whether the text input should be cleared or not */ + textInputShouldClear: PropTypes.bool.isRequired, + + /** Function to set whether the text input should be cleared or not */ + setTextInputShouldClear: PropTypes.func.isRequired, + + /** Whether the user is blocked from concierge or not */ + isBlockedFromConcierge: PropTypes.bool.isRequired, + + /** Whether the input is disabled or not */ + disabled: PropTypes.bool.isRequired, + + /** Whether the full composer is available or not */ + isFullComposerAvailable: PropTypes.bool.isRequired, + + /** Function to set whether the full composer is available or not */ + setIsFullComposerAvailable: PropTypes.func.isRequired, + + /** Function to set whether the comment is empty or not */ + setIsCommentEmpty: PropTypes.func.isRequired, + + /** A method to call when the form is submitted */ + submitForm: PropTypes.func.isRequired, + + /** Whether the recipient local time is shown or not */ + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Whether the compose input is shown or not */ + shouldShowComposeInput: PropTypes.bool.isRequired, + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, + + /** Ref for the suggestions component */ + suggestionsRef: PropTypes.shape({ + current: PropTypes.shape({ + /** Update the shouldShowSuggestionMenuToFalse prop */ + updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, + + /** Trigger hotkey actions */ + triggerHotkeyActions: PropTypes.func.isRequired, + + /** Check if suggestion calculation should be blocked */ + setShouldBlockSuggestionCalc: PropTypes.func.isRequired, + + /** Callback when the selection changes */ + onSelectionChange: PropTypes.func.isRequired, + }), + }).isRequired, + + /** Ref for the animated view (text input) */ + animatedRef: PropTypes.func.isRequired, + + /** Ref for the composer */ + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + /** Ref for the isNextModalWillOpen */ + isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, +}; + +const defaultProps = { + modal: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + numberOfLines: undefined, + parentReportActions: {}, + reportActions: [], + forwardedRef: null, + measureParentContainer: () => {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js new file mode 100644 index 00000000000..24cf51b018c --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; + +const baseProps = { + /** The current input value */ + value: PropTypes.string.isRequired, + + /** Callback to update the current input value */ + setValue: PropTypes.func.isRequired, + + /** Callback to update the current selection */ + setSelection: PropTypes.func.isRequired, + + /** Whether the composer is expanded */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Callback to update the comment draft */ + updateComment: PropTypes.func.isRequired, + + /** Flag whether we need to consider the participants */ + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func.isRequired, +}; + +const implementationBaseProps = { + /** Whether to use the small or the big suggestion picker */ + isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, + + ...baseProps, +}; + +export {baseProps, implementationBaseProps}; diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 343f2fb8733..8d92c09b7a6 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {View, Keyboard} from 'react-native'; import CONST from '../../../CONST'; -import ReportActionCompose from './ReportActionCompose'; +import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; import AnonymousReportFooter from '../../../components/AnonymousReportFooter'; import SwipeableView from '../../../components/SwipeableView'; import OfflineIndicator from '../../../components/OfflineIndicator'; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index 5016e6c3087..9dcf954e84f 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -1072,7 +1072,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight, shouldIncludeRepor 'worklet'; const optionalPadding = shouldIncludeReportRecipientLocalTimeHeight ? CONST.RECIPIENT_LOCAL_TIME_HEIGHT : 0; - const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING - optionalPadding; + const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + optionalPadding; const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + borderWidth;