From f846039316d44445e451dde97a8677ca4c05f282 Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Fri, 5 May 2023 19:08:00 +0300 Subject: [PATCH 01/74] refactor: in-progress - migrate ReportActionCompose from class to function component --- src/pages/home/report/ReportActionComposeF.js | 1010 +++++++++++++++++ 1 file changed, 1010 insertions(+) create mode 100644 src/pages/home/report/ReportActionComposeF.js diff --git a/src/pages/home/report/ReportActionComposeF.js b/src/pages/home/report/ReportActionComposeF.js new file mode 100644 index 000000000000..faa597ccda4c --- /dev/null +++ b/src/pages/home/report/ReportActionComposeF.js @@ -0,0 +1,1010 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { + View, + TouchableOpacity, + InteractionManager, + LayoutAnimation, +} from 'react-native'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../../../styles/styles'; +import themeColors from '../../../styles/themes/default'; +import Composer from '../../../components/Composer'; +import ONYXKEYS from '../../../ONYXKEYS'; +import Icon from '../../../components/Icon'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../components/AttachmentPicker'; +import * as Report from '../../../libs/actions/Report'; +import ReportTypingIndicator from './ReportTypingIndicator'; +import AttachmentModal from '../../../components/AttachmentModal'; +import compose from '../../../libs/compose'; +import PopoverMenu from '../../../components/PopoverMenu'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; +import withDrawerState from '../../../components/withDrawerState'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; +import CONST from '../../../CONST'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import reportActionPropTypes from './reportActionPropTypes'; +import * as ReportUtils from '../../../libs/ReportUtils'; +import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; +import participantPropTypes from '../../../components/participantPropTypes'; +import ParticipantLocalTime from './ParticipantLocalTime'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; +import {withNetwork, withPersonalDetails} from '../../../components/OnyxProvider'; +import * as User from '../../../libs/actions/User'; +import Tooltip from '../../../components/Tooltip'; +import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; +import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; +import toggleReportActionComposeView from '../../../libs/toggleReportActionComposeView'; +import OfflineIndicator from '../../../components/OfflineIndicator'; +import ExceededCommentLength from '../../../components/ExceededCommentLength'; +import withNavigationFocus from '../../../components/withNavigationFocus'; +import withNavigation from '../../../components/withNavigation'; +import * as EmojiUtils from '../../../libs/EmojiUtils'; +import ReportDropUI from './ReportDropUI'; +import DragAndDrop from '../../../components/DragAndDrop'; +import reportPropTypes from '../../reportPropTypes'; +import EmojiSuggestions from '../../../components/EmojiSuggestions'; +import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; +import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; +import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; +import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import * as ComposerUtils from '../../../libs/ComposerUtils'; +import * as Welcome from '../../../libs/actions/Welcome'; +import Permissions from '../../../libs/Permissions'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** A method to call when the form is submitted */ + onSubmit: PropTypes.func.isRequired, + + /** The comment left by the user */ + comment: PropTypes.string, + + /** Number of lines for the comment */ + numberOfLines: PropTypes.number, + + /** The ID of the report actions will be created for */ + reportID: PropTypes.string.isRequired, + + /** Details about any modals being used */ + modal: PropTypes.shape({ + /** Indicates if there is a modal currently visible or not */ + isVisible: PropTypes.bool, + }), + + /** Personal details of all the users */ + personalDetails: PropTypes.objectOf(participantPropTypes), + + /** The report currently being looked at */ + report: reportPropTypes, + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** Is the report view covered by the drawer */ + isDrawerOpen: PropTypes.bool.isRequired, + + /** Is the window width narrow, like on a mobile device */ + isSmallScreenWidth: PropTypes.bool.isRequired, + + /** Is composer screen focused */ + isFocused: PropTypes.bool.isRequired, + + /** 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, + }), + + /** Stores user's preferred skin tone */ + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** User's frequently used emojis */ + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + keywords: PropTypes.arrayOf(PropTypes.string), + })), + + /** The type of action that's pending */ + pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + + ...windowDimensionsPropTypes, + ...withLocalizePropTypes, + ...withCurrentUserPersonalDetailsPropTypes, + ...keyboardStatePropTypes, +}; + +const defaultProps = { + betas: [], + comment: '', + numberOfLines: undefined, + modal: {}, + report: {}, + reportActions: [], + blockedFromConcierge: {}, + personalDetails: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + frequentlyUsedEmojis: [], + isComposerFullSize: false, + pendingAction: null, + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isEmojiPickerLarge + * @returns {Number} + */ +const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { + // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items + const emojiRowCount = isEmojiPickerLarge + ? Math.max(numRows, CONST.EMOJI_SUGGESTER.MAX_AMOUNT_OF_ITEMS) + : Math.max(numRows, CONST.EMOJI_SUGGESTER.MIN_AMOUNT_OF_ITEMS); + + // -1 because we start at 0 + return emojiRowCount - 1; +}; + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +function ReportActionCompose(props) { + /** + * Updates the Highlight state of the composer + */ + const [isFocused, setIsFocused] = useState(willBlurTextInputOnTapOutside && !props.modal.isVisible && !props.modal.willAlertModalBecomeVisible); + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(props.isComposerFullSize); + + /** + * Updates the should clear state of the composer + */ + const [textInputShouldClear, setTextInputShouldClear] = useState(false); + const [isCommentEmpty, setIsCommentEmpty] = useState(props.comment.length === 0); + + /** + * Updates the visibility state of the menu + */ + const [isMenuVisible, setMenuVisibility] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [selection, setSelection] = useState({start: props.comment.length, end: props.comment.length}); + const [maxLines, setMaxLines] = useState(props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES); + const [value, setValue] = useState(props.comment); + + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + const [conciergePlaceholderRandomIndex] = useState(_.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1))); + const [suggestedEmojis, setSuggestedEmojis] = useState([]); + const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useState(0); + const [colonIndex, setColonIndex] = useState(-1); + const [shouldShowSuggestionMenu, setShouldShowSuggestionMenu] = useState(false); + const [shouldBlockEmojiCalc, setShouldBlockEmojiCalc] = useState(false); + const [isEmojiPickerLarge, setIsEmojiPickerLarge] = useState(false); + const [composerHeight, setComposerHeight] = useState(0); + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + */ + const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); + + // TODO_S: not sure if useState is a good solutions here + // previously unsubscribeEscapeKey was written directly into this + // like unsubscribeEscapeKey = KeyboardShortcut.subscribe... + const unsubscribeEscapeKey = useRef(null); + const comment = useRef(props.comment); + const textInput = useRef(null); + const actionButton = useRef(null); + const prevPropsRef = useRef(); + + /** + * Focus the composer text input + * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer + * @memberof ReportActionCompose + */ + const focus = useCallback((shouldelay = false) => { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + if (!textInput) { + return; + } + + if (!shouldelay) { + textInput.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(() => textInput.current.focus(), 100); + } + }); + }, []); + + /** + * Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often + * to update Onyx and re-render this component. + * + * @param {String} comment + */ + const debouncedSaveReportComment = useCallback(_.debounce( + reportComment => Report.saveReportComment(props.reportID, reportComment || ''), + 1000, + false, + ), + [props.reportID]); + + /** + * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish + * client events. + */ + const debouncedBroadcastUserIsTyping = useCallback(_.debounce( + () => Report.broadcastUserIsTyping(props.reportID), + 100, + true, + ), + [props.reportID]); + + /** + * Update the value of the comment in Onyx + * + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment + */ + const updateComment = useCallback((commentValue, shouldDebounceSaveComment) => { + const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); + + // TODO-S: prevState check again + setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + setValue(newComment); + if (commentValue !== newComment) { + const remainder = value.slice(selection.end).length; + setSelection({ + start: newComment.length - remainder, + end: newComment.length - remainder, + }); + } + + // Indicate that draft has been created. + if (comment.current.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(props.reportID, true); + } + + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(props.reportID, false); + } + + comment.current = newComment; + if (shouldDebounceSaveComment) { + debouncedSaveReportComment(newComment); + } else { + Report.saveReportComment(props.reportID, newComment || ''); + } + if (newComment) { + debouncedBroadcastUserIsTyping(); + } + }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value, debouncedSaveReportComment, debouncedBroadcastUserIsTyping]); + + /** + * Set the maximum number of lines for the composer + */ + const updateMaxLines = useCallback(() => { + let maxLinesNumber = props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + if (props.isComposerFullSize) { + maxLinesNumber = CONST.COMPOSER.MAX_LINES_FULL; + } + setMaxLines(maxLinesNumber); + }, [props.isSmallScreenWidth, props.isComposerFullSize]); + + /** + * Used to show Popover menu on Workspace chat at first sign-in + * @returns {Boolean} + */ + const showPopoverMenu = useCallback(() => { + setMenuVisibility(true); + return true; + }, []); + + useEffect(() => { + // 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 || !props.isFocused) { + return; + } + + focus(false); + }); + + const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + unsubscribeEscapeKey.current = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + if (!isFocused || comment.current.length === 0) { + return; + } + + updateComment('', true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); + + updateMaxLines(); + updateComment(comment.current); + + // Shows Popover Menu on Workspace Chat at first sign-in + if (!props.disabled) { + Welcome.show({ + routes: lodashGet(props.navigation.getState(), 'routes', []), + showPopoverMenu, + }); + } + return () => { + ReportActionComposeFocusManager.clear(); + + if (unsubscribeEscapeKey) { + unsubscribeEscapeKey(); + } + }; + }, []); + + useEffect(() => { + const prevProps = prevPropsRef.current; + + // TODO-S: check if prevprops exist? + if (prevProps) { + const sidebarOpened = !prevProps.isDrawerOpen && props.isDrawerOpen; + if (sidebarOpened) { + toggleReportActionComposeView(true); + } + + // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (willBlurTextInputOnTapOutside && props.isFocused + && prevProps && prevProps.modal.isVisible && !props.modal.isVisible) { + focus(); + } + + if (props.isComposerFullSize !== prevProps.isComposerFullSize) { + updateMaxLines(); + } + + // Value state does not have the same value as comment props when the comment gets changed from another tab. + // In this case, we should synchronize the value between tabs. + const shouldSyncComment = prevProps.comment !== props.comment && value !== props.comment; + + if (props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { + return; + } + + updateComment(comment.current); + } + prevPropsRef.current = { + isDrawerOpen: props.isDrawerOpen, + isFocused: props.isFocused, + modal: props.modal, + isComposerFullSize: props.isComposerFullSize, + comment: props.comment, + report: props.report, + }; + }, [props.isDrawerOpen, props.isFocused, props.modal, props.isComposerFullSize, props.comment, props.report, focus, updateMaxLines, value, updateComment]); + + /** + * Clean data related to EmojiSuggestions + */ + const resetSuggestedEmojis = useCallback(() => { + setSuggestedEmojis([]); + setHighlightedEmojiIndex(0); + setColonIndex(-1); + setShouldShowSuggestionMenu(false); + setIsEmojiPickerLarge(true); + }, []); + + /** + * Check if this piece of string looks like an emoji + * @param {String} str + * @param {Number} pos + * @returns {Boolean} + */ + const isEmojiCode = useCallback((str, pos) => { + const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const leftWord = _.last(leftWords); + + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; + }, []); + + /** + * Calculates and cares about the content of an Emoji Suggester + */ + const calculateEmojiSuggestion = useCallback(() => { + // TODO-S: think maybe rewrite state to compose solution + // to avoid 5 set states + if (!value) { + resetSuggestedEmojis(); + return; + } + if (shouldBlockEmojiCalc) { + setShouldBlockEmojiCalc(false); + return; + } + const leftString = value.substring(0, selection.end); + setColonIndex(leftString.lastIndexOf(':')); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selection.end); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; + setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); + + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); + + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + setSuggestedEmojis(newSuggestedEmojis); + setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); + } + + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + + setHighlightedEmojiIndex(0); + }, [composerHeight, isEmojiCode, value, selection, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc]); + + const onSelectionChange = useCallback((e) => { + setSelection(e.nativeEvent.selection); + calculateEmojiSuggestion(); + }, [calculateEmojiSuggestion]); + + /** + * Set the TextInput Ref + * + * @param {Element} el + * @memberof ReportActionCompose + */ + const setTextInputRef = useCallback((el) => { + ReportActionComposeFocusManager.composerRef.current = el; + textInput.current = el; + }, []); + + /** + * Get the placeholder to display in the chat input. + * + * @return {String} + */ + const getInputPlaceholder = useCallback(() => { + if (ReportUtils.chatIncludesConcierge(props.report)) { + if (User.isBlockedFromConcierge(props.blockedFromConcierge)) { + return props.translate('reportActionCompose.blockedFromConcierge'); + } + + return props.translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; + } + + // TODO-S: check dependency array for props (do we need it at all?) + return props.translate('reportActionCompose.writeSomething'); + }, [props, conciergePlaceholderRandomIndex]); + + /** + * Returns the list of IOU Options + * + * @param {Array} reportParticipants + * @returns {Array} + */ + const getMoneyRequestOptions = useCallback((reportParticipants) => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: props.translate('iou.splitBill'), + onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(props.reportID)), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: props.translate('iou.requestMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(props.reportID)), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: props.translate('iou.sendMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(props.reportID)), + }, + }; + + // TODO-S: check for props in dependency array + return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); + }, [props]); + + // TODO-S: check if eslint rule still valid + // eslint-disable-next-line rulesdir/prefer-early-return + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (shouldShowSuggestionMenu) { + setShouldShowSuggestionMenu(false); + } + }, [shouldShowSuggestionMenu]); + + // eslint-disable-next-line rulesdir/prefer-early-return + const updateShouldBlockEmojiCalcToFalse = useCallback(() => { + if (shouldBlockEmojiCalc) { + setShouldBlockEmojiCalc(false); + } + }, [shouldBlockEmojiCalc]); + + /** + * Determines if we can show the task option + * @param {Array} reportParticipants + * @returns {Boolean} + */ + const getTaskOption = useCallback((reportParticipants) => { + // 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(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( + CONST.EXPENSIFY_EMAILS, + email, + )))) { + return []; + } + + return [ + { + icon: Expensicons.Task, + text: props.translate('newTaskPage.assignTask'), + onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(props.reportID)), + }, + ]; + + // TODO-S: check for props in dependency array + }, [props]); + + /** + * Replace the code of emoji and update selection + * @param {Number} highlightedEmojiIndex + */ + const insertSelectedEmoji = useCallback((selectedEmoji) => { + const commentBeforeColon = value.slice(0, colonIndex); + const emojiObject = suggestedEmojis[selectedEmoji]; + const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + + updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + setSelection({ + start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestedEmojis([]); + + EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); + }, [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment]); + + const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, []); + + /** + * Callback for the emoji picker to add whatever emoji is chosen into the main input + * + * @param {String} emoji + */ + const addEmojiToTextBox = useCallback((emoji) => { + setSelection({ + start: selection.start + emoji.length, + end: selection.start + emoji.length, + }); + + // TODO-S: pay attention here to selection passing (maybe need to + // create selection above and pass same value to setState above and selection below?) + updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); + }, [selection, updateComment]); + + /** + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines + */ + const updateNumberOfLines = useCallback((numberOfLines) => { + Report.saveReportCommentNumberOfLines(props.reportID, numberOfLines); + }, [props.reportID]); + + /** + * @returns {String} + */ + const prepareCommentAndResetComposer = useCallback(() => { + const trimmedComment = comment.current.trim(); + + // Don't submit empty comments or comments that exceed the character limit + if (isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { + return ''; + } + + updateComment(''); + setTextInputShouldClear(true); + if (props.isComposerFullSize) { + Report.setIsComposerFullSize(props.reportID, false); + } + setIsFullComposerAvailable(false); + return trimmedComment; + }, [isCommentEmpty, props.reportID, updateComment, props.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; + } + + props.onSubmit(newComment); + + // TODO-S: check props dependency + }, [prepareCommentAndResetComposer, debouncedSaveReportComment, props]); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback((e) => { + // Do not trigger actions for mobileWeb or native clients that have the keyboard open because for those devices, we want the return key to insert newlines rather than submit the form + if (!e || props.isSmallScreenWidth || props.isKeyboardShown) { + return; + } + + if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestedEmojis.length) { + e.preventDefault(); + insertSelectedEmoji(highlightedEmojiIndex); + return; + } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestedEmojis.length) { + e.preventDefault(); + resetSuggestedEmojis(); + 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 && textInput.current.selectionStart === 0 && isCommentEmpty && !ReportUtils.chatIncludesChronos(props.report) + ) { + e.preventDefault(); + + const lastReportAction = _.find( + props.reportActions, + action => ReportUtils.canEditReportAction(action), + ); + + if (lastReportAction !== -1 && lastReportAction) { + Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + } + } + }, []); + + /** + * @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(props.reportID, file, newComment); + setTextInputShouldClear(false); + }, [props.reportID, debouncedSaveReportComment, prepareCommentAndResetComposer]); + + const reportParticipants = _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login); + const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); + const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; + + const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(props.personalDetails, props.report) + && !props.isComposerFullSize; + + // Prevents focusing and showing the keyboard while the drawer is covering the chat. + const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; + const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge); + const inputPlaceholder = getInputPlaceholder(); + const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); + + return ( + + + {shouldShowReportRecipientLocalTime && } + + + {({displayFileInModal}) => ( + <> + + {({openPicker}) => ( + <> + + {props.isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, false); + }} + + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + + )} + {(!props.isComposerFullSize && isFullComposerAvailable) && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, true); + }} + + // Keep focus on the composer when Expand button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButton.current.blur(); + setMenuVisibility(true); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + + setMenuVisibility(false)} + onItemSelected={() => setMenuVisibility(false)} + anchorPosition={styles.createMenuPositionReportActionCompose} + menuItems={[...getMoneyRequestOptions(reportParticipants), ...getTaskOption(reportParticipants), + { + icon: Expensicons.Paperclip, + text: props.translate('reportActionCompose.addAttachment'), + onSelected: () => { + // Set a flag to block emoji 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) { + setShouldBlockEmojiCalc(true); + } + + openPicker({ + onPicked: displayFileInModal, + }); + }, + }, + ]} + /> + + )} + + + setIsDraggingOver(true)} + onDragLeave={() => setIsDraggingOver(false)} + onDrop={(e) => { + e.preventDefault(); + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + + displayFileInModal(file); + + setIsDraggingOver(false); + }} + disabled={props.disabled} + > + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxLines} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + resetSuggestedEmojis(); + }} + onClick={updateShouldBlockEmojiCalcToFalse} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isComposeDisabled || isBlockedFromConcierge || props.disabled} + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={props.isComposerFullSize} + value={value} + numberOfLines={props.numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + onScroll={() => updateShouldShowSuggestionMenuToFalse()} + /> + + + + )} + + {DeviceCapabilities.canUseTouchScreen() && props.isMediumScreenWidth ? null : ( + focus(true)} + onEmojiSelected={addEmojiToTextBox} + /> + )} + e.preventDefault()} + > + + + + + + + + + {!props.isSmallScreenWidth && } + + + + + {isDraggingOver && } + {!_.isEmpty(suggestedEmojis) && shouldShowSuggestionMenu && ( + setHighlightedEmojiIndex(index)} + > + setSuggestedEmojis([])} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestedEmojis} + comment={value} + updateComment={newComment => setValue(newComment)} + colonIndex={colonIndex} + prefix={value.slice(colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={props.isComposerFullSize} + preferredSkinToneIndex={props.preferredSkinTone} + isEmojiPickerLarge={isEmojiPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + + )} + + ); +} + +ReportActionCompose.propTypes = propTypes; +ReportActionCompose.defaultProps = defaultProps; + +export default compose( + withWindowDimensions, + withDrawerState, + withNavigation, + withNavigationFocus, + withLocalize, + withNetwork(), + withPersonalDetails(), + withCurrentUserPersonalDetails, + withKeyboardState, + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, + numberOfLines: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, + }, + modal: { + key: ONYXKEYS.MODAL, + }, + blockedFromConcierge: { + key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, + }, + frequentlyUsedEmojis: { + key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + }, + }), +)(ReportActionCompose); From fc5bab7ed3f93f94575aef8bc36e65cc0f57f0fe Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Mon, 8 May 2023 16:45:04 +0300 Subject: [PATCH 02/74] refactor: in-progress - add few comments, minor changes in callbacks dependencies --- src/pages/home/report/ReportActionComposeF.js | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/pages/home/report/ReportActionComposeF.js b/src/pages/home/report/ReportActionComposeF.js index faa597ccda4c..44639955f8ce 100644 --- a/src/pages/home/report/ReportActionComposeF.js +++ b/src/pages/home/report/ReportActionComposeF.js @@ -186,6 +186,7 @@ function ReportActionCompose(props) { const [value, setValue] = useState(props.comment); // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + // TODO-S: never reset? better to useRef ? const [conciergePlaceholderRandomIndex] = useState(_.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1))); const [suggestedEmojis, setSuggestedEmojis] = useState([]); const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useState(0); @@ -201,9 +202,7 @@ function ReportActionCompose(props) { */ const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); - // TODO_S: not sure if useState is a good solutions here - // previously unsubscribeEscapeKey was written directly into this - // like unsubscribeEscapeKey = KeyboardShortcut.subscribe... + // TODO_S: double check useRefs const unsubscribeEscapeKey = useRef(null); const comment = useRef(props.comment); const textInput = useRef(null); @@ -212,10 +211,10 @@ function ReportActionCompose(props) { /** * Focus the composer text input - * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer + * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer * @memberof ReportActionCompose */ - const focus = useCallback((shouldelay = false) => { + 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(() => { @@ -223,7 +222,7 @@ function ReportActionCompose(props) { return; } - if (!shouldelay) { + if (!shouldDelay) { textInput.current.focus(); } else { // Keyboard is not opened after Emoji Picker is closed @@ -241,6 +240,8 @@ function ReportActionCompose(props) { * * @param {String} comment */ + // move out of component + // TODO-S: move out of component const debouncedSaveReportComment = useCallback(_.debounce( reportComment => Report.saveReportComment(props.reportID, reportComment || ''), 1000, @@ -252,6 +253,7 @@ function ReportActionCompose(props) { * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish * client events. */ + // TODO-S: move out of component const debouncedBroadcastUserIsTyping = useCallback(_.debounce( () => Report.broadcastUserIsTyping(props.reportID), 100, @@ -272,6 +274,7 @@ function ReportActionCompose(props) { setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); setValue(newComment); if (commentValue !== newComment) { + // TODO-S: prevState.value.slice(prevState.selection.end).length; ?? const remainder = value.slice(selection.end).length; setSelection({ start: newComment.length - remainder, @@ -418,6 +421,7 @@ function ReportActionCompose(props) { * @param {Number} pos * @returns {Boolean} */ + // TODO-S: move out of component? const isEmojiCode = useCallback((str, pos) => { const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); const leftWord = _.last(leftWords); @@ -428,36 +432,40 @@ function ReportActionCompose(props) { /** * Calculates and cares about the content of an Emoji Suggester */ - const calculateEmojiSuggestion = useCallback(() => { + const calculateEmojiSuggestion = useCallback(_.debounce( + () => { // TODO-S: think maybe rewrite state to compose solution // to avoid 5 set states - if (!value) { - resetSuggestedEmojis(); - return; - } - if (shouldBlockEmojiCalc) { - setShouldBlockEmojiCalc(false); - return; - } - const leftString = value.substring(0, selection.end); - setColonIndex(leftString.lastIndexOf(':')); - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selection.end); + if (!value) { + resetSuggestedEmojis(); + return; + } + if (shouldBlockEmojiCalc) { + setShouldBlockEmojiCalc(false); + return; + } + const leftString = value.substring(0, selection.end); + setColonIndex(leftString.lastIndexOf(':')); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selection.end); - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; - setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; + setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - setSuggestedEmojis(newSuggestedEmojis); - setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); - } + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + setSuggestedEmojis(newSuggestedEmojis); + setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); + } - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - setHighlightedEmojiIndex(0); - }, [composerHeight, isEmojiCode, value, selection, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc]); + setHighlightedEmojiIndex(0); + }, + 10, + false, + ), [composerHeight, isEmojiCode, value, selection, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc]); const onSelectionChange = useCallback((e) => { setSelection(e.nativeEvent.selection); @@ -572,6 +580,7 @@ function ReportActionCompose(props) { const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + // TODO-S: check if prevState.colonIndex to coloIndex works correct updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); setSelection({ start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -582,7 +591,7 @@ function ReportActionCompose(props) { EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); }, [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment]); - const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, []); + const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, [props.reportActions]); /** * Callback for the emoji picker to add whatever emoji is chosen into the main input @@ -696,7 +705,8 @@ function ReportActionCompose(props) { Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); } } - }, []); + }, [isCommentEmpty, props.isSmallScreenWidth, props.report, props.reportActions, props.reportID, resetSuggestedEmojis, + submitForm, suggestedEmojis.length, props.isKeyboardShown, highlightedEmojiIndex, insertSelectedEmoji]); /** * @param {Object} file @@ -721,6 +731,8 @@ function ReportActionCompose(props) { // Prevents focusing and showing the keyboard while the drawer is covering the chat. const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge); + + // TODO-S: move to component prop without extra const? const inputPlaceholder = getInputPlaceholder(); const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); @@ -939,7 +951,7 @@ function ReportActionCompose(props) { > {!props.isSmallScreenWidth && } - + {isDraggingOver && } From 07119176840552a7d02a58582ede7c79981175c4 Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Tue, 9 May 2023 00:15:25 +0300 Subject: [PATCH 03/74] refactor: WIP - move few functions out of component, replace some useCallback with use memo, fix bug with emoji show only after 3 symbols --- src/pages/home/report/ReportActionComposeF.js | 180 ++++++++---------- 1 file changed, 81 insertions(+), 99 deletions(-) diff --git a/src/pages/home/report/ReportActionComposeF.js b/src/pages/home/report/ReportActionComposeF.js index 44639955f8ce..19a84f309dfa 100644 --- a/src/pages/home/report/ReportActionComposeF.js +++ b/src/pages/home/report/ReportActionComposeF.js @@ -1,5 +1,5 @@ import React, { - useCallback, useEffect, useRef, useState, + useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import PropTypes from 'prop-types'; import { @@ -163,6 +163,34 @@ const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); +/** + * 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.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const leftWord = _.last(leftWords); + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; +}; function ReportActionCompose(props) { /** * Updates the Highlight state of the composer @@ -185,9 +213,6 @@ function ReportActionCompose(props) { const [maxLines, setMaxLines] = useState(props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES); const [value, setValue] = useState(props.comment); - // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions - // TODO-S: never reset? better to useRef ? - const [conciergePlaceholderRandomIndex] = useState(_.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1))); const [suggestedEmojis, setSuggestedEmojis] = useState([]); const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useState(0); const [colonIndex, setColonIndex] = useState(-1); @@ -209,6 +234,25 @@ function ReportActionCompose(props) { const actionButton = useRef(null); const prevPropsRef = useRef(); + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + const conciergePlaceholderRandomIndex = useMemo( + () => _.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1)), + [props.isSmallScreenWidth, props.translate], + ); + + // Placeholder to display in the chat input. + const inputPlaceholder = useMemo(() => { + if (ReportUtils.chatIncludesConcierge(props.report)) { + if (User.isBlockedFromConcierge(props.blockedFromConcierge)) { + return props.translate('reportActionCompose.blockedFromConcierge'); + } + + return props.translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; + } + + return props.translate('reportActionCompose.writeSomething'); + }, [props.translate, props.report, props.blockedFromConcierge, conciergePlaceholderRandomIndex]); + /** * Focus the composer text input * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer @@ -218,7 +262,7 @@ function ReportActionCompose(props) { // There could be other animations running while we trigger manual focus. // This prevents focus from making those animations janky. InteractionManager.runAfterInteractions(() => { - if (!textInput) { + if (!textInput.current) { return; } @@ -234,33 +278,6 @@ function ReportActionCompose(props) { }); }, []); - /** - * Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often - * to update Onyx and re-render this component. - * - * @param {String} comment - */ - // move out of component - // TODO-S: move out of component - const debouncedSaveReportComment = useCallback(_.debounce( - reportComment => Report.saveReportComment(props.reportID, reportComment || ''), - 1000, - false, - ), - [props.reportID]); - - /** - * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish - * client events. - */ - // TODO-S: move out of component - const debouncedBroadcastUserIsTyping = useCallback(_.debounce( - () => Report.broadcastUserIsTyping(props.reportID), - 100, - true, - ), - [props.reportID]); - /** * Update the value of the comment in Onyx * @@ -270,11 +287,9 @@ function ReportActionCompose(props) { const updateComment = useCallback((commentValue, shouldDebounceSaveComment) => { const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); - // TODO-S: prevState check again setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); setValue(newComment); if (commentValue !== newComment) { - // TODO-S: prevState.value.slice(prevState.selection.end).length; ?? const remainder = value.slice(selection.end).length; setSelection({ start: newComment.length - remainder, @@ -294,14 +309,14 @@ function ReportActionCompose(props) { comment.current = newComment; if (shouldDebounceSaveComment) { - debouncedSaveReportComment(newComment); + debouncedSaveReportComment(props.reportID, newComment); } else { Report.saveReportComment(props.reportID, newComment || ''); } if (newComment) { - debouncedBroadcastUserIsTyping(); + debouncedBroadcastUserIsTyping(props.reportID); } - }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value, debouncedSaveReportComment, debouncedBroadcastUserIsTyping]); + }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value]); /** * Set the maximum number of lines for the composer @@ -353,19 +368,20 @@ function ReportActionCompose(props) { showPopoverMenu, }); } + return () => { ReportActionComposeFocusManager.clear(); if (unsubscribeEscapeKey) { - unsubscribeEscapeKey(); + unsubscribeEscapeKey.current(); } }; }, []); + // TODO: still under discussion - might use another approach to migrate ComponentDidUpdate useEffect(() => { const prevProps = prevPropsRef.current; - // TODO-S: check if prevprops exist? if (prevProps) { const sidebarOpened = !prevProps.isDrawerOpen && props.isDrawerOpen; if (sidebarOpened) { @@ -415,27 +431,11 @@ function ReportActionCompose(props) { setIsEmojiPickerLarge(true); }, []); - /** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ - // TODO-S: move out of component? - const isEmojiCode = useCallback((str, pos) => { - const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - const leftWord = _.last(leftWords); - - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; - }, []); - /** * Calculates and cares about the content of an Emoji Suggester */ - const calculateEmojiSuggestion = useCallback(_.debounce( - () => { - // TODO-S: think maybe rewrite state to compose solution - // to avoid 5 set states + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { if (!value) { resetSuggestedEmojis(); return; @@ -444,32 +444,37 @@ function ReportActionCompose(props) { setShouldBlockEmojiCalc(false); return; } - const leftString = value.substring(0, selection.end); - setColonIndex(leftString.lastIndexOf(':')); - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selection.end); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; - setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); - + const leftString = value.substring(0, selectionEnd); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { setSuggestedEmojis(newSuggestedEmojis); setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); + } else { + setSuggestedEmojis([]); + setShouldShowSuggestionMenu(false); } LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; + setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); + setColonIndex(leftString.lastIndexOf(':')); setHighlightedEmojiIndex(0); - }, - 10, - false, - ), [composerHeight, isEmojiCode, value, selection, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc]); + }, [composerHeight, value, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc], + ); const onSelectionChange = useCallback((e) => { setSelection(e.nativeEvent.selection); - calculateEmojiSuggestion(); + + /** + * 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]); /** @@ -483,24 +488,6 @@ function ReportActionCompose(props) { textInput.current = el; }, []); - /** - * Get the placeholder to display in the chat input. - * - * @return {String} - */ - const getInputPlaceholder = useCallback(() => { - if (ReportUtils.chatIncludesConcierge(props.report)) { - if (User.isBlockedFromConcierge(props.blockedFromConcierge)) { - return props.translate('reportActionCompose.blockedFromConcierge'); - } - - return props.translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; - } - - // TODO-S: check dependency array for props (do we need it at all?) - return props.translate('reportActionCompose.writeSomething'); - }, [props, conciergePlaceholderRandomIndex]); - /** * Returns the list of IOU Options * @@ -526,11 +513,10 @@ function ReportActionCompose(props) { }, }; - // TODO-S: check for props in dependency array + // TODO: check for props in dependency array return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); }, [props]); - // TODO-S: check if eslint rule still valid // eslint-disable-next-line rulesdir/prefer-early-return const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (shouldShowSuggestionMenu) { @@ -567,7 +553,7 @@ function ReportActionCompose(props) { }, ]; - // TODO-S: check for props in dependency array + // TODO: check for props in dependency array }, [props]); /** @@ -580,7 +566,7 @@ function ReportActionCompose(props) { const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - // TODO-S: check if prevState.colonIndex to coloIndex works correct + // TODO: check if prevState.colonIndex to coloIndex works correct updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); setSelection({ start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -604,8 +590,6 @@ function ReportActionCompose(props) { end: selection.start + emoji.length, }); - // TODO-S: pay attention here to selection passing (maybe need to - // create selection above and pass same value to setState above and selection below?) updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); }, [selection, updateComment]); @@ -659,8 +643,8 @@ function ReportActionCompose(props) { props.onSubmit(newComment); - // TODO-S: check props dependency - }, [prepareCommentAndResetComposer, debouncedSaveReportComment, props]); + // TODO: check props dependency + }, [prepareCommentAndResetComposer, props]); /** * Listens for keyboard shortcuts and applies the action @@ -719,7 +703,7 @@ function ReportActionCompose(props) { const newComment = prepareCommentAndResetComposer(); Report.addAttachment(props.reportID, file, newComment); setTextInputShouldClear(false); - }, [props.reportID, debouncedSaveReportComment, prepareCommentAndResetComposer]); + }, [props.reportID, prepareCommentAndResetComposer]); const reportParticipants = _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login); const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); @@ -732,8 +716,6 @@ function ReportActionCompose(props) { const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge); - // TODO-S: move to component prop without extra const? - const inputPlaceholder = getInputPlaceholder(); const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); return ( From c116987a184672251c68250c2df87fcb56ccaadc Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Tue, 9 May 2023 00:37:08 +0300 Subject: [PATCH 04/74] refactor: WIP - move code from ReportActionComposeF to ReportAction compose (replace class file with function one) --- src/pages/home/report/ReportActionCompose.js | 1319 +++++++++--------- 1 file changed, 634 insertions(+), 685 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 5e589b499898..19a84f309dfa 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { + useCallback, useEffect, useMemo, useRef, useState, +} from 'react'; import PropTypes from 'prop-types'; import { View, @@ -24,7 +26,7 @@ import PopoverMenu from '../../../components/PopoverMenu'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import withDrawerState from '../../../components/withDrawerState'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutside from '../../../libs/willBlurTextInputOnTapOutside'; +import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; import CONST from '../../../CONST'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; @@ -159,169 +161,321 @@ const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { return emojiRowCount - 1; }; -class ReportActionCompose extends React.Component { - constructor(props) { - super(props); - this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); - this.updateComment = this.updateComment.bind(this); - this.debouncedSaveReportComment = _.debounce(this.debouncedSaveReportComment.bind(this), 1000, false); - this.debouncedBroadcastUserIsTyping = _.debounce(this.debouncedBroadcastUserIsTyping.bind(this), 100, true); - this.triggerHotkeyActions = this.triggerHotkeyActions.bind(this); - this.submitForm = this.submitForm.bind(this); - this.setIsFocused = this.setIsFocused.bind(this); - this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this); - this.focus = this.focus.bind(this); - this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); - this.onSelectionChange = this.onSelectionChange.bind(this); - this.isEmojiCode = this.isEmojiCode.bind(this); - this.setTextInputRef = this.setTextInputRef.bind(this); - this.getInputPlaceholder = this.getInputPlaceholder.bind(this); - this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); - this.getTaskOption = this.getTaskOption.bind(this); - this.addAttachment = this.addAttachment.bind(this); - this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); - this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); - this.updateNumberOfLines = this.updateNumberOfLines.bind(this); - this.showPopoverMenu = this.showPopoverMenu.bind(this); - this.comment = props.comment; - this.setShouldBlockEmojiCalcToFalse = this.setShouldBlockEmojiCalcToFalse.bind(this); - - // React Native will retain focus on an input for native devices but web/mWeb behave differently so we have some focus management - // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager - this.willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutside(); - - this.state = { - isFocused: this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible, - isFullComposerAvailable: props.isComposerFullSize, - textInputShouldClear: false, - isCommentEmpty: props.comment.length === 0, - isMenuVisible: false, - isDraggingOver: false, - selection: { - start: props.comment.length, - end: props.comment.length, - }, - maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, - value: props.comment, - - // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions - conciergePlaceholderRandomIndex: _.random(this.props.translate('reportActionCompose.conciergePlaceholderOptions').length - (this.props.isSmallScreenWidth ? 4 : 1)), - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex: -1, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge: false, - composerHeight: 0, - hasExceededMaxCommentLength: false, - }; - } +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +/** + * 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.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const leftWord = _.last(leftWords); + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; +}; +function ReportActionCompose(props) { + /** + * Updates the Highlight state of the composer + */ + const [isFocused, setIsFocused] = useState(willBlurTextInputOnTapOutside && !props.modal.isVisible && !props.modal.willAlertModalBecomeVisible); + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(props.isComposerFullSize); + + /** + * Updates the should clear state of the composer + */ + const [textInputShouldClear, setTextInputShouldClear] = useState(false); + const [isCommentEmpty, setIsCommentEmpty] = useState(props.comment.length === 0); + + /** + * Updates the visibility state of the menu + */ + const [isMenuVisible, setMenuVisibility] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [selection, setSelection] = useState({start: props.comment.length, end: props.comment.length}); + const [maxLines, setMaxLines] = useState(props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES); + const [value, setValue] = useState(props.comment); + + const [suggestedEmojis, setSuggestedEmojis] = useState([]); + const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useState(0); + const [colonIndex, setColonIndex] = useState(-1); + const [shouldShowSuggestionMenu, setShouldShowSuggestionMenu] = useState(false); + const [shouldBlockEmojiCalc, setShouldBlockEmojiCalc] = useState(false); + const [isEmojiPickerLarge, setIsEmojiPickerLarge] = useState(false); + const [composerHeight, setComposerHeight] = useState(0); + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + */ + const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); + + // TODO_S: double check useRefs + const unsubscribeEscapeKey = useRef(null); + const comment = useRef(props.comment); + const textInput = useRef(null); + const actionButton = useRef(null); + const prevPropsRef = useRef(); + + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions + const conciergePlaceholderRandomIndex = useMemo( + () => _.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1)), + [props.isSmallScreenWidth, props.translate], + ); + + // Placeholder to display in the chat input. + const inputPlaceholder = useMemo(() => { + if (ReportUtils.chatIncludesConcierge(props.report)) { + if (User.isBlockedFromConcierge(props.blockedFromConcierge)) { + return props.translate('reportActionCompose.blockedFromConcierge'); + } + + return props.translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; + } + + return props.translate('reportActionCompose.writeSomething'); + }, [props.translate, props.report, props.blockedFromConcierge, conciergePlaceholderRandomIndex]); + + /** + * 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 (!textInput.current) { + return; + } + + if (!shouldDelay) { + textInput.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(() => textInput.current.focus(), 100); + } + }); + }, []); + + /** + * Update the value of the comment in Onyx + * + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment + */ + const updateComment = useCallback((commentValue, shouldDebounceSaveComment) => { + const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); + + setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + setValue(newComment); + if (commentValue !== newComment) { + const remainder = value.slice(selection.end).length; + setSelection({ + start: newComment.length - remainder, + end: newComment.length - remainder, + }); + } + + // Indicate that draft has been created. + if (comment.current.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(props.reportID, true); + } + + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(props.reportID, false); + } - componentDidMount() { + comment.current = newComment; + if (shouldDebounceSaveComment) { + debouncedSaveReportComment(props.reportID, newComment); + } else { + Report.saveReportComment(props.reportID, newComment || ''); + } + if (newComment) { + debouncedBroadcastUserIsTyping(props.reportID); + } + }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value]); + + /** + * Set the maximum number of lines for the composer + */ + const updateMaxLines = useCallback(() => { + let maxLinesNumber = props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + if (props.isComposerFullSize) { + maxLinesNumber = CONST.COMPOSER.MAX_LINES_FULL; + } + setMaxLines(maxLinesNumber); + }, [props.isSmallScreenWidth, props.isComposerFullSize]); + + /** + * Used to show Popover menu on Workspace chat at first sign-in + * @returns {Boolean} + */ + const showPopoverMenu = useCallback(() => { + setMenuVisibility(true); + return true; + }, []); + + useEffect(() => { // This callback is used in the contextMenuActions to manage giving focus back to the compose input. // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component ReportActionComposeFocusManager.onComposerFocus(() => { - if (!this.willBlurTextInputOnTapOutside || !this.props.isFocused) { + if (!willBlurTextInputOnTapOutside || !props.isFocused) { return; } - this.focus(false); + focus(false); }); const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; - this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { - if (!this.state.isFocused || this.comment.length === 0) { + unsubscribeEscapeKey.current = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + if (!isFocused || comment.current.length === 0) { return; } - this.updateComment('', true); + updateComment('', true); }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); - this.setMaxLines(); - this.updateComment(this.comment); + updateMaxLines(); + updateComment(comment.current); // Shows Popover Menu on Workspace Chat at first sign-in - if (!this.props.disabled) { + if (!props.disabled) { Welcome.show({ - routes: lodashGet(this.props.navigation.getState(), 'routes', []), - showPopoverMenu: this.showPopoverMenu, + routes: lodashGet(props.navigation.getState(), 'routes', []), + showPopoverMenu, }); } - } - componentDidUpdate(prevProps) { - const sidebarOpened = !prevProps.isDrawerOpen && this.props.isDrawerOpen; - if (sidebarOpened) { - toggleReportActionComposeView(true); - } + return () => { + ReportActionComposeFocusManager.clear(); - // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (this.willBlurTextInputOnTapOutside && this.props.isFocused - && prevProps.modal.isVisible && !this.props.modal.isVisible) { - this.focus(); - } + if (unsubscribeEscapeKey) { + unsubscribeEscapeKey.current(); + } + }; + }, []); - if (this.props.isComposerFullSize !== prevProps.isComposerFullSize) { - this.setMaxLines(); - } + // TODO: still under discussion - might use another approach to migrate ComponentDidUpdate + useEffect(() => { + const prevProps = prevPropsRef.current; - // Value state does not have the same value as comment props when the comment gets changed from another tab. - // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevProps.comment !== this.props.comment && this.state.value !== this.props.comment; + if (prevProps) { + const sidebarOpened = !prevProps.isDrawerOpen && props.isDrawerOpen; + if (sidebarOpened) { + toggleReportActionComposeView(true); + } - // As the report IDs change, make sure to update the composer comment as we need to make sure - // we do not show incorrect data in there (ie. draft of message from other report). - if (this.props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { - return; - } + // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (willBlurTextInputOnTapOutside && props.isFocused + && prevProps && prevProps.modal.isVisible && !props.modal.isVisible) { + focus(); + } - this.updateComment(this.props.comment); - } + if (props.isComposerFullSize !== prevProps.isComposerFullSize) { + updateMaxLines(); + } - componentWillUnmount() { - ReportActionComposeFocusManager.clear(); + // Value state does not have the same value as comment props when the comment gets changed from another tab. + // In this case, we should synchronize the value between tabs. + const shouldSyncComment = prevProps.comment !== props.comment && value !== props.comment; - if (this.unsubscribeEscapeKey) { - this.unsubscribeEscapeKey(); - } - } + if (props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { + return; + } - onSelectionChange(e) { - this.setState({selection: e.nativeEvent.selection}); - this.calculateEmojiSuggestion(); - } + updateComment(comment.current); + } + prevPropsRef.current = { + isDrawerOpen: props.isDrawerOpen, + isFocused: props.isFocused, + modal: props.modal, + isComposerFullSize: props.isComposerFullSize, + comment: props.comment, + report: props.report, + }; + }, [props.isDrawerOpen, props.isFocused, props.modal, props.isComposerFullSize, props.comment, props.report, focus, updateMaxLines, value, updateComment]); /** - * Updates the Highlight state of the composer - * - * @param {Boolean} shouldHighlight + * Clean data related to EmojiSuggestions */ - setIsFocused(shouldHighlight) { - this.setState({isFocused: shouldHighlight}); - } - - setIsFullComposerAvailable(isFullComposerAvailable) { - this.setState({isFullComposerAvailable}); - } + const resetSuggestedEmojis = useCallback(() => { + setSuggestedEmojis([]); + setHighlightedEmojiIndex(0); + setColonIndex(-1); + setShouldShowSuggestionMenu(false); + setIsEmojiPickerLarge(true); + }, []); /** - * Updates the should clear state of the composer - * - * @param {Boolean} shouldClear + * Calculates and cares about the content of an Emoji Suggester */ - setTextInputShouldClear(shouldClear) { - this.setState({textInputShouldClear: shouldClear}); - } + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { + if (!value) { + resetSuggestedEmojis(); + return; + } + if (shouldBlockEmojiCalc) { + setShouldBlockEmojiCalc(false); + return; + } + const leftString = value.substring(0, selectionEnd); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); - /** - * Updates the visibility state of the menu - * - * @param {Boolean} isMenuVisible - */ - setMenuVisibility(isMenuVisible) { - this.setState({isMenuVisible}); - } + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + setSuggestedEmojis(newSuggestedEmojis); + setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); + } else { + setSuggestedEmojis([]); + setShouldShowSuggestionMenu(false); + } + + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; + setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); + setColonIndex(leftString.lastIndexOf(':')); + setHighlightedEmojiIndex(0); + }, [composerHeight, value, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc], + ); + + const onSelectionChange = useCallback((e) => { + setSelection(e.nativeEvent.selection); + + /** + * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion + * because in other case calculateEmojiSuggestion will have an old calculation value + * of suggestion instead of current one + */ + calculateEmojiSuggestion(e.nativeEvent.selection.end); + }, [calculateEmojiSuggestion]); /** * Set the TextInput Ref @@ -329,27 +483,10 @@ class ReportActionCompose extends React.Component { * @param {Element} el * @memberof ReportActionCompose */ - setTextInputRef(el) { + const setTextInputRef = useCallback((el) => { ReportActionComposeFocusManager.composerRef.current = el; - this.textInput = el; - } - - /** - * Get the placeholder to display in the chat input. - * - * @return {String} - */ - getInputPlaceholder() { - if (ReportUtils.chatIncludesConcierge(this.props.report)) { - if (User.isBlockedFromConcierge(this.props.blockedFromConcierge)) { - return this.props.translate('reportActionCompose.blockedFromConcierge'); - } - - return this.props.translate('reportActionCompose.conciergePlaceholderOptions')[this.state.conciergePlaceholderRandomIndex]; - } - - return this.props.translate('reportActionCompose.writeSomething'); - } + textInput.current = el; + }, []); /** * Returns the list of IOU Options @@ -357,70 +494,51 @@ class ReportActionCompose extends React.Component { * @param {Array} reportParticipants * @returns {Array} */ - getMoneyRequestOptions(reportParticipants) { + const getMoneyRequestOptions = useCallback((reportParticipants) => { const options = { [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { icon: Expensicons.Receipt, - text: this.props.translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(this.props.reportID)), + text: props.translate('iou.splitBill'), + onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(props.reportID)), }, [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { icon: Expensicons.MoneyCircle, - text: this.props.translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(this.props.reportID)), + text: props.translate('iou.requestMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(props.reportID)), }, [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { icon: Expensicons.Send, - text: this.props.translate('iou.sendMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(this.props.reportID)), + text: props.translate('iou.sendMoney'), + onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(props.reportID)), }, }; - return _.map(ReportUtils.getMoneyRequestOptions(this.props.report, reportParticipants, this.props.betas), option => options[option]); - } - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - * - * @param {Boolean} hasExceededMaxCommentLength - */ - setExceededMaxCommentLength(hasExceededMaxCommentLength) { - this.setState({hasExceededMaxCommentLength}); - } - - /** - * Set the maximum number of lines for the composer - */ - setMaxLines() { - let maxLines = this.props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - if (this.props.isComposerFullSize) { - maxLines = CONST.COMPOSER.MAX_LINES_FULL; - } - this.setState({maxLines}); - } + // TODO: check for props in dependency array + return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); + }, [props]); // eslint-disable-next-line rulesdir/prefer-early-return - setShouldShowSuggestionMenuToFalse() { - if (this.state && this.state.shouldShowSuggestionMenu) { - this.setState({shouldShowSuggestionMenu: false}); + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (shouldShowSuggestionMenu) { + setShouldShowSuggestionMenu(false); } - } + }, [shouldShowSuggestionMenu]); // eslint-disable-next-line rulesdir/prefer-early-return - setShouldBlockEmojiCalcToFalse() { - if (this.state && this.state.shouldBlockEmojiCalc) { - this.setState({shouldBlockEmojiCalc: false}); + const updateShouldBlockEmojiCalcToFalse = useCallback(() => { + if (shouldBlockEmojiCalc) { + setShouldBlockEmojiCalc(false); } - } + }, [shouldBlockEmojiCalc]); /** * Determines if we can show the task option * @param {Array} reportParticipants * @returns {Boolean} */ - getTaskOption(reportParticipants) { + const getTaskOption = useCallback((reportParticipants) => { // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email - if (!Permissions.canUseTasks(this.props.betas) || (lodashGet(this.props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( + if (!Permissions.canUseTasks(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( CONST.EXPENSIFY_EMAILS, email, )))) { @@ -430,590 +548,421 @@ class ReportActionCompose extends React.Component { return [ { icon: Expensicons.Task, - text: this.props.translate('newTaskPage.assignTask'), - onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(this.props.reportID)), + text: props.translate('newTaskPage.assignTask'), + onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(props.reportID)), }, ]; - } - /** - * Clean data related to EmojiSuggestions - */ - resetSuggestedEmojis() { - this.setState({ - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex: -1, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge: true, - }); - } - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - calculateEmojiSuggestion() { - if (!this.state.value) { - this.resetSuggestedEmojis(); - return; - } - if (this.state.shouldBlockEmojiCalc) { - this.setState({shouldBlockEmojiCalc: false}); - return; - } - const leftString = this.state.value.substring(0, this.state.selection.end); - const colonIndex = leftString.lastIndexOf(':'); - const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.selection.end); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = this.props.windowHeight / this.state.composerHeight >= 6.8; - const isEmojiPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - - const nextState = { - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge, - }; - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); - - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis); - } - - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - - this.setState(nextState); - } - - /** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ - isEmojiCode(str, pos) { - const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - const leftWord = _.last(leftWords); - - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; - } + // TODO: check for props in dependency array + }, [props]); /** * Replace the code of emoji and update selection * @param {Number} highlightedEmojiIndex */ - insertSelectedEmoji(highlightedEmojiIndex) { - const commentBeforeColon = this.state.value.slice(0, this.state.colonIndex); - const emojiObject = this.state.suggestedEmojis[highlightedEmojiIndex]; - const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - - this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); - this.setState(prevState => ({ - selection: { - start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }, - suggestedEmojis: [], - })); - EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject); - } + const insertSelectedEmoji = useCallback((selectedEmoji) => { + const commentBeforeColon = value.slice(0, colonIndex); + const emojiObject = suggestedEmojis[selectedEmoji]; + const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + + // TODO: check if prevState.colonIndex to coloIndex works correct + updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + setSelection({ + start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestedEmojis([]); - isEmptyChat() { - return _.size(this.props.reportActions) === 1; - } + EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); + }, [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment]); + + const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, [props.reportActions]); /** * Callback for the emoji picker to add whatever emoji is chosen into the main input * * @param {String} emoji */ - addEmojiToTextBox(emoji) { - this.setState(prevState => ({ - selection: { - start: prevState.selection.start + emoji.length, - end: prevState.selection.start + emoji.length, - }, - })); - this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, emoji)); - } - - /** - * Focus the composer text input - * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose - */ - focus(shouldelay = false) { - // There could be other animations running while we trigger manual focus. - // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!this.textInput) { - return; - } - - if (!shouldelay) { - this.textInput.focus(); - } else { - // Keyboard is not opened after Emoji Picker is closed - // SetTimeout is used as a workaround - // https://github.com/react-native-modal/react-native-modal/issues/114 - // We carefully choose a delay. 100ms is found enough for keyboard to open. - setTimeout(() => this.textInput.focus(), 100); - } + const addEmojiToTextBox = useCallback((emoji) => { + setSelection({ + start: selection.start + emoji.length, + end: selection.start + emoji.length, }); - } - /** - * Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often - * to update Onyx and re-render this component. - * - * @param {String} comment - */ - debouncedSaveReportComment(comment) { - Report.saveReportComment(this.props.reportID, comment || ''); - } + updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); + }, [selection, updateComment]); /** - * Broadcast that the user is typing. We debounce this method in the constructor to limit how often we publish - * client events. + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines */ - debouncedBroadcastUserIsTyping() { - Report.broadcastUserIsTyping(this.props.reportID); - } + const updateNumberOfLines = useCallback((numberOfLines) => { + Report.saveReportCommentNumberOfLines(props.reportID, numberOfLines); + }, [props.reportID]); /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment + * @returns {String} */ - updateComment(comment, shouldDebounceSaveComment) { - const newComment = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); - this.setState((prevState) => { - const newState = { - isCommentEmpty: !!newComment.match(/^(\s)*$/), - value: newComment, - }; - if (comment !== newComment) { - const remainder = prevState.value.slice(prevState.selection.end).length; - newState.selection = { - start: newComment.length - remainder, - end: newComment.length - remainder, - }; - } - return newState; - }); + const prepareCommentAndResetComposer = useCallback(() => { + const trimmedComment = comment.current.trim(); - // Indicate that draft has been created. - if (this.comment.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(this.props.reportID, true); + // Don't submit empty comments or comments that exceed the character limit + if (isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { + return ''; } - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(this.props.reportID, false); + updateComment(''); + setTextInputShouldClear(true); + if (props.isComposerFullSize) { + Report.setIsComposerFullSize(props.reportID, false); } + setIsFullComposerAvailable(false); + return trimmedComment; + }, [isCommentEmpty, props.reportID, updateComment, props.isComposerFullSize]); - this.comment = newComment; - if (shouldDebounceSaveComment) { - this.debouncedSaveReportComment(newComment); - } else { - Report.saveReportComment(this.props.reportID, newComment || ''); + /** + * Add a new comment to this chat + * + * @param {SyntheticEvent} [e] + */ + const submitForm = useCallback((e) => { + if (e) { + e.preventDefault(); } - if (newComment) { - this.debouncedBroadcastUserIsTyping(); + + // 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; } - } - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - updateNumberOfLines(numberOfLines) { - Report.saveReportCommentNumberOfLines(this.props.reportID, numberOfLines); - } + props.onSubmit(newComment); + + // TODO: check props dependency + }, [prepareCommentAndResetComposer, props]); /** * Listens for keyboard shortcuts and applies the action * * @param {Object} e */ - triggerHotkeyActions(e) { + const triggerHotkeyActions = useCallback((e) => { // Do not trigger actions for mobileWeb or native clients that have the keyboard open because for those devices, we want the return key to insert newlines rather than submit the form - if (!e || this.props.isSmallScreenWidth || this.props.isKeyboardShown) { + if (!e || props.isSmallScreenWidth || props.isKeyboardShown) { return; } - if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && this.state.suggestedEmojis.length) { + if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestedEmojis.length) { e.preventDefault(); - this.insertSelectedEmoji(this.state.highlightedEmojiIndex); + insertSelectedEmoji(highlightedEmojiIndex); return; } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && this.state.suggestedEmojis.length) { + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestedEmojis.length) { e.preventDefault(); - this.resetSuggestedEmojis(); + resetSuggestedEmojis(); return; } // Submit the form when Enter is pressed if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { e.preventDefault(); - this.submitForm(); + submitForm(); } // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants if ( - e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && this.textInput.selectionStart === 0 && this.state.isCommentEmpty && !ReportUtils.chatIncludesChronos(this.props.report) + e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInput.current.selectionStart === 0 && isCommentEmpty && !ReportUtils.chatIncludesChronos(props.report) ) { e.preventDefault(); const lastReportAction = _.find( - this.props.reportActions, + props.reportActions, action => ReportUtils.canEditReportAction(action), ); if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(this.props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); } } - } - - /** - * @returns {String} - */ - prepareCommentAndResetComposer() { - const trimmedComment = this.comment.trim(); - - // Don't submit empty comments or comments that exceed the character limit - if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - this.updateComment(''); - this.setTextInputShouldClear(true); - if (this.props.isComposerFullSize) { - Report.setIsComposerFullSize(this.props.reportID, false); - } - this.setState({isFullComposerAvailable: false}); - - return trimmedComment; - } + }, [isCommentEmpty, props.isSmallScreenWidth, props.report, props.reportActions, props.reportID, resetSuggestedEmojis, + submitForm, suggestedEmojis.length, props.isKeyboardShown, highlightedEmojiIndex, insertSelectedEmoji]); /** * @param {Object} file */ - addAttachment(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 - this.debouncedSaveReportComment.cancel(); - const comment = this.prepareCommentAndResetComposer(); - Report.addAttachment(this.props.reportID, file, comment); - this.setTextInputShouldClear(false); - } - - /** - * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] - */ - submitForm(e) { - if (e) { - e.preventDefault(); - } - - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - this.debouncedSaveReportComment.cancel(); - - const comment = this.prepareCommentAndResetComposer(); - if (!comment) { - return; - } - - this.props.onSubmit(comment); - } - - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - showPopoverMenu() { - this.setMenuVisibility(true); - return true; - } - - render() { - const reportParticipants = _.without(lodashGet(this.props.report, 'participants', []), this.props.currentUserPersonalDetails.login); - const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); - const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyEmails[0]]; - - const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report) - && !this.props.isComposerFullSize; - - // Prevents focusing and showing the keyboard while the drawer is covering the chat. - const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth; - const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); - const inputPlaceholder = this.getInputPlaceholder(); - const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); - const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; - - return ( - + - } + - {shouldShowReportRecipientLocalTime && } - - - {({displayFileInModal}) => ( - <> - - {({openPicker}) => ( - <> - - {this.props.isComposerFullSize && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, false); - }} + {({displayFileInModal}) => ( + <> + + {({openPicker}) => ( + <> + + {props.isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, false); + }} // Keep focus on the composer when Collapse button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - - )} - {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, true); - }} + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + + )} + {(!props.isComposerFullSize && isFullComposerAvailable) && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, true); + }} // Keep focus on the composer when Expand button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - )} - - this.actionButton = el} - onPress={(e) => { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - this.actionButton.blur(); - this.setMenuVisibility(true); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - - this.setMenuVisibility(false)} - onItemSelected={() => this.setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...this.getMoneyRequestOptions(reportParticipants), ...this.getTaskOption(reportParticipants), - { - icon: Expensicons.Paperclip, - text: this.props.translate('reportActionCompose.addAttachment'), - onSelected: () => { - // Set a flag to block emoji calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (this.willBlurTextInputOnTapOutside) { - this.setState({shouldBlockEmojiCalc: true}); - } - - openPicker({ - onPicked: displayFileInModal, - }); - }, + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButton.current.blur(); + setMenuVisibility(true); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + + + setMenuVisibility(false)} + onItemSelected={() => setMenuVisibility(false)} + anchorPosition={styles.createMenuPositionReportActionCompose} + menuItems={[...getMoneyRequestOptions(reportParticipants), ...getTaskOption(reportParticipants), + { + icon: Expensicons.Paperclip, + text: props.translate('reportActionCompose.addAttachment'), + onSelected: () => { + // Set a flag to block emoji 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) { + setShouldBlockEmojiCalc(true); + } + + openPicker({ + onPicked: displayFileInModal, + }); }, - ]} - /> - - )} - - - { - this.setState({isDraggingOver: true}); + }, + ]} + /> + + )} + + + setIsDraggingOver(true)} + onDragLeave={() => setIsDraggingOver(false)} + onDrop={(e) => { + e.preventDefault(); + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + + displayFileInModal(file); + + setIsDraggingOver(false); + }} + disabled={props.disabled} + > + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxLines} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + resetSuggestedEmojis(); }} - onDragLeave={() => { - this.setState({isDraggingOver: false}); + onClick={updateShouldBlockEmojiCalcToFalse} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isComposeDisabled || isBlockedFromConcierge || props.disabled} + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={props.isComposerFullSize} + value={value} + numberOfLines={props.numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); }} - onDrop={(e) => { - e.preventDefault(); - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - this.setState({isDraggingOver: false}); - }} - disabled={this.props.disabled} - > - this.updateComment(comment, true)} - onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={this.state.maxLines} - onFocus={() => this.setIsFocused(true)} - onBlur={() => { - this.setIsFocused(false); - this.resetSuggestedEmojis(); - }} - onClick={this.setShouldBlockEmojiCalcToFalse} - onPasteFile={displayFileInModal} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={this.state.isFullComposerAvailable} - setIsFullComposerAvailable={this.setIsFullComposerAvailable} - isComposerFullSize={this.props.isComposerFullSize} - value={this.state.value} - numberOfLines={this.props.numberOfLines} - onNumberOfLinesChange={this.updateNumberOfLines} - onLayout={(e) => { - const composerHeight = e.nativeEvent.layout.height; - if (this.state.composerHeight === composerHeight) { - return; - } - this.setState({composerHeight}); - }} - onScroll={() => this.setShouldShowSuggestionMenuToFalse()} - /> - - - - )} - - {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( - { - this.focus(true); - }} - onEmojiSelected={this.addEmojiToTextBox} - /> + onScroll={() => updateShouldShowSuggestionMenuToFalse()} + /> + + + )} - + {DeviceCapabilities.canUseTouchScreen() && props.isMediumScreenWidth ? null : ( + focus(true)} + onEmojiSelected={addEmojiToTextBox} + /> + )} + e.preventDefault()} - > - - - - - - - - e.preventDefault()} > - {!this.props.isSmallScreenWidth && } - - + + + + + - - {this.state.isDraggingOver && } - {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && ( - this.setState({highlightedEmojiIndex: index})} - > - this.setState({suggestedEmojis: []})} - highlightedEmojiIndex={this.state.highlightedEmojiIndex} - emojis={this.state.suggestedEmojis} - comment={this.state.value} - updateComment={newComment => this.setState({value: newComment})} - colonIndex={this.state.colonIndex} - prefix={this.state.value.slice(this.state.colonIndex + 1, this.state.selection.start)} - onSelect={this.insertSelectedEmoji} - isComposerFullSize={this.props.isComposerFullSize} - preferredSkinToneIndex={this.props.preferredSkinTone} - isEmojiPickerLarge={this.state.isEmojiPickerLarge} - composerHeight={this.state.composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - - )} - - ); - } + + + {!props.isSmallScreenWidth && } + + + + + {isDraggingOver && } + {!_.isEmpty(suggestedEmojis) && shouldShowSuggestionMenu && ( + setHighlightedEmojiIndex(index)} + > + setSuggestedEmojis([])} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestedEmojis} + comment={value} + updateComment={newComment => setValue(newComment)} + colonIndex={colonIndex} + prefix={value.slice(colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={props.isComposerFullSize} + preferredSkinToneIndex={props.preferredSkinTone} + isEmojiPickerLarge={isEmojiPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + + )} + + ); } ReportActionCompose.propTypes = propTypes; From 25707c542b49502ec28796833753b577b0338de6 Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Tue, 9 May 2023 13:38:55 +0300 Subject: [PATCH 05/74] refactor: WIP - remove prevProps useref - replace it with useffect logic, add few TODO comment, remove dev file ReportActionComposeF --- src/pages/home/report/ReportActionCompose.js | 60 +- src/pages/home/report/ReportActionComposeF.js | 1004 ----------------- 2 files changed, 24 insertions(+), 1040 deletions(-) delete mode 100644 src/pages/home/report/ReportActionComposeF.js diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 19a84f309dfa..a775149a80c1 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -232,7 +232,6 @@ function ReportActionCompose(props) { const comment = useRef(props.comment); const textInput = useRef(null); const actionButton = useRef(null); - const prevPropsRef = useRef(); // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions const conciergePlaceholderRandomIndex = useMemo( @@ -378,47 +377,33 @@ function ReportActionCompose(props) { }; }, []); - // TODO: still under discussion - might use another approach to migrate ComponentDidUpdate + // eslint-disable-next-line rulesdir/prefer-early-return useEffect(() => { - const prevProps = prevPropsRef.current; - - if (prevProps) { - const sidebarOpened = !prevProps.isDrawerOpen && props.isDrawerOpen; - if (sidebarOpened) { - toggleReportActionComposeView(true); - } - - // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (willBlurTextInputOnTapOutside && props.isFocused - && prevProps && prevProps.modal.isVisible && !props.modal.isVisible) { - focus(); - } - - if (props.isComposerFullSize !== prevProps.isComposerFullSize) { - updateMaxLines(); - } + if (props.isDrawerOpen) { + toggleReportActionComposeView(true); + } + }, [props.isDrawerOpen]); - // Value state does not have the same value as comment props when the comment gets changed from another tab. - // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevProps.comment !== props.comment && value !== props.comment; + useEffect(() => { + updateMaxLines(); + }, [props.isComposerFullSize]); - if (props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { - return; - } + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (willBlurTextInputOnTapOutside && props.isFocused && !props.modal.isVisible) { + focus(); + } + }, [props.isFocused, props.modal.isVisible]); + // eslint-disable-next-line rulesdir/prefer-early-return + useEffect(() => { + if (value !== props.comment) { updateComment(comment.current); } - prevPropsRef.current = { - isDrawerOpen: props.isDrawerOpen, - isFocused: props.isFocused, - modal: props.modal, - isComposerFullSize: props.isComposerFullSize, - comment: props.comment, - report: props.report, - }; - }, [props.isDrawerOpen, props.isFocused, props.modal, props.isComposerFullSize, props.comment, props.report, focus, updateMaxLines, value, updateComment]); + }, [props.report.reportID]); /** * Clean data related to EmojiSuggestions @@ -494,6 +479,7 @@ function ReportActionCompose(props) { * @param {Array} reportParticipants * @returns {Array} */ + // TODO: rewrite to useMemo const getMoneyRequestOptions = useCallback((reportParticipants) => { const options = { [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { @@ -536,6 +522,7 @@ function ReportActionCompose(props) { * @param {Array} reportParticipants * @returns {Boolean} */ + // TODO: rewrite to useMemo const getTaskOption = useCallback((reportParticipants) => { // 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(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( @@ -705,6 +692,7 @@ function ReportActionCompose(props) { setTextInputShouldClear(false); }, [props.reportID, prepareCommentAndResetComposer]); + // TODO: wrap things below in useMemo const reportParticipants = _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login); const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; diff --git a/src/pages/home/report/ReportActionComposeF.js b/src/pages/home/report/ReportActionComposeF.js deleted file mode 100644 index 19a84f309dfa..000000000000 --- a/src/pages/home/report/ReportActionComposeF.js +++ /dev/null @@ -1,1004 +0,0 @@ -import React, { - useCallback, useEffect, useMemo, useRef, useState, -} from 'react'; -import PropTypes from 'prop-types'; -import { - View, - TouchableOpacity, - InteractionManager, - LayoutAnimation, -} from 'react-native'; -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; -import styles from '../../../styles/styles'; -import themeColors from '../../../styles/themes/default'; -import Composer from '../../../components/Composer'; -import ONYXKEYS from '../../../ONYXKEYS'; -import Icon from '../../../components/Icon'; -import * as Expensicons from '../../../components/Icon/Expensicons'; -import AttachmentPicker from '../../../components/AttachmentPicker'; -import * as Report from '../../../libs/actions/Report'; -import ReportTypingIndicator from './ReportTypingIndicator'; -import AttachmentModal from '../../../components/AttachmentModal'; -import compose from '../../../libs/compose'; -import PopoverMenu from '../../../components/PopoverMenu'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; -import withDrawerState from '../../../components/withDrawerState'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; -import CONST from '../../../CONST'; -import Navigation from '../../../libs/Navigation/Navigation'; -import ROUTES from '../../../ROUTES'; -import reportActionPropTypes from './reportActionPropTypes'; -import * as ReportUtils from '../../../libs/ReportUtils'; -import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; -import participantPropTypes from '../../../components/participantPropTypes'; -import ParticipantLocalTime from './ParticipantLocalTime'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; -import {withNetwork, withPersonalDetails} from '../../../components/OnyxProvider'; -import * as User from '../../../libs/actions/User'; -import Tooltip from '../../../components/Tooltip'; -import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; -import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; -import toggleReportActionComposeView from '../../../libs/toggleReportActionComposeView'; -import OfflineIndicator from '../../../components/OfflineIndicator'; -import ExceededCommentLength from '../../../components/ExceededCommentLength'; -import withNavigationFocus from '../../../components/withNavigationFocus'; -import withNavigation from '../../../components/withNavigation'; -import * as EmojiUtils from '../../../libs/EmojiUtils'; -import ReportDropUI from './ReportDropUI'; -import DragAndDrop from '../../../components/DragAndDrop'; -import reportPropTypes from '../../reportPropTypes'; -import EmojiSuggestions from '../../../components/EmojiSuggestions'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; -import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; -import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; -import KeyboardShortcut from '../../../libs/KeyboardShortcut'; -import * as ComposerUtils from '../../../libs/ComposerUtils'; -import * as Welcome from '../../../libs/actions/Welcome'; -import Permissions from '../../../libs/Permissions'; - -const propTypes = { - /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), - - /** A method to call when the form is submitted */ - onSubmit: PropTypes.func.isRequired, - - /** The comment left by the user */ - comment: PropTypes.string, - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - - /** The ID of the report actions will be created for */ - reportID: PropTypes.string.isRequired, - - /** Details about any modals being used */ - modal: PropTypes.shape({ - /** Indicates if there is a modal currently visible or not */ - isVisible: PropTypes.bool, - }), - - /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** The report currently being looked at */ - report: reportPropTypes, - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - - /** Is the report view covered by the drawer */ - isDrawerOpen: PropTypes.bool.isRequired, - - /** Is the window width narrow, like on a mobile device */ - isSmallScreenWidth: PropTypes.bool.isRequired, - - /** Is composer screen focused */ - isFocused: PropTypes.bool.isRequired, - - /** 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, - }), - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - /** User's frequently used emojis */ - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ - code: PropTypes.string.isRequired, - keywords: PropTypes.arrayOf(PropTypes.string), - })), - - /** The type of action that's pending */ - pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), - - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, - ...withCurrentUserPersonalDetailsPropTypes, - ...keyboardStatePropTypes, -}; - -const defaultProps = { - betas: [], - comment: '', - numberOfLines: undefined, - modal: {}, - report: {}, - reportActions: [], - blockedFromConcierge: {}, - personalDetails: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - frequentlyUsedEmojis: [], - isComposerFullSize: false, - pendingAction: null, - ...withCurrentUserPersonalDetailsDefaultProps, -}; - -/** - * Return the max available index for arrow manager. - * @param {Number} numRows - * @param {Boolean} isEmojiPickerLarge - * @returns {Number} - */ -const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { - // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items - const emojiRowCount = isEmojiPickerLarge - ? Math.max(numRows, CONST.EMOJI_SUGGESTER.MAX_AMOUNT_OF_ITEMS) - : Math.max(numRows, CONST.EMOJI_SUGGESTER.MIN_AMOUNT_OF_ITEMS); - - // -1 because we start at 0 - return emojiRowCount - 1; -}; - -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - -/** - * 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.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - const leftWord = _.last(leftWords); - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; -}; -function ReportActionCompose(props) { - /** - * Updates the Highlight state of the composer - */ - const [isFocused, setIsFocused] = useState(willBlurTextInputOnTapOutside && !props.modal.isVisible && !props.modal.willAlertModalBecomeVisible); - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(props.isComposerFullSize); - - /** - * Updates the should clear state of the composer - */ - const [textInputShouldClear, setTextInputShouldClear] = useState(false); - const [isCommentEmpty, setIsCommentEmpty] = useState(props.comment.length === 0); - - /** - * Updates the visibility state of the menu - */ - const [isMenuVisible, setMenuVisibility] = useState(false); - const [isDraggingOver, setIsDraggingOver] = useState(false); - const [selection, setSelection] = useState({start: props.comment.length, end: props.comment.length}); - const [maxLines, setMaxLines] = useState(props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES); - const [value, setValue] = useState(props.comment); - - const [suggestedEmojis, setSuggestedEmojis] = useState([]); - const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useState(0); - const [colonIndex, setColonIndex] = useState(-1); - const [shouldShowSuggestionMenu, setShouldShowSuggestionMenu] = useState(false); - const [shouldBlockEmojiCalc, setShouldBlockEmojiCalc] = useState(false); - const [isEmojiPickerLarge, setIsEmojiPickerLarge] = useState(false); - const [composerHeight, setComposerHeight] = useState(0); - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - */ - const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); - - // TODO_S: double check useRefs - const unsubscribeEscapeKey = useRef(null); - const comment = useRef(props.comment); - const textInput = useRef(null); - const actionButton = useRef(null); - const prevPropsRef = useRef(); - - // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions - const conciergePlaceholderRandomIndex = useMemo( - () => _.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1)), - [props.isSmallScreenWidth, props.translate], - ); - - // Placeholder to display in the chat input. - const inputPlaceholder = useMemo(() => { - if (ReportUtils.chatIncludesConcierge(props.report)) { - if (User.isBlockedFromConcierge(props.blockedFromConcierge)) { - return props.translate('reportActionCompose.blockedFromConcierge'); - } - - return props.translate('reportActionCompose.conciergePlaceholderOptions')[conciergePlaceholderRandomIndex]; - } - - return props.translate('reportActionCompose.writeSomething'); - }, [props.translate, props.report, props.blockedFromConcierge, conciergePlaceholderRandomIndex]); - - /** - * 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 (!textInput.current) { - return; - } - - if (!shouldDelay) { - textInput.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(() => textInput.current.focus(), 100); - } - }); - }, []); - - /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment - */ - const updateComment = useCallback((commentValue, shouldDebounceSaveComment) => { - const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); - - setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); - setValue(newComment); - if (commentValue !== newComment) { - const remainder = value.slice(selection.end).length; - setSelection({ - start: newComment.length - remainder, - end: newComment.length - remainder, - }); - } - - // Indicate that draft has been created. - if (comment.current.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(props.reportID, true); - } - - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(props.reportID, false); - } - - comment.current = newComment; - if (shouldDebounceSaveComment) { - debouncedSaveReportComment(props.reportID, newComment); - } else { - Report.saveReportComment(props.reportID, newComment || ''); - } - if (newComment) { - debouncedBroadcastUserIsTyping(props.reportID); - } - }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value]); - - /** - * Set the maximum number of lines for the composer - */ - const updateMaxLines = useCallback(() => { - let maxLinesNumber = props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - if (props.isComposerFullSize) { - maxLinesNumber = CONST.COMPOSER.MAX_LINES_FULL; - } - setMaxLines(maxLinesNumber); - }, [props.isSmallScreenWidth, props.isComposerFullSize]); - - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - const showPopoverMenu = useCallback(() => { - setMenuVisibility(true); - return true; - }, []); - - useEffect(() => { - // 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 || !props.isFocused) { - return; - } - - focus(false); - }); - - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; - unsubscribeEscapeKey.current = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { - if (!isFocused || comment.current.length === 0) { - return; - } - - updateComment('', true); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); - - updateMaxLines(); - updateComment(comment.current); - - // Shows Popover Menu on Workspace Chat at first sign-in - if (!props.disabled) { - Welcome.show({ - routes: lodashGet(props.navigation.getState(), 'routes', []), - showPopoverMenu, - }); - } - - return () => { - ReportActionComposeFocusManager.clear(); - - if (unsubscribeEscapeKey) { - unsubscribeEscapeKey.current(); - } - }; - }, []); - - // TODO: still under discussion - might use another approach to migrate ComponentDidUpdate - useEffect(() => { - const prevProps = prevPropsRef.current; - - if (prevProps) { - const sidebarOpened = !prevProps.isDrawerOpen && props.isDrawerOpen; - if (sidebarOpened) { - toggleReportActionComposeView(true); - } - - // We want to focus or refocus the input when a modal has been closed and the underlying screen is focused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (willBlurTextInputOnTapOutside && props.isFocused - && prevProps && prevProps.modal.isVisible && !props.modal.isVisible) { - focus(); - } - - if (props.isComposerFullSize !== prevProps.isComposerFullSize) { - updateMaxLines(); - } - - // Value state does not have the same value as comment props when the comment gets changed from another tab. - // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevProps.comment !== props.comment && value !== props.comment; - - if (props.report.reportID === prevProps.report.reportID && !shouldSyncComment) { - return; - } - - updateComment(comment.current); - } - prevPropsRef.current = { - isDrawerOpen: props.isDrawerOpen, - isFocused: props.isFocused, - modal: props.modal, - isComposerFullSize: props.isComposerFullSize, - comment: props.comment, - report: props.report, - }; - }, [props.isDrawerOpen, props.isFocused, props.modal, props.isComposerFullSize, props.comment, props.report, focus, updateMaxLines, value, updateComment]); - - /** - * Clean data related to EmojiSuggestions - */ - const resetSuggestedEmojis = useCallback(() => { - setSuggestedEmojis([]); - setHighlightedEmojiIndex(0); - setColonIndex(-1); - setShouldShowSuggestionMenu(false); - setIsEmojiPickerLarge(true); - }, []); - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - const calculateEmojiSuggestion = useCallback( - (selectionEnd) => { - if (!value) { - resetSuggestedEmojis(); - return; - } - if (shouldBlockEmojiCalc) { - setShouldBlockEmojiCalc(false); - return; - } - const leftString = value.substring(0, selectionEnd); - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); - - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - setSuggestedEmojis(newSuggestedEmojis); - setShouldShowSuggestionMenu(!_.isEmpty(newSuggestedEmojis)); - } else { - setSuggestedEmojis([]); - setShouldShowSuggestionMenu(false); - } - - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; - setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); - setColonIndex(leftString.lastIndexOf(':')); - setHighlightedEmojiIndex(0); - }, [composerHeight, value, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc], - ); - - const onSelectionChange = useCallback((e) => { - setSelection(e.nativeEvent.selection); - - /** - * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion - * because in other case calculateEmojiSuggestion will have an old calculation value - * of suggestion instead of current one - */ - calculateEmojiSuggestion(e.nativeEvent.selection.end); - }, [calculateEmojiSuggestion]); - - /** - * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose - */ - const setTextInputRef = useCallback((el) => { - ReportActionComposeFocusManager.composerRef.current = el; - textInput.current = el; - }, []); - - /** - * Returns the list of IOU Options - * - * @param {Array} reportParticipants - * @returns {Array} - */ - const getMoneyRequestOptions = useCallback((reportParticipants) => { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: props.translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.getIouSplitRoute(props.reportID)), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: props.translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(props.reportID)), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: props.translate('iou.sendMoney'), - onSelected: () => Navigation.navigate(ROUTES.getIOUSendRoute(props.reportID)), - }, - }; - - // TODO: check for props in dependency array - return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); - }, [props]); - - // eslint-disable-next-line rulesdir/prefer-early-return - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (shouldShowSuggestionMenu) { - setShouldShowSuggestionMenu(false); - } - }, [shouldShowSuggestionMenu]); - - // eslint-disable-next-line rulesdir/prefer-early-return - const updateShouldBlockEmojiCalcToFalse = useCallback(() => { - if (shouldBlockEmojiCalc) { - setShouldBlockEmojiCalc(false); - } - }, [shouldBlockEmojiCalc]); - - /** - * Determines if we can show the task option - * @param {Array} reportParticipants - * @returns {Boolean} - */ - const getTaskOption = useCallback((reportParticipants) => { - // 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(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( - CONST.EXPENSIFY_EMAILS, - email, - )))) { - return []; - } - - return [ - { - icon: Expensicons.Task, - text: props.translate('newTaskPage.assignTask'), - onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(props.reportID)), - }, - ]; - - // TODO: check for props in dependency array - }, [props]); - - /** - * Replace the code of emoji and update selection - * @param {Number} highlightedEmojiIndex - */ - const insertSelectedEmoji = useCallback((selectedEmoji) => { - const commentBeforeColon = value.slice(0, colonIndex); - const emojiObject = suggestedEmojis[selectedEmoji]; - const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - - // TODO: check if prevState.colonIndex to coloIndex works correct - updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); - setSelection({ - start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }); - setSuggestedEmojis([]); - - EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); - }, [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment]); - - const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, [props.reportActions]); - - /** - * Callback for the emoji picker to add whatever emoji is chosen into the main input - * - * @param {String} emoji - */ - const addEmojiToTextBox = useCallback((emoji) => { - setSelection({ - start: selection.start + emoji.length, - end: selection.start + emoji.length, - }); - - updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); - }, [selection, updateComment]); - - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - const updateNumberOfLines = useCallback((numberOfLines) => { - Report.saveReportCommentNumberOfLines(props.reportID, numberOfLines); - }, [props.reportID]); - - /** - * @returns {String} - */ - const prepareCommentAndResetComposer = useCallback(() => { - const trimmedComment = comment.current.trim(); - - // Don't submit empty comments or comments that exceed the character limit - if (isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - updateComment(''); - setTextInputShouldClear(true); - if (props.isComposerFullSize) { - Report.setIsComposerFullSize(props.reportID, false); - } - setIsFullComposerAvailable(false); - return trimmedComment; - }, [isCommentEmpty, props.reportID, updateComment, props.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; - } - - props.onSubmit(newComment); - - // TODO: check props dependency - }, [prepareCommentAndResetComposer, props]); - - /** - * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e - */ - const triggerHotkeyActions = useCallback((e) => { - // Do not trigger actions for mobileWeb or native clients that have the keyboard open because for those devices, we want the return key to insert newlines rather than submit the form - if (!e || props.isSmallScreenWidth || props.isKeyboardShown) { - return; - } - - if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestedEmojis.length) { - e.preventDefault(); - insertSelectedEmoji(highlightedEmojiIndex); - return; - } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestedEmojis.length) { - e.preventDefault(); - resetSuggestedEmojis(); - 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 && textInput.current.selectionStart === 0 && isCommentEmpty && !ReportUtils.chatIncludesChronos(props.report) - ) { - e.preventDefault(); - - const lastReportAction = _.find( - props.reportActions, - action => ReportUtils.canEditReportAction(action), - ); - - if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); - } - } - }, [isCommentEmpty, props.isSmallScreenWidth, props.report, props.reportActions, props.reportID, resetSuggestedEmojis, - submitForm, suggestedEmojis.length, props.isKeyboardShown, highlightedEmojiIndex, insertSelectedEmoji]); - - /** - * @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(props.reportID, file, newComment); - setTextInputShouldClear(false); - }, [props.reportID, prepareCommentAndResetComposer]); - - const reportParticipants = _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login); - const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); - const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; - - const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(props.personalDetails, props.report) - && !props.isComposerFullSize; - - // Prevents focusing and showing the keyboard while the drawer is covering the chat. - const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; - const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge); - - const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); - - return ( - - - {shouldShowReportRecipientLocalTime && } - - - {({displayFileInModal}) => ( - <> - - {({openPicker}) => ( - <> - - {props.isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(props.reportID, false); - }} - - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || props.disabled} - > - - - - - )} - {(!props.isComposerFullSize && isFullComposerAvailable) && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(props.reportID, true); - }} - - // Keep focus on the composer when Expand button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || props.disabled} - > - - - - )} - - { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - actionButton.current.blur(); - setMenuVisibility(true); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || props.disabled} - > - - - - - setMenuVisibility(false)} - onItemSelected={() => setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...getMoneyRequestOptions(reportParticipants), ...getTaskOption(reportParticipants), - { - icon: Expensicons.Paperclip, - text: props.translate('reportActionCompose.addAttachment'), - onSelected: () => { - // Set a flag to block emoji 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) { - setShouldBlockEmojiCalc(true); - } - - openPicker({ - onPicked: displayFileInModal, - }); - }, - }, - ]} - /> - - )} - - - setIsDraggingOver(true)} - onDragLeave={() => setIsDraggingOver(false)} - onDrop={(e) => { - e.preventDefault(); - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - setIsDraggingOver(false); - }} - disabled={props.disabled} - > - updateComment(commentValue, true)} - onKeyPress={triggerHotkeyActions} - style={[styles.textInputCompose, props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={maxLines} - onFocus={() => setIsFocused(true)} - onBlur={() => { - setIsFocused(false); - resetSuggestedEmojis(); - }} - onClick={updateShouldBlockEmojiCalcToFalse} - onPasteFile={displayFileInModal} - shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge || props.disabled} - selection={selection} - onSelectionChange={onSelectionChange} - isFullComposerAvailable={isFullComposerAvailable} - setIsFullComposerAvailable={setIsFullComposerAvailable} - isComposerFullSize={props.isComposerFullSize} - value={value} - numberOfLines={props.numberOfLines} - onNumberOfLinesChange={updateNumberOfLines} - onLayout={(e) => { - const composerLayoutHeight = e.nativeEvent.layout.height; - if (composerHeight === composerLayoutHeight) { - return; - } - setComposerHeight(composerLayoutHeight); - }} - onScroll={() => updateShouldShowSuggestionMenuToFalse()} - /> - - - - )} - - {DeviceCapabilities.canUseTouchScreen() && props.isMediumScreenWidth ? null : ( - focus(true)} - onEmojiSelected={addEmojiToTextBox} - /> - )} - e.preventDefault()} - > - - - - - - - - - {!props.isSmallScreenWidth && } - - - - - {isDraggingOver && } - {!_.isEmpty(suggestedEmojis) && shouldShowSuggestionMenu && ( - setHighlightedEmojiIndex(index)} - > - setSuggestedEmojis([])} - highlightedEmojiIndex={highlightedEmojiIndex} - emojis={suggestedEmojis} - comment={value} - updateComment={newComment => setValue(newComment)} - colonIndex={colonIndex} - prefix={value.slice(colonIndex + 1, selection.start)} - onSelect={insertSelectedEmoji} - isComposerFullSize={props.isComposerFullSize} - preferredSkinToneIndex={props.preferredSkinTone} - isEmojiPickerLarge={isEmojiPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - - )} - - ); -} - -ReportActionCompose.propTypes = propTypes; -ReportActionCompose.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withDrawerState, - withNavigation, - withNavigationFocus, - withLocalize, - withNetwork(), - withPersonalDetails(), - withCurrentUserPersonalDetails, - withKeyboardState, - withOnyx({ - betas: { - key: ONYXKEYS.BETAS, - }, - comment: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - }, - numberOfLines: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, - }, - modal: { - key: ONYXKEYS.MODAL, - }, - blockedFromConcierge: { - key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, - }, - frequentlyUsedEmojis: { - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - }), -)(ReportActionCompose); From 622587138530ec16a0ef9aa0db294e36dd3256ff Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Tue, 9 May 2023 16:07:53 +0300 Subject: [PATCH 06/74] refactor: rewrite few const and callback to useMemo --- src/pages/home/report/ReportActionCompose.js | 50 +++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index a775149a80c1..18a31de0c3eb 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -227,12 +227,26 @@ function ReportActionCompose(props) { */ const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); - // TODO_S: double check useRefs const unsubscribeEscapeKey = useRef(null); const comment = useRef(props.comment); const textInput = useRef(null); const actionButton = useRef(null); + const reportParticipants = useMemo( + () => _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login), + [props.report, props.currentUserPersonalDetails.login], + ); + const participantsWithoutExpensifyEmails = useMemo(() => _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS), [reportParticipants]); + const shouldShowReportRecipientLocalTime = useMemo( + () => ReportUtils.canShowReportRecipientLocalTime(props.personalDetails, props.report) && !props.isComposerFullSize, + [props.personalDetails, props.report, props.isComposerFullSize], + ); + + const isBlockedFromConcierge = useMemo( + () => ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge), + [props.report, props.blockedFromConcierge], + ); + // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions const conciergePlaceholderRandomIndex = useMemo( () => _.random(props.translate('reportActionCompose.conciergePlaceholderOptions').length - (props.isSmallScreenWidth ? 4 : 1)), @@ -475,12 +489,9 @@ function ReportActionCompose(props) { /** * Returns the list of IOU Options - * - * @param {Array} reportParticipants * @returns {Array} */ - // TODO: rewrite to useMemo - const getMoneyRequestOptions = useCallback((reportParticipants) => { + const moneyRequestOptions = useMemo(() => { const options = { [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { icon: Expensicons.Receipt, @@ -499,9 +510,8 @@ function ReportActionCompose(props) { }, }; - // TODO: check for props in dependency array return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); - }, [props]); + }, [reportParticipants, props.report, props.betas, props.translate, props.reportID]); // eslint-disable-next-line rulesdir/prefer-early-return const updateShouldShowSuggestionMenuToFalse = useCallback(() => { @@ -519,11 +529,9 @@ function ReportActionCompose(props) { /** * Determines if we can show the task option - * @param {Array} reportParticipants * @returns {Boolean} */ - // TODO: rewrite to useMemo - const getTaskOption = useCallback((reportParticipants) => { + 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(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( CONST.EXPENSIFY_EMAILS, @@ -539,9 +547,7 @@ function ReportActionCompose(props) { onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(props.reportID)), }, ]; - - // TODO: check for props in dependency array - }, [props]); + }, [props.betas, props.report, reportParticipants, props.translate, props.reportID]); /** * Replace the code of emoji and update selection @@ -553,7 +559,6 @@ function ReportActionCompose(props) { const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - // TODO: check if prevState.colonIndex to coloIndex works correct updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); setSelection({ start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -629,9 +634,7 @@ function ReportActionCompose(props) { } props.onSubmit(newComment); - - // TODO: check props dependency - }, [prepareCommentAndResetComposer, props]); + }, [prepareCommentAndResetComposer, props.onSubmit]); /** * Listens for keyboard shortcuts and applies the action @@ -692,18 +695,9 @@ function ReportActionCompose(props) { setTextInputShouldClear(false); }, [props.reportID, prepareCommentAndResetComposer]); - // TODO: wrap things below in useMemo - const reportParticipants = _.without(lodashGet(props.report, 'participants', []), props.currentUserPersonalDetails.login); - const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); - const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; - - const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(props.personalDetails, props.report) - && !props.isComposerFullSize; - // Prevents focusing and showing the keyboard while the drawer is covering the chat. const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; - const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge); - + const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); return ( @@ -803,7 +797,7 @@ function ReportActionCompose(props) { onClose={() => setMenuVisibility(false)} onItemSelected={() => setMenuVisibility(false)} anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...getMoneyRequestOptions(reportParticipants), ...getTaskOption(reportParticipants), + menuItems={[...moneyRequestOptions, ...taskOption, { icon: Expensicons.Paperclip, text: props.translate('reportActionCompose.addAttachment'), From df2c91a29d6f5d07fad7a30924a87ddf9cc507ba Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Wed, 10 May 2023 17:48:47 +0300 Subject: [PATCH 07/74] refactor: prettier changes, moved onDrop event to separate useCallback --- src/pages/home/report/ReportActionCompose.js | 550 ++++++++++--------- 1 file changed, 297 insertions(+), 253 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index eb8dbfd6e799..e67665effd69 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -1,13 +1,6 @@ -import React, { - useCallback, useEffect, useMemo, useRef, useState, -} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; -import { - View, - TouchableOpacity, - InteractionManager, - LayoutAnimation, -} from 'react-native'; +import {View, TouchableOpacity, InteractionManager, LayoutAnimation} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -115,10 +108,12 @@ const propTypes = { preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** User's frequently used emojis */ - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ - code: PropTypes.string.isRequired, - keywords: PropTypes.arrayOf(PropTypes.string), - })), + frequentlyUsedEmojis: PropTypes.arrayOf( + PropTypes.shape({ + code: PropTypes.string.isRequired, + keywords: PropTypes.arrayOf(PropTypes.string), + }), + ), /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), @@ -179,11 +174,11 @@ const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { }, 100); /** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ + * 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.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); const leftWord = _.last(leftWords); @@ -296,39 +291,42 @@ function ReportActionCompose(props) { * @param {String} comment * @param {Boolean} shouldDebounceSaveComment */ - const updateComment = useCallback((commentValue, shouldDebounceSaveComment) => { - const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); - - setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); - setValue(newComment); - if (commentValue !== newComment) { - const remainder = value.slice(selection.end).length; - setSelection({ - start: newComment.length - remainder, - end: newComment.length - remainder, - }); - } + const updateComment = useCallback( + (commentValue, shouldDebounceSaveComment) => { + const newComment = EmojiUtils.replaceEmojis(commentValue, props.isSmallScreenWidth, props.preferredSkinTone); + + setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + setValue(newComment); + if (commentValue !== newComment) { + const remainder = value.slice(selection.end).length; + setSelection({ + start: newComment.length - remainder, + end: newComment.length - remainder, + }); + } - // Indicate that draft has been created. - if (comment.current.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(props.reportID, true); - } + // Indicate that draft has been created. + if (comment.current.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(props.reportID, true); + } - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(props.reportID, false); - } + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(props.reportID, false); + } - comment.current = newComment; - if (shouldDebounceSaveComment) { - debouncedSaveReportComment(props.reportID, newComment); - } else { - Report.saveReportComment(props.reportID, newComment || ''); - } - if (newComment) { - debouncedBroadcastUserIsTyping(props.reportID); - } - }, [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value]); + comment.current = newComment; + if (shouldDebounceSaveComment) { + debouncedSaveReportComment(props.reportID, newComment); + } else { + Report.saveReportComment(props.reportID, newComment || ''); + } + if (newComment) { + debouncedBroadcastUserIsTyping(props.reportID); + } + }, + [props.isSmallScreenWidth, props.preferredSkinTone, props.reportID, selection.end, value], + ); /** * Set the maximum number of lines for the composer @@ -362,13 +360,20 @@ function ReportActionCompose(props) { }); const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; - unsubscribeEscapeKey.current = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { - if (!isFocused || comment.current.length === 0) { - return; - } - - updateComment('', true); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); + unsubscribeEscapeKey.current = KeyboardShortcut.subscribe( + shortcutConfig.shortcutKey, + () => { + if (!isFocused || comment.current.length === 0) { + return; + } + + updateComment('', true); + }, + shortcutConfig.descriptionKey, + shortcutConfig.modifiers, + true, + true, + ); updateMaxLines(); updateComment(comment.current); @@ -461,19 +466,23 @@ function ReportActionCompose(props) { setIsEmojiPickerLarge(!props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion)); setColonIndex(leftString.lastIndexOf(':')); setHighlightedEmojiIndex(0); - }, [composerHeight, value, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc], + }, + [composerHeight, value, props.windowHeight, props.isSmallScreenWidth, resetSuggestedEmojis, shouldBlockEmojiCalc], ); - const onSelectionChange = useCallback((e) => { - setSelection(e.nativeEvent.selection); + const onSelectionChange = useCallback( + (e) => { + setSelection(e.nativeEvent.selection); - /** - * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion - * because in other case calculateEmojiSuggestion will have an old calculation value - * of suggestion instead of current one - */ - calculateEmojiSuggestion(e.nativeEvent.selection.end); - }, [calculateEmojiSuggestion]); + /** + * 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], + ); /** * Set the TextInput Ref @@ -509,7 +518,7 @@ function ReportActionCompose(props) { }, }; - return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), option => options[option]); + return _.map(ReportUtils.getMoneyRequestOptions(props.report, reportParticipants, props.betas), (option) => options[option]); }, [reportParticipants, props.report, props.betas, props.translate, props.reportID]); // eslint-disable-next-line rulesdir/prefer-early-return @@ -532,10 +541,10 @@ function ReportActionCompose(props) { */ 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(props.betas) || (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( - CONST.EXPENSIFY_EMAILS, - email, - )))) { + if ( + !Permissions.canUseTasks(props.betas) || + (lodashGet(props.report, 'participants', []).length === 1 && _.some(reportParticipants, (email) => _.contains(CONST.EXPENSIFY_EMAILS, email))) + ) { return []; } @@ -552,21 +561,24 @@ function ReportActionCompose(props) { * Replace the code of emoji and update selection * @param {Number} highlightedEmojiIndex */ - const insertSelectedEmoji = useCallback((selectedEmoji) => { - const commentBeforeColon = value.slice(0, colonIndex); - const emojiObject = suggestedEmojis[selectedEmoji]; - const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - - updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); - setSelection({ - start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }); - setSuggestedEmojis([]); + const insertSelectedEmoji = useCallback( + (selectedEmoji) => { + const commentBeforeColon = value.slice(0, colonIndex); + const emojiObject = suggestedEmojis[selectedEmoji]; + const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + + updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + setSelection({ + start: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestedEmojis([]); - EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); - }, [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment]); + EmojiUtils.addToFrequentlyUsedEmojis(props.frequentlyUsedEmojis, emojiObject); + }, + [colonIndex, props.frequentlyUsedEmojis, suggestedEmojis, value, props.preferredSkinTone, selection, updateComment], + ); const isEmptyChat = useCallback(() => _.size(props.reportActions) === 1, [props.reportActions]); @@ -575,22 +587,28 @@ function ReportActionCompose(props) { * * @param {String} emoji */ - const addEmojiToTextBox = useCallback((emoji) => { - setSelection({ - start: selection.start + emoji.length, - end: selection.start + emoji.length, - }); + const addEmojiToTextBox = useCallback( + (emoji) => { + setSelection({ + start: selection.start + emoji.length, + end: selection.start + emoji.length, + }); - updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); - }, [selection, updateComment]); + updateComment(ComposerUtils.insertText(comment.current, selection, emoji)); + }, + [selection, updateComment], + ); /** * Update the number of lines for a comment in Onyx * @param {Number} numberOfLines */ - const updateNumberOfLines = useCallback((numberOfLines) => { - Report.saveReportCommentNumberOfLines(props.reportID, numberOfLines); - }, [props.reportID]); + const updateNumberOfLines = useCallback( + (numberOfLines) => { + Report.saveReportCommentNumberOfLines(props.reportID, numberOfLines); + }, + [props.reportID], + ); /** * @returns {String} @@ -617,81 +635,96 @@ function ReportActionCompose(props) { * * @param {SyntheticEvent} [e] */ - const submitForm = useCallback((e) => { - if (e) { - e.preventDefault(); - } + 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(); + // 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; - } + const newComment = prepareCommentAndResetComposer(); + if (!newComment) { + return; + } - props.onSubmit(newComment); - }, [prepareCommentAndResetComposer, props.onSubmit]); + props.onSubmit(newComment); + }, + [prepareCommentAndResetComposer, props.onSubmit], + ); /** * Listens for keyboard shortcuts and applies the action * * @param {Object} e */ - const triggerHotkeyActions = useCallback((e) => { - if (!e || ComposerUtils.canSkipTriggerHotkeys(props.isSmallScreenWidth, props.isKeyboardShown)) { - return; - } + const triggerHotkeyActions = useCallback( + (e) => { + if (!e || ComposerUtils.canSkipTriggerHotkeys(props.isSmallScreenWidth, props.isKeyboardShown)) { + return; + } - if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestedEmojis.length) { - e.preventDefault(); - insertSelectedEmoji(highlightedEmojiIndex); - return; - } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestedEmojis.length) { - e.preventDefault(); - resetSuggestedEmojis(); - return; - } + if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestedEmojis.length) { + e.preventDefault(); + insertSelectedEmoji(highlightedEmojiIndex); + return; + } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestedEmojis.length) { + e.preventDefault(); + resetSuggestedEmojis(); + return; + } - // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); - submitForm(); - } + // 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 && - textInput.current.selectionStart === 0 && - isCommentEmpty && - !ReportUtils.chatIncludesChronos(props.report) - ) { - e.preventDefault(); + // 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 && textInput.current.selectionStart === 0 && isCommentEmpty && !ReportUtils.chatIncludesChronos(props.report)) { + e.preventDefault(); - const lastReportAction = _.find(props.reportActions, (action) => ReportUtils.canEditReportAction(action)); + const lastReportAction = _.find(props.reportActions, (action) => ReportUtils.canEditReportAction(action)); - if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + if (lastReportAction !== -1 && lastReportAction) { + Report.saveReportActionDraft(props.reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + } } - } - }, [isCommentEmpty, props.isSmallScreenWidth, props.report, props.reportActions, props.reportID, resetSuggestedEmojis, - submitForm, suggestedEmojis.length, props.isKeyboardShown, highlightedEmojiIndex, insertSelectedEmoji]); + }, + [ + isCommentEmpty, + props.isSmallScreenWidth, + props.report, + props.reportActions, + props.reportID, + resetSuggestedEmojis, + submitForm, + suggestedEmojis.length, + props.isKeyboardShown, + highlightedEmojiIndex, + insertSelectedEmoji, + ], + ); /** * @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(props.reportID, file, newComment); - setTextInputShouldClear(false); - }, [props.reportID, prepareCommentAndResetComposer]); + 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(props.reportID, file, newComment); + setTextInputShouldClear(false); + }, + [props.reportID, prepareCommentAndResetComposer], + ); /** * Event handler to update the state after the attachment preview is closed. @@ -701,16 +734,32 @@ function ReportActionCompose(props) { setIsAttachmentPreviewActive(false); }, [updateShouldBlockEmojiCalcToFalse]); + const onDropAttachment = useCallback((e, displayFileInModal) => { + e.preventDefault(); + if (isAttachmentPreviewActive) { + setIsDraggingOver(false); + return; + } + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + + displayFileInModal(file); + + setIsDraggingOver(false); + setIsAttachmentPreviewActive(true); + }, []); + // Prevents focusing and showing the keyboard while the drawer is covering the chat. const isComposeDisabled = props.isDrawerOpen && props.isSmallScreenWidth; const reportRecipient = props.personalDetails[participantsWithoutExpensifyEmails[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !props.disabled && (isFocused || isDraggingOver); return ( - {shouldShowReportRecipientLocalTime && } - {({openPicker}) => ( <> - {props.isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(props.reportID, false); - }} - - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || props.disabled} - > - - - - + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + )} - {(!props.isComposerFullSize && isFullComposerAvailable) && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(props.reportID, true); - }} - - // Keep focus on the composer when Expand button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || props.disabled} - > - - - + {!props.isComposerFullSize && isFullComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(props.reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || props.disabled} + > + + + )} setMenuVisibility(false)} onItemSelected={() => setMenuVisibility(false)} anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...moneyRequestOptions, ...taskOption, + menuItems={[ + ...moneyRequestOptions, + ...taskOption, { icon: Expensicons.Paperclip, text: props.translate('reportActionCompose.addAttachment'), @@ -830,20 +879,7 @@ function ReportActionCompose(props) { activeDropZoneId={CONST.REPORT.ACTIVE_DROP_NATIVE_ID} onDragEnter={() => setIsDraggingOver(true)} onDragLeave={() => setIsDraggingOver(false)} - onDrop={(e) => { - e.preventDefault(); - if (isAttachmentPreviewActive) { - setIsDraggingOver(false); - return; - } - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - setIsDraggingOver(false); - setIsAttachmentPreviewActive(true); - }} + onDrop={(e) => onDropAttachment(e, displayFileInModal)} disabled={props.disabled} > updateComment(commentValue, true)} + onChangeText={(commentValue) => updateComment(commentValue, true)} onKeyPress={triggerHotkeyActions} style={[styles.textInputCompose, props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxLines} @@ -898,61 +934,69 @@ function ReportActionCompose(props) { )} e.preventDefault()} + // Keep focus on the composer when Send message is clicked. + onMouseDown={(e) => e.preventDefault()} > - + - {!props.isSmallScreenWidth && } - + {isDraggingOver && } {!_.isEmpty(suggestedEmojis) && shouldShowSuggestionMenu && ( - setHighlightedEmojiIndex(index)} - > - setSuggestedEmojis([])} - highlightedEmojiIndex={highlightedEmojiIndex} - emojis={suggestedEmojis} - comment={value} - updateComment={newComment => setValue(newComment)} - colonIndex={colonIndex} - prefix={value.slice(colonIndex + 1, selection.start)} - onSelect={insertSelectedEmoji} - isComposerFullSize={props.isComposerFullSize} - preferredSkinToneIndex={props.preferredSkinTone} - isEmojiPickerLarge={isEmojiPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - + setHighlightedEmojiIndex(index)} + > + setSuggestedEmojis([])} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestedEmojis} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={colonIndex} + prefix={value.slice(colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={props.isComposerFullSize} + preferredSkinToneIndex={props.preferredSkinTone} + isEmojiPickerLarge={isEmojiPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + )} ); From 56d8f98529205a63a5f6d72331994688812a7f87 Mon Sep 17 00:00:00 2001 From: Stas Kravchuk Date: Thu, 11 May 2023 17:32:53 +0300 Subject: [PATCH 08/74] merge main into branch --- .prettierignore | 2 + android/app/build.gradle | 4 +- ios/NewExpensify/Info.plist | 4 +- ios/NewExpensifyTests/Info.plist | 4 +- package-lock.json | 16 +- package.json | 4 +- src/CONST.js | 5 + src/ONYXKEYS.js | 4 + src/ROUTES.js | 1 - src/components/Checkbox.js | 5 + src/components/LHNOptionsList/OptionRowLHN.js | 2 +- src/components/MagicCodeInput.js | 452 ++++++++---------- src/components/MentionSuggestions.js | 111 +++++ src/components/OptionsList/BaseOptionsList.js | 63 +-- .../OptionsList/optionsListPropTypes.js | 4 + ...etonView.js => OptionsListSkeletonView.js} | 8 +- .../OptionsSelector/BaseOptionsSelector.js | 6 +- .../Reactions/ReactionTooltipContent.js | 20 +- .../Reactions/ReportActionItemReactions.js | 3 +- src/components/ReportActionItem/IOUPreview.js | 59 ++- .../{IOUAction.js => MoneyRequestAction.js} | 10 +- .../ReportActionItem/TaskPreview.js | 95 ++++ src/components/TextInput/BaseTextInput.js | 7 +- .../withCurrentUserPersonalDetails.js | 9 +- src/languages/en.js | 5 + src/languages/es.js | 5 + src/libs/GetStyledTextArray.js | 2 +- src/libs/OptionsListUtils.js | 19 +- src/libs/PersonalDetailsUtils.js | 7 +- src/libs/ReportActionsUtils.js | 5 + src/libs/ReportUtils.js | 89 +++- src/libs/SidebarUtils.js | 11 +- .../Device/getDeviceInfo/getBaseInfo.js | 8 + .../getDeviceInfo/getOSAndName/index.js | 3 + .../getOSAndName/index.native.js | 11 + .../Device/getDeviceInfo/index.android.js | 10 + .../Device/getDeviceInfo/index.desktop.js | 10 + .../actions/Device/getDeviceInfo/index.ios.js | 10 + .../actions/Device/getDeviceInfo/index.js | 23 + src/libs/actions/Device/index.js | 3 +- src/libs/actions/IOU.js | 49 +- src/libs/actions/Report.js | 5 - src/libs/actions/Session/index.js | 20 +- src/libs/actions/Task.js | 56 ++- src/libs/actions/User.js | 108 +++++ src/pages/NewChatPage.js | 54 ++- src/pages/SearchPage.js | 18 +- src/pages/home/HeaderView.js | 24 +- .../report/ContextMenu/ContextMenuActions.js | 2 + src/pages/home/report/ReportActionCompose.js | 254 +++++++--- src/pages/home/report/ReportActionItem.js | 14 +- .../home/report/ReportActionItemCreated.js | 2 +- src/pages/home/report/ReportFooter.js | 2 +- .../home/report/reportActionPropTypes.js | 1 - src/pages/home/sidebar/SidebarLinks.js | 4 +- src/pages/iou/MoneyRequestModal.js | 6 +- .../MoneyRequestParticipantsSelector.js | 17 + .../MoneyRequestParticipantsSplitSelector.js | 17 + src/pages/personalDetailsPropType.js | 4 +- .../Contacts/ContactMethodDetailsPage.js | 85 +++- .../Profile/Contacts/NewContactMethodPage.js | 3 +- .../workspace/WorkspaceInviteMessagePage.js | 12 +- src/pages/workspace/WorkspaceInvitePage.js | 43 +- src/pages/workspace/WorkspaceMembersPage.js | 10 +- src/styles/styles.js | 16 + 65 files changed, 1432 insertions(+), 513 deletions(-) create mode 100644 src/components/MentionSuggestions.js rename src/components/{LHNSkeletonView.js => OptionsListSkeletonView.js} (94%) rename src/components/ReportActionItem/{IOUAction.js => MoneyRequestAction.js} (96%) create mode 100644 src/components/ReportActionItem/TaskPreview.js create mode 100644 src/libs/actions/Device/getDeviceInfo/getBaseInfo.js create mode 100644 src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js create mode 100644 src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js create mode 100644 src/libs/actions/Device/getDeviceInfo/index.android.js create mode 100644 src/libs/actions/Device/getDeviceInfo/index.desktop.js create mode 100644 src/libs/actions/Device/getDeviceInfo/index.ios.js create mode 100644 src/libs/actions/Device/getDeviceInfo/index.js diff --git a/.prettierignore b/.prettierignore index c7a5913c6ef8..5cad6e04b900 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ # The GH actions don't seem to compile and verify themselves well when Prettier is applied to them .github/actions/javascript/**/index.js +desktop/dist/**/*.js +dist/**/*.js diff --git a/android/app/build.gradle b/android/app/build.gradle index 9717b8bdb5ae..015f02d114f3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001031200 - versionName "1.3.12-0" + versionCode 1001031300 + versionName "1.3.13-0" } splits { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0b9fed869cfb..f6ab2448f034 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.12 + 1.3.13 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.12.0 + 1.3.13.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 16721dc12277..4572d4f22020 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.12 + 1.3.13 CFBundleSignature ???? CFBundleVersion - 1.3.12.0 + 1.3.13.0 diff --git a/package-lock.json b/package-lock.json index f776c2474aaf..8520c058c17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.12-0", + "version": "1.3.13-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.12-0", + "version": "1.3.13-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -40,7 +40,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#d9c8b08ca67363e4442291ae283326d25f445cc5", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#b29d8528cd9be19a131ef77f9edfeb652d2af646", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -23463,8 +23463,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#d9c8b08ca67363e4442291ae283326d25f445cc5", - "integrity": "sha512-Gg9iry9Wa5+ot73mZ6c8GetDcfqfW20e70mA1JyxKsmZ5frUmFWZ3dbQ8GgHZeaAzEKMVvQfGKDM9QfIQaz0FA==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#b29d8528cd9be19a131ef77f9edfeb652d2af646", + "integrity": "sha512-IpZFu0qyDu8i7rgAki+mWHVS/bbrAGNJZnGTN/Oz1tY+AxL+9H8SwCW6n3r8Z7mzMsolirV6jj2gL6y4NgeIIQ==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -57100,9 +57100,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#d9c8b08ca67363e4442291ae283326d25f445cc5", - "integrity": "sha512-Gg9iry9Wa5+ot73mZ6c8GetDcfqfW20e70mA1JyxKsmZ5frUmFWZ3dbQ8GgHZeaAzEKMVvQfGKDM9QfIQaz0FA==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#d9c8b08ca67363e4442291ae283326d25f445cc5", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#b29d8528cd9be19a131ef77f9edfeb652d2af646", + "integrity": "sha512-IpZFu0qyDu8i7rgAki+mWHVS/bbrAGNJZnGTN/Oz1tY+AxL+9H8SwCW6n3r8Z7mzMsolirV6jj2gL6y4NgeIIQ==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#b29d8528cd9be19a131ef77f9edfeb652d2af646", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", diff --git a/package.json b/package.json index 3f33a8ee6834..0328a8450192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.12-0", + "version": "1.3.13-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -75,7 +75,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#d9c8b08ca67363e4442291ae283326d25f445cc5", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#b29d8528cd9be19a131ef77f9edfeb652d2af646", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", diff --git a/src/CONST.js b/src/CONST.js index b53e2d42b5a3..a501dc100f9d 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -1011,12 +1011,17 @@ const CONST = { CODE_2FA: /^\d{6}$/, ATTACHMENT_ID: /chat-attachments\/(\d+)/, HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, + HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/, // eslint-disable-next-line no-misleading-character-class NEW_LINE_OR_WHITE_SPACE_OR_EMOJI: /[\n\s\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, // Define the regular expression pattern to match a string starting with a colon and ending with a space or newline character EMOJI_REPLACER: /^:[^\n\r]+?(?=$|\s)/, + + // Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character + MENTION_REPLACER: /^@[^\n\r]*?(?=$|\s)/, + MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, }, diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index ff2c32f3d94d..f93e80328019 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -119,6 +119,7 @@ export default { REPORT_DRAFT_COMMENT_NUMBER_OF_LINES: 'reportDraftCommentNumberOfLines_', REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', REPORT_USER_IS_TYPING: 'reportUserIsTyping_', + SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', }, @@ -212,4 +213,7 @@ export default { // Whether the auth token is valid IS_TOKEN_VALID: 'isTokenValid', + + // A map of the user's security group IDs they belong to in specific domains + MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups', }; diff --git a/src/ROUTES.js b/src/ROUTES.js index 584d01088bc3..d0e16820772c 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -102,7 +102,6 @@ export default { NEW_TASK_DETAILS: `${NEW_TASK}/details`, NEW_TASK_TITLE: `${NEW_TASK}/title`, NEW_TASK_DESCRIPTION: `${NEW_TASK}/description`, - getTaskDetailsRoute: (taskID) => `task/details/${taskID}`, SEARCH: 'search', SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', DETAILS: 'details', diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js index 19d46a2470ae..9e4f5da57dcf 100644 --- a/src/components/Checkbox.js +++ b/src/components/Checkbox.js @@ -26,6 +26,9 @@ const propTypes = { /** Additional styles to add to checkbox button */ style: stylePropTypes, + /** Additional styles to add to checkbox container */ + containerStyle: stylePropTypes, + /** Callback that is called when mousedown is triggered. */ onMouseDown: PropTypes.func, @@ -38,6 +41,7 @@ const defaultProps = { hasError: false, disabled: false, style: [], + containerStyle: [], forwardedRef: undefined, children: null, onMouseDown: undefined, @@ -112,6 +116,7 @@ class Checkbox extends React.Component { { tooltipEnabled numberOfLines={1} textStyles={displayNameStyle} - shouldUseFullTitle={optionItem.isChatRoom || optionItem.isPolicyExpenseChat} + shouldUseFullTitle={optionItem.isChatRoom || optionItem.isPolicyExpenseChat || optionItem.isTaskReport} /> {optionItem.isChatRoom && ( {}, onFulfill: () => {}, }; -class MagicCodeInput extends React.PureComponent { - constructor(props) { - super(props); - - this.inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys()); - this.inputRefs = {}; - - this.state = { - input: '', - focusedIndex: 0, - editIndex: 0, - numbers: props.value ? this.decomposeString(props.value) : Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR), - }; - - this.onFocus = this.onFocus.bind(this); - this.onChangeText = this.onChangeText.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); +/** + * Converts a given string into an array of numbers that must have the same + * number of elements as the number of inputs. + * + * @param {String} value + * @returns {Array} + */ +const decomposeString = (value) => { + let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); + if (arr.length < CONST.MAGIC_CODE_LENGTH) { + arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); } + return arr; +}; - componentDidMount() { - if (!this.props.autoFocus) { +/** + * Converts an array of strings into a single string. If there are undefined or + * empty values, it will replace them with a space. + * + * @param {Array} value + * @returns {String} + */ +const composeToString = (value) => _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); + +const inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys()); + +function MagicCodeInput(props) { + const inputRefs = useRef([]); + const [input, setInput] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const [editIndex, setEditIndex] = useState(0); + + useImperativeHandle(props.innerRef, () => ({ + focus() { + setFocusedIndex(0); + inputRefs.current[0].focus(); + }, + clear() { + setInput(''); + setFocusedIndex(0); + setEditIndex(0); + inputRefs.current[0].focus(); + props.onChangeText(''); + }, + })); + + useEffect(() => { + // Blurs the input and removes focus from the last input and, if it should submit + // on complete, it will call the onFulfill callback. + const numbers = decomposeString(props.value); + if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== CONST.MAGIC_CODE_LENGTH) { return; } + inputRefs.current[editIndex].blur(); + setFocusedIndex(undefined); + props.onFulfill(props.value); - if (this.props.shouldDelayFocus) { - this.focusTimeout = setTimeout(() => this.inputRefs[0].focus(), CONST.ANIMATED_TRANSITION); - } - - this.inputRefs[0].focus(); - } + // We have not added the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.value, props.shouldSubmitOnComplete, props.onFulfill]); - componentDidUpdate(prevProps) { - if (prevProps.value === this.props.value) { + useEffect(() => { + if (!props.autoFocus) { return; } - this.setState({ - numbers: this.decomposeString(this.props.value), - }); - } - - componentWillUnmount() { - if (!this.focusTimeout) { - return; + let focusTimeout = null; + if (props.shouldDelayFocus) { + focusTimeout = setTimeout(() => inputRefs.current[0].focus(), CONST.ANIMATED_TRANSITION); } - clearTimeout(this.focusTimeout); - } + + inputRefs.current[0].focus(); + + return () => { + if (!focusTimeout) { + return; + } + clearTimeout(focusTimeout); + }; + }, [props.autoFocus, props.shouldDelayFocus]); /** * Focuses on the input when it is pressed. @@ -103,12 +139,10 @@ class MagicCodeInput extends React.PureComponent { * @param {Object} event * @param {Number} index */ - onFocus(event) { + const onFocus = (event) => { event.preventDefault(); - this.setState({ - input: '', - }); - } + setInput(''); + }; /** * Callback for the onPress event, updates the indexes @@ -117,14 +151,12 @@ class MagicCodeInput extends React.PureComponent { * @param {Object} event * @param {Number} index */ - onPress(event, index) { + const onPress = (event, index) => { event.preventDefault(); - this.setState({ - input: '', - focusedIndex: index, - editIndex: index, - }); - } + setInput(''); + setFocusedIndex(index); + setEditIndex(index); + }; /** * Updates the magic inputs with the contents written in the @@ -135,46 +167,28 @@ class MagicCodeInput extends React.PureComponent { * * @param {String} value */ - onChangeText(value) { + const onChangeText = (value) => { if (_.isUndefined(value) || _.isEmpty(value) || !ValidationUtils.isNumeric(value)) { return; } - this.setState( - (prevState) => { - const numbersArr = value - .trim() - .split('') - .slice(0, CONST.MAGIC_CODE_LENGTH - prevState.editIndex); - const numbers = [ - ...prevState.numbers.slice(0, prevState.editIndex), - ...numbersArr, - ...prevState.numbers.slice(numbersArr.length + prevState.editIndex, CONST.MAGIC_CODE_LENGTH), - ]; - - // Updates the focused input taking into consideration the last input - // edited and the number of digits added by the user. - const focusedIndex = Math.min(prevState.editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1); - - return { - numbers, - focusedIndex, - input: value, - }; - }, - () => { - const finalInput = this.composeToString(this.state.numbers); - this.props.onChangeText(finalInput); - - // Blurs the input and removes focus from the last input and, if it should submit - // on complete, it will call the onFulfill callback. - if (this.props.shouldSubmitOnComplete && _.filter(this.state.numbers, (n) => ValidationUtils.isNumeric(n)).length === CONST.MAGIC_CODE_LENGTH) { - this.inputRefs[this.state.editIndex].blur(); - this.setState({focusedIndex: undefined}, () => this.props.onFulfill(finalInput)); - } - }, - ); - } + // Updates the focused input taking into consideration the last input + // edited and the number of digits added by the user. + const numbersArr = value + .trim() + .split('') + .slice(0, CONST.MAGIC_CODE_LENGTH - editIndex); + const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1); + + let numbers = decomposeString(props.value); + numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, CONST.MAGIC_CODE_LENGTH)]; + + setFocusedIndex(updatedFocusedIndex); + setInput(value); + + const finalInput = composeToString(numbers); + props.onChangeText(finalInput); + }; /** * Handles logic related to certain key presses. @@ -184,169 +198,121 @@ class MagicCodeInput extends React.PureComponent { * * @param {Object} event */ - onKeyPress({nativeEvent: {key: keyValue}}) { + const onKeyPress = ({nativeEvent: {key: keyValue}}) => { if (keyValue === 'Backspace') { - this.setState( - ({numbers, focusedIndex}) => { - // If the currently focused index already has a value, it will delete - // that value but maintain the focus on the same input. - if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { - const newNumbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH)]; - return { - input: '', - numbers: newNumbers, - editIndex: focusedIndex, - }; - } - - const hasInputs = _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== 0; - let newNumbers = numbers; - - // Fill the array with empty characters if there are no inputs. - if (focusedIndex === 0 && !hasInputs) { - newNumbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR); - - // Deletes the value of the previous input and focuses on it. - } else if (focusedIndex !== 0) { - newNumbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH)]; - } - - // Saves the input string so that it can compare to the change text - // event that will be triggered, this is a workaround for mobile that - // triggers the change text on the event after the key press. - return { - input: '', - numbers: newNumbers, - focusedIndex: Math.max(0, focusedIndex - 1), - editIndex: Math.max(0, focusedIndex - 1), - }; - }, - () => { - if (_.isUndefined(this.state.focusedIndex)) { - return; - } - this.inputRefs[this.state.focusedIndex].focus(); - }, - ); - } else if (keyValue === 'ArrowLeft' && !_.isUndefined(this.state.focusedIndex)) { - this.setState( - (prevState) => ({ - input: '', - focusedIndex: Math.max(0, prevState.focusedIndex - 1), - editIndex: Math.max(0, prevState.focusedIndex - 1), - }), - () => this.inputRefs[this.state.focusedIndex].focus(), - ); - } else if (keyValue === 'ArrowRight' && !_.isUndefined(this.state.focusedIndex)) { - this.setState( - (prevState) => ({ - input: '', - focusedIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1), - editIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1), - }), - () => this.inputRefs[this.state.focusedIndex].focus(), - ); - } else if (keyValue === 'Enter') { - this.setState({input: ''}); - this.props.onFulfill(this.composeToString(this.state.numbers)); + let numbers = decomposeString(props.value); + + // If the currently focused index already has a value, it will delete + // that value but maintain the focus on the same input. + if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { + setInput(''); + numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH)]; + setEditIndex(focusedIndex); + props.onChangeText(composeToString(numbers)); + return; + } + + const hasInputs = _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== 0; + + // Fill the array with empty characters if there are no inputs. + if (focusedIndex === 0 && !hasInputs) { + numbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR); + + // Deletes the value of the previous input and focuses on it. + } else if (focusedIndex !== 0) { + numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH)]; + } + + const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + props.onChangeText(composeToString(numbers)); + + if (!_.isUndefined(newFocusedIndex)) { + inputRefs.current[newFocusedIndex].focus(); + } } - } - - focus() { - this.setState({focusedIndex: 0}); - this.inputRefs[0].focus(); - } - - clear() { - this.setState({ - input: '', - focusedIndex: 0, - editIndex: 0, - numbers: Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR), - }); - this.inputRefs[0].focus(); - } - - /** - * Converts a given string into an array of numbers that must have the same - * number of elements as the number of inputs. - * - * @param {String} value - * @returns {Array} - */ - decomposeString(value) { - let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); - if (arr.length < CONST.MAGIC_CODE_LENGTH) { - arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); + if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { + const newFocusedIndex = Math.max(0, focusedIndex - 1); + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current[newFocusedIndex].focus(); + } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { + const newFocusedIndex = Math.min(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1); + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current[newFocusedIndex].focus(); + } else if (keyValue === 'Enter') { + setInput(''); + props.onFulfill(props.value); } - return arr; - } - - /** - * Converts an array of strings into a single string. If there are undefined or - * empty values, it will replace them with a space. - * - * @param {Array} value - * @returns {String} - */ - composeToString(value) { - return _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); - } - - render() { - return ( - <> - - {_.map(this.inputPlaceholderSlots, (index) => ( - - - {this.state.numbers[index] || ''} - - - (this.inputRefs[index] = ref)} - autoFocus={index === 0 && this.props.autoFocus} - inputMode="numeric" - textContentType="oneTimeCode" - name={this.props.name} - maxLength={CONST.MAGIC_CODE_LENGTH} - value={this.state.input} - hideFocusedState - autoComplete={index === 0 ? this.props.autoComplete : 'off'} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - onChangeText={(value) => { - // Do not run when the event comes from an input that is - // not currently being responsible for the input, this is - // necessary to avoid calls when the input changes due to - // deleted characters. Only happens in mobile. - if (index !== this.state.editIndex) { - return; - } - this.onChangeText(value); - }} - onKeyPress={this.onKeyPress} - onPress={(event) => this.onPress(event, index)} - onFocus={this.onFocus} - /> - + }; + + return ( + <> + + {_.map(inputPlaceholderSlots, (index) => ( + + + {decomposeString(props.value)[index] || ''} - ))} - - {!_.isEmpty(this.props.errorText) && ( - - )} - - ); - } + + (inputRefs.current[index] = ref)} + autoFocus={index === 0 && props.autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={props.name} + maxLength={CONST.MAGIC_CODE_LENGTH} + value={input} + hideFocusedState + autoComplete={index === 0 ? props.autoComplete : 'off'} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(value) => { + // Do not run when the event comes from an input that is + // not currently being responsible for the input, this is + // necessary to avoid calls when the input changes due to + // deleted characters. Only happens in mobile. + if (index !== editIndex) { + return; + } + onChangeText(value); + }} + onKeyPress={onKeyPress} + onPress={(event) => onPress(event, index)} + onFocus={onFocus} + /> + + + ))} + + {!_.isEmpty(props.errorText) && ( + + )} + + ); } MagicCodeInput.propTypes = propTypes; MagicCodeInput.defaultProps = defaultProps; -export default MagicCodeInput; +export default forwardRef((props, ref) => ( + +)); diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js new file mode 100644 index 000000000000..d1a6441e1d33 --- /dev/null +++ b/src/components/MentionSuggestions.js @@ -0,0 +1,111 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../styles/styles'; +import * as StyleUtils from '../styles/StyleUtils'; +import Text from './Text'; +import CONST from '../CONST'; +import Avatar from './Avatar'; +import AutoCompleteSuggestions from './AutoCompleteSuggestions'; +import getStyledTextArray from '../libs/GetStyledTextArray'; +import avatarPropTypes from './avatarPropTypes'; + +const propTypes = { + /** The index of the highlighted mention */ + highlightedMentionIndex: PropTypes.number, + + /** Array of suggested mentions */ + mentions: PropTypes.arrayOf( + PropTypes.shape({ + /** Display name of the user */ + text: PropTypes.string, + + /** Email/phone number of the user */ + alternateText: PropTypes.string, + + /** Array of icons of the user. We use the first element of this array */ + icons: PropTypes.arrayOf(avatarPropTypes), + }), + ).isRequired, + + /** Fired when the user selects an mention */ + onSelect: PropTypes.func.isRequired, + + /** Mention prefix that follows the @ sign */ + prefix: PropTypes.string.isRequired, + + /** Show that we can use large mention picker. + * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. + * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ + isMentionPickerLarge: PropTypes.bool.isRequired, + + /** Show that we should include ReportRecipientLocalTime view height */ + shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, +}; + +const defaultProps = { + highlightedMentionIndex: 0, +}; + +/** + * Create unique keys for each mention item + * @param {Object} item + * @param {Number} index + * @returns {String} + */ +const keyExtractor = (item) => item.alternateText; + +const MentionSuggestions = (props) => { + /** + * Render a suggestion menu item component. + * @param {Object} item + * @returns {JSX.Element} + */ + const renderSuggestionMenuItem = (item) => { + const displayedText = _.uniq([item.text, item.alternateText]).join(' - '); + const styledTextArray = getStyledTextArray(displayedText, props.prefix); + + return ( + + + + {_.map(styledTextArray, ({text, isColored}, i) => ( + + {text} + + ))} + + + ); + }; + + return ( + + ); +}; + +MentionSuggestions.propTypes = propTypes; +MentionSuggestions.defaultProps = defaultProps; +MentionSuggestions.displayName = 'MentionSuggestions'; + +export default MentionSuggestions; diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 14b0ccc5a3b6..df14af5234bd 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -8,6 +8,7 @@ import OptionRow from '../OptionRow'; import SectionList from '../SectionList'; import Text from '../Text'; import {propTypes as optionsListPropTypes, defaultProps as optionsListDefaultProps} from './optionsListPropTypes'; +import OptionsListSkeletonView from '../OptionsListSkeletonView'; const propTypes = { /** Determines whether the keyboard gets dismissed in response to a drag */ @@ -49,7 +50,7 @@ class BaseOptionsList extends Component { nextProps.focusedIndex !== this.props.focusedIndex || nextProps.selectedOptions.length !== this.props.selectedOptions.length || nextProps.headerMessage !== this.props.headerMessage || - !_.isEqual(nextProps.sections, this.props.sections) || + nextProps.isLoading !== this.props.isLoading || !_.isEqual(nextProps.sections, this.props.sections) ); } @@ -206,33 +207,39 @@ class BaseOptionsList extends Component { render() { return ( - {this.props.headerMessage ? ( - - {this.props.headerMessage} - - ) : null} - + {this.props.isLoading ? ( + + ) : ( + <> + {this.props.headerMessage ? ( + + {this.props.headerMessage} + + ) : null} + + + )} ); } diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 15cb2b4b8a60..915d84e058c3 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -64,6 +64,9 @@ const propTypes = { /** Whether to disable the interactivity of the list's option row(s) */ isDisabled: PropTypes.bool, + /** Whether the options list skeleton loading view should be displayed */ + isLoading: PropTypes.bool, + /** Callback to execute when the SectionList lays out */ onLayout: PropTypes.func, @@ -90,6 +93,7 @@ const defaultProps = { innerRef: null, showTitleTooltip: false, isDisabled: false, + isLoading: false, onLayout: undefined, shouldHaveOptionSeparator: false, shouldDisableRowInnerPadding: false, diff --git a/src/components/LHNSkeletonView.js b/src/components/OptionsListSkeletonView.js similarity index 94% rename from src/components/LHNSkeletonView.js rename to src/components/OptionsListSkeletonView.js index 98f988f63066..02aca958eddd 100644 --- a/src/components/LHNSkeletonView.js +++ b/src/components/OptionsListSkeletonView.js @@ -16,7 +16,7 @@ const defaultTypes = { shouldAnimate: true, }; -class LHNSkeletonView extends React.Component { +class OptionsListSkeletonView extends React.Component { constructor(props) { super(props); this.state = { @@ -105,7 +105,7 @@ class LHNSkeletonView extends React.Component { } } -LHNSkeletonView.propTypes = propTypes; -LHNSkeletonView.defaultProps = defaultTypes; +OptionsListSkeletonView.propTypes = propTypes; +OptionsListSkeletonView.defaultProps = defaultTypes; -export default LHNSkeletonView; +export default OptionsListSkeletonView; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index c1cf0799c391..21af8d1762db 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -12,7 +12,6 @@ import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import TextInput from '../TextInput'; import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; import KeyboardShortcut from '../../libs/KeyboardShortcut'; -import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import {propTypes as optionsSelectorPropTypes, defaultProps as optionsSelectorDefaultProps} from './optionsSelectorPropTypes'; import setSelection from '../../libs/setSelection'; @@ -289,7 +288,7 @@ class BaseOptionsSelector extends Component { blurOnSubmit={Boolean(this.state.allOptions.length)} /> ); - const optionsList = this.props.shouldShowOptions ? ( + const optionsList = ( (this.list = el)} optionHoveredStyle={this.props.optionHoveredStyle} @@ -314,9 +313,8 @@ class BaseOptionsSelector extends Component { } }} contentContainerStyles={shouldShowFooter ? undefined : [this.props.safeAreaPaddingBottomStyle]} + isLoading={!this.props.shouldShowOptions} /> - ) : ( - ); return ( { - const users = PersonalDetailsUtils.getPersonalDetailsByIDs(props.accountIDs, true); + const users = useMemo( + () => PersonalDetailsUtils.getPersonalDetailsByIDs(props.accountIDs, props.currentUserPersonalDetails.accountID, true), + [props.currentUserPersonalDetails.accountID, props.accountIDs], + ); const namesString = _.filter( _.map(users, (user) => user && user.displayName), (n) => n, ).join(', '); - return ( @@ -57,6 +61,6 @@ const ReactionTooltipContent = (props) => { }; ReactionTooltipContent.propTypes = propTypes; -ReactionTooltipContent.defaultProps = withCurrentUserPersonalDetails; +ReactionTooltipContent.defaultProps = defaultProps; ReactionTooltipContent.displayName = 'ReactionTooltipContent'; -export default React.memo(compose(withPersonalDetails(), withLocalize)(ReactionTooltipContent)); +export default React.memo(withLocalize(ReactionTooltipContent)); diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemReactions.js index 5cb2d5d6add1..314f00c68280 100644 --- a/src/components/Reactions/ReportActionItemReactions.js +++ b/src/components/Reactions/ReportActionItemReactions.js @@ -84,7 +84,7 @@ const ReportActionItemReactions = (props) => { props.toggleReaction(emoji); }; const onReactionListOpen = (event) => { - const users = PersonalDetailsUtils.getPersonalDetailsByIDs(reactionUsers); + const users = PersonalDetailsUtils.getPersonalDetailsByIDs(reactionUsers, props.currentUserPersonalDetails.accountID); ReactionList.showReactionList(event, popoverReactionListAnchor.current, users, reaction.emoji, emojiCodes, reactionCount, hasUserReacted); }; @@ -95,6 +95,7 @@ const ReportActionItemReactions = (props) => { emojiName={reaction.emoji} emojiCodes={emojiCodes} accountIDs={reactionUsers} + currentUserPersonalDetails={props.currentUserPersonalDetails} /> )} renderTooltipContentKey={[...reactionUsers, ...emojiCodes]} diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index 202689085d97..758c708e4d91 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -26,6 +26,8 @@ import {showContextMenuForReport} from '../ShowContextMenuContext'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import Button from '../Button'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; +import * as StyleUtils from '../../styles/StyleUtils'; +import getButtonState from '../../libs/getButtonState'; const propTypes = { /** Additional logic for displaying the pay button */ @@ -142,7 +144,7 @@ const IOUPreview = (props) => { const managerEmail = props.iouReport.managerEmail || ''; const ownerEmail = props.iouReport.ownerEmail || ''; - // When displaying within a IOUDetailsModal we cannot guarentee that participants are included in the originalMessage data + // When displaying within a IOUDetailsModal we cannot guarantee that participants are included in the originalMessage data // Because an IOUPreview of type split can never be rendered within the IOUDetailsModal, manually building the email array is only needed for non-billSplit ious const participantEmails = props.isBillSplit ? props.action.originalMessage.participants : [managerEmail, ownerEmail]; const participantAvatars = OptionsListUtils.getAvatarsForLogins(participantEmails, props.personalDetails); @@ -154,6 +156,19 @@ const IOUPreview = (props) => { const requestAmount = props.isBillSplit ? props.action.originalMessage.amount : props.iouReport.total; const requestCurrency = props.isBillSplit ? lodashGet(props.action, 'originalMessage.currency', CONST.CURRENCY.USD) : props.iouReport.currency; + const getSettledMessage = () => { + switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { + case CONST.IOU.PAYMENT_TYPE.PAYPAL_ME: + return props.translate('iou.settledPaypalMe'); + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + return props.translate('iou.settledElsewhere'); + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + return props.translate('iou.settledExpensify'); + default: + return ''; + } + }; + const showContextMenu = (event) => { // Use action and shouldHidePayButton props to check if we are in IOUDetailsModal, // if it's true, do nothing when user long press, otherwise show context menu. @@ -177,10 +192,30 @@ const IOUPreview = (props) => { needsOffscreenAlphaCompositing > - {props.isBillSplit ? props.translate('iou.split') : props.translate('iou.cash')} - {CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)} + {props.isBillSplit ? props.translate('iou.split') : props.translate('iou.cash')} + {Boolean(getSettledMessage()) && ( + <> + + {getSettledMessage()} + + )} + + + + + + {CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)} {!props.iouReport.hasOutstandingIOU && !props.isBillSplit && ( { )} - - - + {props.isBillSplit && ( + + + + )} {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( {props.translate('iou.pendingConversionMessage')} )} - {Str.htmlDecode(lodashGet(props.action, 'originalMessage.comment', ''))} + {Str.htmlDecode(lodashGet(props.action, 'originalMessage.comment', ''))} {isCurrentUserManager && !props.shouldHidePayButton && props.iouReport.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && (