From 9f1cac249a6b4d8dda57d434ec27d2ba91e71be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 4 Aug 2023 12:24:21 +0200 Subject: [PATCH 01/85] move reportactioncompose to own folder --- PERFORMANCE_AUDIT_LOG.md | 42 +++++++ .../ReportActionCompose.js | 108 +++++++++--------- src/pages/home/report/ReportFooter.js | 2 +- 3 files changed, 97 insertions(+), 55 deletions(-) create mode 100644 PERFORMANCE_AUDIT_LOG.md rename src/pages/home/report/{ => ReportActionCompose}/ReportActionCompose.js (94%) diff --git a/PERFORMANCE_AUDIT_LOG.md b/PERFORMANCE_AUDIT_LOG.md new file mode 100644 index 00000000000..328616df0c6 --- /dev/null +++ b/PERFORMANCE_AUDIT_LOG.md @@ -0,0 +1,42 @@ +# Improve the peformance of the composer input + +## Problem / Reproduction + +- Run the desktop app +- Open the developer tools +- Go to performance, and set CPU throttling to 6x and Hardware Concurrency to 8x or 4x +- Open a chat and type something + +You will notice that the input is very badly lacking behind. + +## Findings log + +We start with working from the branch + +- `reapply-onyx-upgrade-use-cache-with-fixes` + +as it contains the Onyx cache fixes, which we want to have in place. + +One thing is certain is, that the composer will lag badly when we re-render the sidebar or the whole report screen. So we want to bring these down as well. + +I was measuring the performance with react devtools, and measured a single key press. +(Note: I put one letter already there, as putting a letter will mark the report as draft, which will cause updates to the SidebarLinks, which i wanted not to shadow the performance investigation for the composer for now). + +- ReportActionCompose re-renders: ~10x +- Composer re-renders: ~15x + +Those components are still class components. Our team has already rewritten them to function components. +I want to apply the performance optimizations to those FCs, so we don't have dupe work. +So i merged the following PRs in my branch: + +- https://github.com/Expensify/App/pull/18648 + - Same amount of re-renders after merging +- https://github.com/Expensify/App/pull/23359 + - Improved performance + +After mergint the PRs: + +- ReportActionCompose re-renders: ~6x +- Composer re-renders: ~8x + +I know want to check if i can even get the component to re-render less, afterwards i want to optimize the children to not re-render if not necessary. diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js similarity index 94% rename from src/pages/home/report/ReportActionCompose.js rename to src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 161f03805f7..47a35a0df9c 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -6,60 +6,60 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'; 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 withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import willBlurTextInputOnTapOutsideFunc from '../../../libs/willBlurTextInputOnTapOutside'; -import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; -import CONST from '../../../CONST'; -import reportActionPropTypes from './reportActionPropTypes'; -import * as ReportUtils from '../../../libs/ReportUtils'; -import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; -import participantPropTypes from '../../../components/participantPropTypes'; -import ParticipantLocalTime from './ParticipantLocalTime'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; -import {withNetwork} from '../../../components/OnyxProvider'; -import * as User from '../../../libs/actions/User'; -import Tooltip from '../../../components/Tooltip'; -import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; -import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; -import OfflineIndicator from '../../../components/OfflineIndicator'; -import ExceededCommentLength from '../../../components/ExceededCommentLength'; -import withNavigationFocus from '../../../components/withNavigationFocus'; -import withNavigation from '../../../components/withNavigation'; -import * as EmojiUtils from '../../../libs/EmojiUtils'; -import * as UserUtils from '../../../libs/UserUtils'; -import ReportDropUI from './ReportDropUI'; -import reportPropTypes from '../../reportPropTypes'; -import EmojiSuggestions from '../../../components/EmojiSuggestions'; -import MentionSuggestions from '../../../components/MentionSuggestions'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; -import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; -import * as ComposerUtils from '../../../libs/ComposerUtils'; -import * as Welcome from '../../../libs/actions/Welcome'; -import Permissions from '../../../libs/Permissions'; -import containerComposeStyles from '../../../styles/containerComposeStyles'; -import * as Task from '../../../libs/actions/Task'; -import * as Browser from '../../../libs/Browser'; -import * as IOU from '../../../libs/actions/IOU'; -import useArrowKeyFocusManager from '../../../hooks/useArrowKeyFocusManager'; -import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; -import usePrevious from '../../../hooks/usePrevious'; -import * as KeyDownListener from '../../../libs/KeyboardShortcut/KeyDownPressListener'; -import * as EmojiPickerActions from '../../../libs/actions/EmojiPickerAction'; -import withAnimatedRef from '../../../components/withAnimatedRef'; -import updatePropsPaperWorklet from '../../../libs/updatePropsPaperWorklet'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Composer from '../../../../components/Composer'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../../components/AttachmentPicker'; +import * as Report from '../../../../libs/actions/Report'; +import ReportTypingIndicator from '../ReportTypingIndicator'; +import AttachmentModal from '../../../../components/AttachmentModal'; +import compose from '../../../../libs/compose'; +import PopoverMenu from '../../../../components/PopoverMenu'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; +import CONST from '../../../../CONST'; +import reportActionPropTypes from '../reportActionPropTypes'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; +import participantPropTypes from '../../../../components/participantPropTypes'; +import ParticipantLocalTime from '../ParticipantLocalTime'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; +import {withNetwork} from '../../../../components/OnyxProvider'; +import * as User from '../../../../libs/actions/User'; +import Tooltip from '../../../../components/Tooltip'; +import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; +import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; +import OfflineIndicator from '../../../../components/OfflineIndicator'; +import ExceededCommentLength from '../../../../components/ExceededCommentLength'; +import withNavigationFocus from '../../../../components/withNavigationFocus'; +import withNavigation from '../../../../components/withNavigation'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import * as UserUtils from '../../../../libs/UserUtils'; +import ReportDropUI from '../ReportDropUI'; +import reportPropTypes from '../../../reportPropTypes'; +import EmojiSuggestions from '../../../../components/EmojiSuggestions'; +import MentionSuggestions from '../../../../components/MentionSuggestions'; +import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; +import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; +import * as ComposerUtils from '../../../../libs/ComposerUtils'; +import * as Welcome from '../../../../libs/actions/Welcome'; +import Permissions from '../../../../libs/Permissions'; +import containerComposeStyles from '../../../../styles/containerComposeStyles'; +import * as Task from '../../../../libs/actions/Task'; +import * as Browser from '../../../../libs/Browser'; +import * as IOU from '../../../../libs/actions/IOU'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import usePrevious from '../../../../hooks/usePrevious'; +import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; +import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; +import withAnimatedRef from '../../../../components/withAnimatedRef'; +import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; const {RNTextInputReset} = NativeModules; diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index b38fdc85373..70f2dc04d5c 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {View, Keyboard} from 'react-native'; import CONST from '../../../CONST'; -import ReportActionCompose from './ReportActionCompose'; +import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; import AnonymousReportFooter from '../../../components/AnonymousReportFooter'; import SwipeableView from '../../../components/SwipeableView'; import OfflineIndicator from '../../../components/OfflineIndicator'; From 12405c3ec18bd3dc441031a6b7891a9f95b200c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 4 Aug 2023 16:12:03 +0200 Subject: [PATCH 02/85] first refactor --- PERFORMANCE_AUDIT_LOG.md | 10 + .../ReportActionCompose.js | 436 ++--------------- .../report/ReportActionCompose/Suggestions.js | 460 ++++++++++++++++++ 3 files changed, 514 insertions(+), 392 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/Suggestions.js diff --git a/PERFORMANCE_AUDIT_LOG.md b/PERFORMANCE_AUDIT_LOG.md index 328616df0c6..b2bcd479433 100644 --- a/PERFORMANCE_AUDIT_LOG.md +++ b/PERFORMANCE_AUDIT_LOG.md @@ -40,3 +40,13 @@ After mergint the PRs: - Composer re-renders: ~8x I know want to check if i can even get the component to re-render less, afterwards i want to optimize the children to not re-render if not necessary. + +### Moving the suggestions out + +I figured that there are a lot of state updates just for the suggestions. +I am moving that to a new component. + +When testing to just remove the suggestion logic I get the following: + +- ReportActionCompose re-renders: ~4x +- Composer re-renders: ~6x \ No newline at end of file diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 47a35a0df9c..755c8b765ea 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -39,11 +39,8 @@ import ExceededCommentLength from '../../../../components/ExceededCommentLength' import withNavigationFocus from '../../../../components/withNavigationFocus'; import withNavigation from '../../../../components/withNavigation'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; -import * as UserUtils from '../../../../libs/UserUtils'; import ReportDropUI from '../ReportDropUI'; import reportPropTypes from '../../../reportPropTypes'; -import EmojiSuggestions from '../../../../components/EmojiSuggestions'; -import MentionSuggestions from '../../../../components/MentionSuggestions'; import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../../libs/ComposerUtils'; @@ -53,13 +50,13 @@ import containerComposeStyles from '../../../../styles/containerComposeStyles'; import * as Task from '../../../../libs/actions/Task'; import * as Browser from '../../../../libs/Browser'; import * as IOU from '../../../../libs/actions/IOU'; -import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; +import Suggestions from './Suggestions'; const {RNTextInputReset} = NativeModules; @@ -150,34 +147,6 @@ const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, }; -const defaultSuggestionsValues = { - suggestedEmojis: [], - suggestedMentions: [], - colonIndex: -1, - atSignIndex: -1, - shouldShowEmojiSuggestionMenu: false, - shouldShowMentionSuggestionMenu: false, - mentionPrefix: '', - isAutoSuggestionPickerLarge: false, -}; - -/** - * Return the max available index for arrow manager. - * @param {Number} numRows - * @param {Boolean} isAutoSuggestionPickerLarge - * @returns {Number} - */ -const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { - // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items - // and for large we show up to 20 items for mentions/emojis - const rowCount = isAutoSuggestionPickerLarge - ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) - : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); - - // -1 because we start at 0 - return rowCount - 1; -}; - const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will @@ -201,32 +170,6 @@ const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { Report.broadcastUserIsTyping(reportID); }, 100); -/** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ -const isEmojiCode = (str, pos) => { - const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const leftWord = _.last(leftWords); - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; -}; - -/** - * Check if this piece of string looks like a mention - * @param {String} str - * @returns {Boolean} - */ -const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); - -/** - * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} - */ -const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); - // For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), // so we need to ensure that it is only updated after focus. @@ -243,10 +186,6 @@ function ReportActionCompose({translate, animatedRef, ...props}) { const shouldAutoFocus = !props.modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && props.shouldShowComposeInput; - // These variables are used to decide whether to block the suggestions list from showing to prevent flickering - const shouldBlockEmojiCalc = useRef(false); - const shouldBlockMentionCalc = useRef(false); - /** * Updates the should clear state of the composer */ @@ -266,23 +205,6 @@ function ReportActionCompose({translate, animatedRef, ...props}) { const [composerHeight, setComposerHeight] = useState(0); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer - const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); - - const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; - const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowMentionSuggestionMenu; - - const [highlightedEmojiIndex] = useArrowKeyFocusManager({ - isActive: isEmojiSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), - shouldExcludeTextAreaNodes: false, - }); - const [highlightedMentionIndex] = useArrowKeyFocusManager({ - isActive: isMentionSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), - shouldExcludeTextAreaNodes: false, - }); - const insertedEmojis = useRef([]); /** @@ -304,6 +226,8 @@ function ReportActionCompose({translate, animatedRef, ...props}) { const textInput = useRef(null); const actionButton = useRef(null); + const suggestionsRef = useRef(null); + const reportParticipants = useMemo( () => _.without(lodashGet(props.report, 'participantAccountIDs', []), props.currentUserPersonalDetails.accountID), [props.currentUserPersonalDetails.accountID, props.report], @@ -482,170 +406,15 @@ function ReportActionCompose({translate, animatedRef, ...props}) { [checkComposerVisibility, focus, replaceSelectionWithText], ); - /** - * Clean data related to EmojiSuggestions - */ - const resetSuggestions = useCallback(() => { - setSuggestionValues(defaultSuggestionsValues); - }, []); - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - const calculateEmojiSuggestion = useCallback( - (selectionEnd) => { - if (shouldBlockEmojiCalc.current) { - shouldBlockEmojiCalc.current = false; - return; - } - const leftString = value.substring(0, selectionEnd); - const colonIndex = leftString.lastIndexOf(':'); - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = props.windowHeight / composerHeight >= 6.8; - const isAutoSuggestionPickerLarge = !props.isSmallScreenWidth || (props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - - const nextState = { - suggestedEmojis: [], - colonIndex, - shouldShowEmojiSuggestionMenu: false, - isAutoSuggestionPickerLarge, - }; - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, props.preferredLocale); - - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); - } - - setSuggestionValues((prevState) => ({...prevState, ...nextState})); - }, - [value, props.windowHeight, props.isSmallScreenWidth, props.preferredLocale, composerHeight], - ); - - const getMentionOptions = useCallback( - (personalDetails, searchValue = '') => { - const suggestions = []; - - if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { - suggestions.push({ - text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, - alternateText: translate('mentionSuggestions.hereAlternateText'), - icons: [ - { - source: Expensicons.Megaphone, - type: 'avatar', - }, - ], - }); - } - - const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => { - // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail.login) { - return false; - } - if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { - return false; - } - return true; - }); - - const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); - _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { - suggestions.push({ - text: detail.displayName, - alternateText: detail.login, - icons: [ - { - name: detail.login, - source: UserUtils.getAvatar(detail.avatar, detail.accountID), - type: 'avatar', - }, - ], - }); - }); - - return suggestions; - }, - [translate], - ); - - const calculateMentionSuggestion = useCallback( - (selectionEnd) => { - if (shouldBlockMentionCalc.current) { - shouldBlockMentionCalc.current = false; - return; - } - - const valueAfterTheCursor = value.substring(selectionEnd); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - - let indexOfLastNonWhitespaceCharAfterTheCursor; - if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { - // we didn't find a whitespace/emoji after the cursor, so we will use the entire string - indexOfLastNonWhitespaceCharAfterTheCursor = value.length; - } else { - indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd; - } - - const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const lastWord = _.last(words); - - let atSignIndex; - if (lastWord.startsWith('@')) { - atSignIndex = leftString.lastIndexOf(lastWord); - } - - const prefix = lastWord.substring(1); - - const nextState = { - suggestedMentions: [], - atSignIndex, - mentionPrefix: prefix, - }; - - const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); - - if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { - const suggestions = getMentionOptions(props.personalDetails, prefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); - } - - setSuggestionValues((prevState) => ({ - ...prevState, - ...nextState, - })); - }, - [getMentionOptions, props.personalDetails, value], - ); - - const onSelectionChange = useCallback( - (e) => { - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + const onSelectionChange = useCallback((e) => { + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - if (!value || e.nativeEvent.selection.end < 1) { - resetSuggestions(); - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; - return; - } - - setSelection(e.nativeEvent.selection); + if (suggestionsRef.current.onSelectionChange(e)) { + return; + } - /** - * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion - * because in other case calculateEmojiSuggestion will have an old calculation value - * of suggestion instead of current one - */ - calculateEmojiSuggestion(e.nativeEvent.selection.end); - calculateMentionSuggestion(e.nativeEvent.selection.end); - }, - [calculateEmojiSuggestion, calculateMentionSuggestion, resetSuggestions, value], - ); + setSelection(e.nativeEvent.selection); + }, []); const setUpComposeFocusManager = useCallback(() => { // This callback is used in the contextMenuActions to manage giving focus back to the compose input. @@ -702,16 +471,6 @@ function ReportActionCompose({translate, animatedRef, ...props}) { })); }, [props.betas, props.report, reportParticipants, translate]); - // eslint-disable-next-line rulesdir/prefer-early-return - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (suggestionValues.shouldShowEmojiSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); - } - if (suggestionValues.shouldShowMentionSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowMentionSuggestionMenu: false})); - } - }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]); - /** * Determines if we can show the task option * @returns {Boolean} @@ -731,62 +490,6 @@ function ReportActionCompose({translate, animatedRef, ...props}) { ]; }, [props.betas, props.report, props.reportID, translate]); - /** - * Replace the code of emoji and update selection - * @param {Number} selectedEmoji - */ - const insertSelectedEmoji = useCallback( - (selectedEmoji) => { - const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const emojiObject = suggestionValues.suggestedEmojis[selectedEmoji]; - const emojiCode = emojiObject.types && emojiObject.types[props.preferredSkinTone] ? emojiObject.types[props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - - updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - - // In some Android phones keyboard, the text to search for the emoji is not cleared - // will be added after the user starts typing again on the keyboard. This package is - // a workaround to reset the keyboard natively. - if (RNTextInputReset) { - RNTextInputReset.resetKeyboardInput(findNodeHandle(textInput)); - } - - setSelection({ - start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - - insertedEmojis.current = [...insertedEmojis.current, emojiObject]; - debouncedUpdateFrequentlyUsedEmojis(emojiObject); - }, - [debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, selection.end, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], - ); - - /** - * Replace the code of mention and update selection - * @param {Number} highlightedMentionIndex - */ - const insertSelectedMention = useCallback( - (highlightedMentionIndexInner) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); - const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; - const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; - const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - - updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); - setSelection({ - start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({ - ...prevState, - suggestedMentions: [], - })); - }, - [suggestionValues, value, updateComment], - ); - /** * Update the number of lines for a comment in Onyx * @param {Number} numberOfLines @@ -819,6 +522,13 @@ function ReportActionCompose({translate, animatedRef, ...props}) { return trimmedComment; }, [props.reportID, updateComment, props.isComposerFullSize]); + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (!suggestionsRef.current) { + return; + } + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + }, []); + /** * Add a new comment to this chat * @@ -845,37 +555,13 @@ function ReportActionCompose({translate, animatedRef, ...props}) { [prepareCommentAndResetComposer, props], ); - /** - * 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 suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; - - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); - if (suggestionValues.suggestedEmojis.length > 0) { - insertSelectedEmoji(highlightedEmojiIndex); - } - if (suggestionValues.suggestedMentions.length > 0) { - insertSelectedMention(highlightedMentionIndex); - } - return; - } - - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); - - if (suggestionsExist) { - resetSuggestions(); - } - + if (suggestionsRef.current.triggerHotkeyActions(e)) { return; } @@ -898,23 +584,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { } } }, - [ - highlightedEmojiIndex, - highlightedMentionIndex, - insertSelectedEmoji, - insertSelectedMention, - props.isKeyboardShown, - props.isSmallScreenWidth, - props.parentReportActions, - props.report, - props.reportActions, - props.reportID, - resetSuggestions, - submitForm, - suggestionValues.suggestedEmojis.length, - suggestionValues.suggestedMentions.length, - value.length, - ], + [props.isKeyboardShown, props.isSmallScreenWidth, props.parentReportActions, props.report, props.reportActions, props.reportID, submitForm, value.length], ); /** @@ -937,10 +607,9 @@ function ReportActionCompose({translate, animatedRef, ...props}) { * Event handler to update the state after the attachment preview is closed. */ const attachmentPreviewClosed = useCallback(() => { - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; + updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); - }, []); + }, [updateShouldShowSuggestionMenuToFalse]); useEffect(() => { const unsubscribeNavigationBlur = props.navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); @@ -1091,7 +760,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { { e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(); Report.setIsComposerFullSize(props.reportID, true); }} // Keep focus on the composer when Expand button is clicked. @@ -1170,12 +839,9 @@ function ReportActionCompose({translate, animatedRef, ...props}) { onFocus={() => setIsFocused(true)} onBlur={() => { setIsFocused(false); - resetSuggestions(); - }} - onClick={() => { - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; + suggestionsRef.current.resetSuggestions(); }} + onClick={updateShouldShowSuggestionMenuToFalse()} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} onClear={() => setTextInputShouldClear(false)} @@ -1196,7 +862,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { } setComposerHeight(composerLayoutHeight); }} - onScroll={() => updateShouldShowSuggestionMenuToFalse()} + onScroll={updateShouldShowSuggestionMenuToFalse} /> - {isEmojiSuggestionsMenuVisible && ( - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))} - highlightedEmojiIndex={highlightedEmojiIndex} - emojis={suggestionValues.suggestedEmojis} - comment={value} - updateComment={(newComment) => setValue(newComment)} - colonIndex={suggestionValues.colonIndex} - prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)} - onSelect={insertSelectedEmoji} - isComposerFullSize={props.isComposerFullSize} - preferredSkinToneIndex={props.preferredSkinTone} - isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - )} - {isMentionSuggestionsMenuVisible && ( - setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))} - highlightedMentionIndex={highlightedMentionIndex} - mentions={suggestionValues.suggestedMentions} - comment={value} - updateComment={(newComment) => setValue(newComment)} - colonIndex={suggestionValues.colonIndex} - prefix={suggestionValues.mentionPrefix} - onSelect={insertSelectedMention} - isComposerFullSize={props.isComposerFullSize} - isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - )} + ); } diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js new file mode 100644 index 00000000000..7660325844b --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -0,0 +1,460 @@ +import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import EmojiSuggestions from '../../../../components/EmojiSuggestions'; +import MentionSuggestions from '../../../../components/MentionSuggestions'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import * as UserUtils from '../../../../libs/UserUtils'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isAutoSuggestionPickerLarge + * @returns {Number} + */ +const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { + // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items + // and for large we show up to 20 items for mentions/emojis + const rowCount = isAutoSuggestionPickerLarge + ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) + : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); + + // -1 because we start at 0 + return rowCount - 1; +}; + +/** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ +const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); + +/** + * Check if this piece of string looks like an emoji + * @param {String} str + * @param {Number} pos + * @returns {Boolean} + */ +const isEmojiCode = (str, pos) => { + const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const leftWord = _.last(leftWords); + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; +}; + +/** + * Check if this piece of string looks like a mention + * @param {String} str + * @returns {Boolean} + */ +const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); + +const defaultSuggestionsValues = { + suggestedEmojis: [], + suggestedMentions: [], + colonIndex: -1, + atSignIndex: -1, + shouldShowEmojiSuggestionMenu: false, + shouldShowMentionSuggestionMenu: false, + mentionPrefix: '', + isAutoSuggestionPickerLarge: false, +}; + +const propTypes = { + // Onyx/Hooks + preferredSkinTone: PropTypes.number.isRequired, + windowHeight: PropTypes.number.isRequired, + isSmallScreenWidth: PropTypes.bool.isRequired, + preferredLocale: PropTypes.string.isRequired, + personalDetails: PropTypes.object.isRequired, + translate: PropTypes.func.isRequired, + // Input + value: PropTypes.string.isRequired, + setValue: PropTypes.func.isRequired, + selection: PropTypes.shape({ + start: PropTypes.number.isRequired, + end: PropTypes.number.isRequired, + }).isRequired, + setSelection: PropTypes.func.isRequired, + // Esoteric props + isComposerFullSize: PropTypes.bool.isRequired, + updateComment: PropTypes.func.isRequired, + composerHeight: PropTypes.number.isRequired, + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + // Custom added + forwardedRef: PropTypes.object.isRequired, +}; + +// TODO: split between emoji and mention suggestions +function Suggestions({ + isComposerFullSize, + windowHeight, + preferredLocale, + isSmallScreenWidth, + preferredSkinTone, + personalDetails, + translate, + value, + setValue, + selection, + setSelection, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, +}) { + // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + + const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; + const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowMentionSuggestionMenu; + + const [highlightedEmojiIndex] = useArrowKeyFocusManager({ + isActive: isEmojiSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + const [highlightedMentionIndex] = useArrowKeyFocusManager({ + isActive: isMentionSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + // These variables are used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockEmojiCalc = useRef(false); + const shouldBlockMentionCalc = useRef(false); + + /** + * Replace the code of emoji and update selection + * @param {Number} selectedEmoji + */ + const insertSelectedEmoji = useCallback( + (selectedEmoji) => { + const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); + const emojiObject = suggestionValues.suggestedEmojis[selectedEmoji]; + const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); + + updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + + // TODO: i think this should come from the outside + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + // if (RNTextInputReset) { + // RNTextInputReset.resetKeyboardInput(findNodeHandle(textInput)); + // } + + setSelection({ + start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); + + // TODO: function from the outside + // insertedEmojis.current = [...insertedEmojis.current, emojiObject]; + // debouncedUpdateFrequentlyUsedEmojis(emojiObject); + }, + [preferredSkinTone, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + ); + + /** + * Replace the code of mention and update selection + * @param {Number} highlightedMentionIndex + */ + const insertSelectedMention = useCallback( + (highlightedMentionIndexInner) => { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); + const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; + const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; + const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); + + updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); + setSelection({ + start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + })); + }, + [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, updateComment, setSelection], + ); + + /** + * Clean data related to EmojiSuggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback( + (e) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; + + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedEmojis.length > 0) { + insertSelectedEmoji(highlightedEmojiIndex); + } + if (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + } + return true; + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [ + highlightedEmojiIndex, + highlightedMentionIndex, + insertSelectedEmoji, + insertSelectedMention, + resetSuggestions, + suggestionValues.suggestedEmojis.length, + suggestionValues.suggestedMentions.length, + ], + ); + + /** + * Calculates and cares about the content of an Emoji Suggester + */ + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockEmojiCalc.current) { + shouldBlockEmojiCalc.current = false; + return; + } + const leftString = value.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; + const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + + const nextState = { + suggestedEmojis: [], + colonIndex, + shouldShowEmojiSuggestionMenu: false, + isAutoSuggestionPickerLarge, + }; + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); + + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + } + + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + }, + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale], + ); + + const getMentionOptions = useCallback( + (personalDetailsParam, searchValue = '') => { + const suggestions = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: Expensicons.Megaphone, + type: 'avatar', + }, + ], + }); + } + + const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => { + // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned + if (!detail.login) { + return false; + } + if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { + return false; + } + return true; + }); + + const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); + _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { + suggestions.push({ + text: detail.displayName, + alternateText: detail.login, + icons: [ + { + name: detail.login, + source: UserUtils.getAvatar(detail.avatar, detail.accountID), + type: 'avatar', + }, + ], + }); + }); + + return suggestions; + }, + [translate], + ); + + const calculateMentionSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockMentionCalc.current) { + shouldBlockMentionCalc.current = false; + return; + } + + const valueAfterTheCursor = value.substring(selectionEnd); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + + let indexOfLastNonWhitespaceCharAfterTheCursor; + if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { + // we didn't find a whitespace/emoji after the cursor, so we will use the entire string + indexOfLastNonWhitespaceCharAfterTheCursor = value.length; + } else { + indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd; + } + + const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const lastWord = _.last(words); + + let atSignIndex; + if (lastWord.startsWith('@')) { + atSignIndex = leftString.lastIndexOf(lastWord); + } + + const prefix = lastWord.substring(1); + + const nextState = { + suggestedMentions: [], + atSignIndex, + mentionPrefix: prefix, + }; + + const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); + + if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { + const suggestions = getMentionOptions(personalDetails, prefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); + } + + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + }, + [getMentionOptions, personalDetails, value], + ); + + const onSelectionChange = useCallback( + (e) => { + if (!value || e.nativeEvent.selection.end < 1) { + resetSuggestions(); + shouldBlockEmojiCalc.current = false; + shouldBlockMentionCalc.current = false; + return true; + } + + /** + * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion + * because in other case calculateEmojiSuggestion will have an old calculation value + * of suggestion instead of current one + */ + calculateEmojiSuggestion(e.nativeEvent.selection.end); + calculateMentionSuggestion(e.nativeEvent.selection.end); + }, + [calculateEmojiSuggestion, calculateMentionSuggestion, resetSuggestions, value], + ); + + // eslint-disable-next-line rulesdir/prefer-early-return + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (suggestionValues.shouldShowEmojiSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); + } + if (suggestionValues.shouldShowMentionSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowMentionSuggestionMenu: false})); + } + }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + }), + [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + ); + + return ( + <> + {isEmojiSuggestionsMenuVisible && ( + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestionValues.suggestedEmojis} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={isComposerFullSize} + preferredSkinToneIndex={preferredSkinTone} + isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + )} + {isMentionSuggestionsMenuVisible && ( + setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))} + highlightedMentionIndex={highlightedMentionIndex} + mentions={suggestionValues.suggestedMentions} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={suggestionValues.mentionPrefix} + onSelect={insertSelectedMention} + isComposerFullSize={isComposerFullSize} + isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + )} + + ); +} + +Suggestions.propTypes = propTypes; + +const SuggestionsWithRef = React.forwardRef((props, ref) => ( + +)); + +export default SuggestionsWithRef; From 0cab4a4d3013b4797e459a19daae7bb10ddfa198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 4 Aug 2023 17:13:59 +0200 Subject: [PATCH 03/85] temp: hack good performance --- PERFORMANCE_AUDIT_LOG.md | 7 ++++++- src/components/Composer/index.js | 2 +- .../report/ReportActionCompose/ReportActionCompose.js | 11 ++++++----- web/index.html | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/PERFORMANCE_AUDIT_LOG.md b/PERFORMANCE_AUDIT_LOG.md index b2bcd479433..a8435584e29 100644 --- a/PERFORMANCE_AUDIT_LOG.md +++ b/PERFORMANCE_AUDIT_LOG.md @@ -49,4 +49,9 @@ I am moving that to a new component. When testing to just remove the suggestion logic I get the following: - ReportActionCompose re-renders: ~4x -- Composer re-renders: ~6x \ No newline at end of file +- Composer re-renders: ~6x + +### Loading further messages + +I just made the observation that when we open a new chat the scroll bar on the right gets smaller and smalle. THat probably means we are loading and rendering more and more messages. +I think we should just reduce the size of initially loaded messages, to improve performance. Because after that the chat input is stable. \ No newline at end of file diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 25dd9dc9882..be821d64240 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -461,7 +461,7 @@ function Composer({ defaultValue={defaultValue} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} - onSelectionChange={addCursorPositionToSelectionChange} + // onSelectionChange={addCursorPositionToSelectionChange} numberOfLines={numberOfLines} disabled={isDisabled} onKeyPress={handleKeyPress} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 755c8b765ea..4ecdfa302f4 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -175,11 +175,12 @@ const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { // so we need to ensure that it is only updated after focus. const isMobileSafari = Browser.isMobileSafari(); +const noop = () => {}; function ReportActionCompose({translate, animatedRef, ...props}) { /** * Updates the Highlight state of the composer */ - const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && !props.modal.isVisible && !props.modal.willAlertModalBecomeVisible && props.shouldShowComposeInput); + const [isFocused, setIsFocused] = [true, noop]; // useState(shouldFocusInputOnScreenFocus && !props.modal.isVisible && !props.modal.willAlertModalBecomeVisible && props.shouldShowComposeInput); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(props.isComposerFullSize); const isEmptyChat = useMemo(() => _.size(props.reportActions) === 1, [props.reportActions]); @@ -832,7 +833,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { textAlignVertical="top" placeholder={inputPlaceholder} placeholderTextColor={themeColors.placeholderText} - onChangeText={(commentValue) => updateComment(commentValue, true)} + // onChangeText={(commentValue) => updateComment(commentValue, true)} onKeyPress={triggerHotkeyActions} style={[styles.textInputCompose, props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxComposerLines} @@ -851,7 +852,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { isFullComposerAvailable={isFullSizeComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} isComposerFullSize={props.isComposerFullSize} - value={value} + // value={value} numberOfLines={props.numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition @@ -923,7 +924,7 @@ function ReportActionCompose({translate, animatedRef, ...props}) { /> - + /> */} ); } diff --git a/web/index.html b/web/index.html index d207fa54b97..4dae85ed727 100644 --- a/web/index.html +++ b/web/index.html @@ -122,6 +122,7 @@ + <% if (htmlWebpackPlugin.options.usePolyfillIO) { %> From ea26c2cf7ec9a9284467ccbcaa1a7fdff0de496d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 11:44:30 +0200 Subject: [PATCH 04/85] fix issues after --- .../ReportActionCompose.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 056aceb7b79..d8b1d7778ee 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -606,7 +606,7 @@ function ReportActionCompose({ } } }, - [props.isKeyboardShown, props.isSmallScreenWidth, props.parentReportActions, props.report, props.reportActions, props.reportID, submitForm, value.length], + [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, value.length], ); /** @@ -788,7 +788,7 @@ function ReportActionCompose({ onPress={(e) => { e.preventDefault(); suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(props.reportID, true); + Report.setIsComposerFullSize(reportID, true); }} // Keep focus on the composer when Expand button is clicked. onMouseDown={(e) => e.preventDefault()} @@ -897,9 +897,9 @@ function ReportActionCompose({ onSelectionChange={onSelectionChange} isFullComposerAvailable={isFullSizeComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} - isComposerFullSize={props.isComposerFullSize} + isComposerFullSize={isComposerFullSize} // value={value} - numberOfLines={props.numberOfLines} + numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition onLayout={(e) => { @@ -972,18 +972,18 @@ function ReportActionCompose({ {/* Date: Mon, 7 Aug 2023 11:46:49 +0200 Subject: [PATCH 05/85] enabled suggestions --- .../home/report/ReportActionCompose/ReportActionCompose.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index d8b1d7778ee..85a5223cb24 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -970,13 +970,14 @@ function ReportActionCompose({ /> - {/* */} + /> ); } From 5042b4b3ab65f334a8de8363e26d755b16b9b0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 11:54:17 +0200 Subject: [PATCH 06/85] add value prop back --- .../home/report/ReportActionCompose/ReportActionCompose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 85a5223cb24..524175be476 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -879,7 +879,7 @@ function ReportActionCompose({ textAlignVertical="top" placeholder={inputPlaceholder} placeholderTextColor={themeColors.placeholderText} - // onChangeText={(commentValue) => updateComment(commentValue, true)} + onChangeText={(commentValue) => updateComment(commentValue, true)} onKeyPress={triggerHotkeyActions} style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxComposerLines} @@ -898,7 +898,7 @@ function ReportActionCompose({ isFullComposerAvailable={isFullSizeComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} isComposerFullSize={isComposerFullSize} - // value={value} + value={value} numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition From 1bf017711bf00e98d020e497c6aff0536b28048b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 12:03:36 +0200 Subject: [PATCH 07/85] use `insertedEmojis` ref correctly --- .../report/ReportActionCompose/ReportActionCompose.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 524175be476..c2f5fbc0bf9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -233,15 +233,15 @@ function ReportActionCompose({ const [composerHeight, setComposerHeight] = useState(0); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const insertedEmojis = useRef([]); + const insertedEmojisRef = useRef([]); /** * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis * API is not called too often. */ const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojis)); - insertedEmojis.current = []; + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); + insertedEmojisRef.current = []; }, []); /** @@ -323,7 +323,7 @@ function ReportActionCompose({ if (!_.isEmpty(emojis)) { User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); - insertedEmojis.current = [...insertedEmojis, ...emojis]; + insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; debouncedUpdateFrequentlyUsedEmojis(); } From 3ab1ca480a380166d4dbbb9d95527f3d589b41c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 12:29:37 +0200 Subject: [PATCH 08/85] add selection change back --- src/components/Composer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index be821d64240..25dd9dc9882 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -461,7 +461,7 @@ function Composer({ defaultValue={defaultValue} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} - // onSelectionChange={addCursorPositionToSelectionChange} + onSelectionChange={addCursorPositionToSelectionChange} numberOfLines={numberOfLines} disabled={isDisabled} onKeyPress={handleKeyPress} From 9ec7169596834929cb8557900be16abed99e18f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 12:47:21 +0200 Subject: [PATCH 09/85] fix issue with menu closing instantly --- .../home/report/ReportActionCompose/ReportActionCompose.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c2f5fbc0bf9..d1768394c8f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -888,7 +888,7 @@ function ReportActionCompose({ setIsFocused(false); suggestionsRef.current.resetSuggestions(); }} - onClick={updateShouldShowSuggestionMenuToFalse()} + onClick={updateShouldShowSuggestionMenuToFalse} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} onClear={() => setTextInputShouldClear(false)} From cfc7b25f49bfb041e7be39223aee8ffe93029614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 12:56:36 +0200 Subject: [PATCH 10/85] pass callbacks --- .../ReportActionCompose/ReportActionCompose.js | 18 ++++++++++++++++++ .../report/ReportActionCompose/Suggestions.js | 15 +++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index d1768394c8f..931881f8550 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -633,6 +633,21 @@ function ReportActionCompose({ setIsAttachmentPreviewActive(false); }, [updateShouldShowSuggestionMenuToFalse]); + const onInsertedEmoji = useCallback( + (emojiObject) => { + insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; + debouncedUpdateFrequentlyUsedEmojis(emojiObject); + }, + [debouncedUpdateFrequentlyUsedEmojis], + ); + + const resetKeyboardInput = useCallback(() => { + if (!RNTextInputReset) { + return; + } + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInput)); + }, [textInput]); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -988,7 +1003,10 @@ function ReportActionCompose({ updateComment={updateComment} composerHeight={composerHeight} shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} + // Custom added ref={suggestionsRef} + onInsertedEmoji={onInsertedEmoji} + resetKeyboardInput={resetKeyboardInput} /> ); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 7660325844b..3a510f925e3 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -86,6 +86,8 @@ const propTypes = { shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added forwardedRef: PropTypes.object.isRequired, + onInsertedEmoji: PropTypes.func.isRequired, + resetKeyboardInput: PropTypes.func.isRequired, }; // TODO: split between emoji and mention suggestions @@ -105,6 +107,8 @@ function Suggestions({ composerHeight, shouldShowReportRecipientLocalTime, forwardedRef, + onInsertedEmoji, + resetKeyboardInput, }) { // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -140,13 +144,10 @@ function Suggestions({ updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - // TODO: i think this should come from the outside // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is // a workaround to reset the keyboard natively. - // if (RNTextInputReset) { - // RNTextInputReset.resetKeyboardInput(findNodeHandle(textInput)); - // } + resetKeyboardInput(); setSelection({ start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -154,11 +155,9 @@ function Suggestions({ }); setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - // TODO: function from the outside - // insertedEmojis.current = [...insertedEmojis.current, emojiObject]; - // debouncedUpdateFrequentlyUsedEmojis(emojiObject); + onInsertedEmoji(emojiObject); }, - [preferredSkinTone, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], ); /** From e7d3a956fa4c68d9ebe61bdcea5408f371fffcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 13:31:55 +0200 Subject: [PATCH 11/85] call bloc calculations --- .../report/ReportActionCompose/ReportActionCompose.js | 6 ++---- .../home/report/ReportActionCompose/Suggestions.js | 11 ++++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 931881f8550..b4d1fa44318 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -849,8 +849,7 @@ function ReportActionCompose({ // Set a flag to block suggestion calculation until we're finished using the file picker, // which will stop any flickering as the file picker opens on non-native devices. if (willBlurTextInputOnTapOutside) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; + suggestionsRef.current.setShouldBlockSuggestionCalc(true); } openPicker({ onPicked: displayFileInModal, @@ -869,8 +868,7 @@ function ReportActionCompose({ // Set a flag to block suggestion calculation until we're finished using the file picker, // which will stop any flickering as the file picker opens on non-native devices. if (willBlurTextInputOnTapOutside) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; + suggestionsRef.current.setShouldBlockSuggestionCalc(true); } openPicker({ diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 3a510f925e3..54a222daf66 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -396,15 +396,24 @@ function Suggestions({ } }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]); + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc) => { + shouldBlockEmojiCalc.current = shouldBlockSuggestionCalc; + shouldBlockMentionCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockEmojiCalc, shouldBlockMentionCalc], + ); + useImperativeHandle( forwardedRef, () => ({ resetSuggestions, onSelectionChange, triggerHotkeyActions, + setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, }), - [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); return ( From 1156d617f9059ee5f93f2a5a215562fb9f260c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 7 Aug 2023 21:20:35 +0200 Subject: [PATCH 12/85] wip --- .../ReportActionCompose/SuggestionEmoji.js | 184 ++++++++ .../ReportActionCompose/SuggestionMention.js | 351 +++++++++++++++ .../report/ReportActionCompose/Suggestions.js | 418 +++--------------- 3 files changed, 596 insertions(+), 357 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/SuggestionEmoji.js create mode 100644 src/pages/home/report/ReportActionCompose/SuggestionMention.js diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js new file mode 100644 index 00000000000..ac06eeae10d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -0,0 +1,184 @@ +import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import MentionSuggestions from '../../../../components/MentionSuggestions'; +import * as UserUtils from '../../../../libs/UserUtils'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; + +/** + * Check if this piece of string looks like an emoji + * @param {String} str + * @param {Number} pos + * @returns {Boolean} + */ +const isEmojiCode = (str, pos) => { + const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const leftWord = _.last(leftWords); + return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; +}; + +function SuggestionEmoji() { + const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; + + const [highlightedEmojiIndex] = useArrowKeyFocusManager({ + isActive: isEmojiSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + /** + * Replace the code of emoji and update selection + * @param {Number} selectedEmoji + */ + const insertSelectedEmoji = useCallback( + (selectedEmoji) => { + const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); + const emojiObject = suggestionValues.suggestedEmojis[selectedEmoji]; + const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); + + updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + resetKeyboardInput(); + + setSelection({ + start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); + + onInsertedEmoji(emojiObject); + }, + [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + ); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback( + (e) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; + + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedEmojis.length > 0) { + insertSelectedEmoji(highlightedEmojiIndex); + } + return true; + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [ + highlightedEmojiIndex, + highlightedMentionIndex, + insertSelectedEmoji, + insertSelectedMention, + resetSuggestions, + suggestionValues.suggestedEmojis.length, + suggestionValues.suggestedMentions.length, + ], + ); + + /** + * Calculates and cares about the content of an Emoji Suggester + */ + const calculateEmojiSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockEmojiCalc.current) { + shouldBlockEmojiCalc.current = false; + return; + } + const leftString = value.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; + const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + + const nextState = { + suggestedEmojis: [], + colonIndex, + shouldShowEmojiSuggestionMenu: false, + isAutoSuggestionPickerLarge, + }; + const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); + + if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + } + + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + }, + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale], + ); + + const onSelectionChange = useCallback( + (e) => { + if (!value || e.nativeEvent.selection.end < 1) { + resetSuggestions(); + shouldBlockEmojiCalc.current = false; + shouldBlockMentionCalc.current = false; + return true; + } + + /** + * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion + * because in other case calculateEmojiSuggestion will have an old calculation value + * of suggestion instead of current one + */ + calculateEmojiSuggestion(e.nativeEvent.selection.end); + calculateMentionSuggestion(e.nativeEvent.selection.end); + }, + [calculateEmojiSuggestion, calculateMentionSuggestion, resetSuggestions, value], + ); + + if (!isEmojiSuggestionsMenuVisible) { + return null; + } + + return ( + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))} + highlightedEmojiIndex={highlightedEmojiIndex} + emojis={suggestionValues.suggestedEmojis} + comment={value} + updateComment={(newComment) => setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)} + onSelect={insertSelectedEmoji} + isComposerFullSize={isComposerFullSize} + preferredSkinToneIndex={preferredSkinTone} + isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + ); +} + +const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( + +)); + +export default SuggestionEmojiWithRef; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js new file mode 100644 index 00000000000..57a6e6103c2 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -0,0 +1,351 @@ +import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import MentionSuggestions from '../../../../components/MentionSuggestions'; +import * as UserUtils from '../../../../libs/UserUtils'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import usePrevious from '../../../../hooks/usePrevious'; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isAutoSuggestionPickerLarge + * @returns {Number} + */ +const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { + // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items + // and for large we show up to 20 items for mentions/emojis + const rowCount = isAutoSuggestionPickerLarge + ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) + : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); + + // -1 because we start at 0 + return rowCount - 1; +}; + +/** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ +const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); + +/** + * Check if this piece of string looks like a mention + * @param {String} str + * @returns {Boolean} + */ +const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); + +const defaultSuggestionsValues = { + suggestedMentions: [], + atSignIndex: -1, + shouldShowSuggestionMenu: false, + mentionPrefix: '', + isAutoSuggestionPickerLarge: false, +}; + +const propTypes = { + // Onyx/Hooks + preferredSkinTone: PropTypes.number.isRequired, + windowHeight: PropTypes.number.isRequired, + isSmallScreenWidth: PropTypes.bool.isRequired, + preferredLocale: PropTypes.string.isRequired, + personalDetails: PropTypes.object.isRequired, + translate: PropTypes.func.isRequired, + // Input + value: PropTypes.string.isRequired, + setValue: PropTypes.func.isRequired, + selection: PropTypes.shape({ + start: PropTypes.number.isRequired, + end: PropTypes.number.isRequired, + }).isRequired, + setSelection: PropTypes.func.isRequired, + // Esoteric props + isComposerFullSize: PropTypes.bool.isRequired, + updateComment: PropTypes.func.isRequired, + composerHeight: PropTypes.number.isRequired, + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + // Custom added + forwardedRef: PropTypes.object.isRequired, + resetKeyboardInput: PropTypes.func.isRequired, +}; + +// TODO: split between emoji and mention suggestions +function SuggestionMention({ + isComposerFullSize, + windowHeight, + preferredLocale, + isSmallScreenWidth, + preferredSkinTone, + personalDetails, + translate, + value, + setValue, + selection, + setSelection, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, + resetKeyboardInput, +}) { + // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + // TODO: const valueRef = usePrevious(value); (maybe even pass from parent?) + + const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; + + const [highlightedMentionIndex] = useArrowKeyFocusManager({ + isActive: isMentionSuggestionsMenuVisible, + maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), + shouldExcludeTextAreaNodes: false, + }); + + // These variables are used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockEmojiCalc = useRef(false); + const shouldBlockMentionCalc = useRef(false); + + /** + * Replace the code of mention and update selection + * @param {Number} highlightedMentionIndex + */ + const insertSelectedMention = useCallback( + (highlightedMentionIndexInner) => { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); + const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; + const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; + const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); + + updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); + setSelection({ + start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + })); + }, + [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, updateComment, setSelection], + ); + + /** + * Clean data related to EmojiSuggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + * + * @param {Object} e + */ + const triggerHotkeyActions = useCallback( + (e) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; + + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + return true; + } + } + + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); + } + + return true; + } + }, + [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedEmojis.length, suggestionValues.suggestedMentions.length], + ); + + const getMentionOptions = useCallback( + (personalDetailsParam, searchValue = '') => { + const suggestions = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: Expensicons.Megaphone, + type: 'avatar', + }, + ], + }); + } + + const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => { + // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned + if (!detail.login) { + return false; + } + if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { + return false; + } + return true; + }); + + const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); + _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { + suggestions.push({ + text: detail.displayName, + alternateText: detail.login, + icons: [ + { + name: detail.login, + source: UserUtils.getAvatar(detail.avatar, detail.accountID), + type: 'avatar', + }, + ], + }); + }); + + return suggestions; + }, + [translate], + ); + + const calculateMentionSuggestion = useCallback( + (selectionEnd) => { + if (shouldBlockMentionCalc.current) { + shouldBlockMentionCalc.current = false; + return; + } + + const valueAfterTheCursor = value.substring(selectionEnd); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + + let indexOfLastNonWhitespaceCharAfterTheCursor; + if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { + // we didn't find a whitespace/emoji after the cursor, so we will use the entire string + indexOfLastNonWhitespaceCharAfterTheCursor = value.length; + } else { + indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd; + } + + const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); + const lastWord = _.last(words); + + let atSignIndex; + if (lastWord.startsWith('@')) { + atSignIndex = leftString.lastIndexOf(lastWord); + } + + const prefix = lastWord.substring(1); + + const nextState = { + suggestedMentions: [], + atSignIndex, + mentionPrefix: prefix, + }; + + const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); + + if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { + const suggestions = getMentionOptions(personalDetails, prefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions); + } + + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + }, + [getMentionOptions, personalDetails, value], + ); + + const onSelectionChange = useCallback( + (e) => { + if (!value || e.nativeEvent.selection.end < 1) { + resetSuggestions(); + shouldBlockEmojiCalc.current = false; + shouldBlockMentionCalc.current = false; + return true; + } + + calculateMentionSuggestion(e.nativeEvent.selection.end); + }, + [calculateMentionSuggestion, resetSuggestions, value], + ); + + // eslint-disable-next-line rulesdir/prefer-early-return + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (suggestionValues.shouldShowEmojiSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); + } + if (suggestionValues.shouldShowSuggestionMenu) { + setSuggestionValues((prevState) => ({...prevState, shouldShowSuggestionMenu: false})); + } + }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowSuggestionMenu]); + + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc) => { + shouldBlockEmojiCalc.current = shouldBlockSuggestionCalc; + shouldBlockMentionCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockEmojiCalc, shouldBlockMentionCalc], + ); + + const onClose = useCallback(() => { + setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []})); + }, []); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + ); + + if (!isMentionSuggestionsMenuVisible) { + return null; + } + + return ( + setValue(newComment)} + colonIndex={suggestionValues.colonIndex} + prefix={suggestionValues.mentionPrefix} + onSelect={insertSelectedMention} + isComposerFullSize={isComposerFullSize} + isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + composerHeight={composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + ); +} + +SuggestionMention.propTypes = propTypes; + +const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( + +)); + +export default SuggestionMentionWithRef; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 54a222daf66..dede9939099 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -1,67 +1,8 @@ -import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; +import React, {useRef, useCallback, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; -import _ from 'underscore'; import CONST from '../../../../CONST'; -import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; -import EmojiSuggestions from '../../../../components/EmojiSuggestions'; -import MentionSuggestions from '../../../../components/MentionSuggestions'; -import * as EmojiUtils from '../../../../libs/EmojiUtils'; -import * as UserUtils from '../../../../libs/UserUtils'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; - -/** - * Return the max available index for arrow manager. - * @param {Number} numRows - * @param {Boolean} isAutoSuggestionPickerLarge - * @returns {Number} - */ -const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { - // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items - // and for large we show up to 20 items for mentions/emojis - const rowCount = isAutoSuggestionPickerLarge - ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) - : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); - - // -1 because we start at 0 - return rowCount - 1; -}; - -/** - * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} - */ -const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); - -/** - * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} - */ -const isEmojiCode = (str, pos) => { - const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const leftWord = _.last(leftWords); - return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; -}; - -/** - * Check if this piece of string looks like a mention - * @param {String} str - * @returns {Boolean} - */ -const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); - -const defaultSuggestionsValues = { - suggestedEmojis: [], - suggestedMentions: [], - colonIndex: -1, - atSignIndex: -1, - shouldShowEmojiSuggestionMenu: false, - shouldShowMentionSuggestionMenu: false, - mentionPrefix: '', - isAutoSuggestionPickerLarge: false, -}; +import SuggestionMention from './SuggestionMention'; +import SuggestionEmoji from './SuggestionEmoji'; const propTypes = { // Onyx/Hooks @@ -110,85 +51,15 @@ function Suggestions({ onInsertedEmoji, resetKeyboardInput, }) { - // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer - const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); - - const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; - const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowMentionSuggestionMenu; - - const [highlightedEmojiIndex] = useArrowKeyFocusManager({ - isActive: isEmojiSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), - shouldExcludeTextAreaNodes: false, - }); - const [highlightedMentionIndex] = useArrowKeyFocusManager({ - isActive: isMentionSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), - shouldExcludeTextAreaNodes: false, - }); - - // These variables are used to decide whether to block the suggestions list from showing to prevent flickering - const shouldBlockEmojiCalc = useRef(false); - const shouldBlockMentionCalc = useRef(false); - - /** - * Replace the code of emoji and update selection - * @param {Number} selectedEmoji - */ - const insertSelectedEmoji = useCallback( - (selectedEmoji) => { - const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const emojiObject = suggestionValues.suggestedEmojis[selectedEmoji]; - const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - - updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - - // In some Android phones keyboard, the text to search for the emoji is not cleared - // will be added after the user starts typing again on the keyboard. This package is - // a workaround to reset the keyboard natively. - resetKeyboardInput(); - - setSelection({ - start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - - onInsertedEmoji(emojiObject); - }, - [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], - ); - - /** - * Replace the code of mention and update selection - * @param {Number} highlightedMentionIndex - */ - const insertSelectedMention = useCallback( - (highlightedMentionIndexInner) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); - const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; - const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; - const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - - updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); - setSelection({ - start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({ - ...prevState, - suggestedMentions: [], - })); - }, - [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, updateComment, setSelection], - ); + const suggestionEmojiRef = useRef(null); + const suggestionMentionRef = useRef(null); /** * Clean data related to EmojiSuggestions */ const resetSuggestions = useCallback(() => { - setSuggestionValues(defaultSuggestionsValues); + suggestionEmojiRef.current.resetSuggestions(); + suggestionMentionRef.current.resetSuggestions(); }, []); /** @@ -196,195 +67,17 @@ function Suggestions({ * * @param {Object} e */ - const triggerHotkeyActions = useCallback( - (e) => { - const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; - - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); - if (suggestionValues.suggestedEmojis.length > 0) { - insertSelectedEmoji(highlightedEmojiIndex); - } - if (suggestionValues.suggestedMentions.length > 0) { - insertSelectedMention(highlightedMentionIndex); - } - return true; - } - - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); - - if (suggestionsExist) { - resetSuggestions(); - } - - return true; - } - }, - [ - highlightedEmojiIndex, - highlightedMentionIndex, - insertSelectedEmoji, - insertSelectedMention, - resetSuggestions, - suggestionValues.suggestedEmojis.length, - suggestionValues.suggestedMentions.length, - ], - ); - - /** - * Calculates and cares about the content of an Emoji Suggester - */ - const calculateEmojiSuggestion = useCallback( - (selectionEnd) => { - if (shouldBlockEmojiCalc.current) { - shouldBlockEmojiCalc.current = false; - return; - } - const leftString = value.substring(0, selectionEnd); - const colonIndex = leftString.lastIndexOf(':'); - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); - - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; - const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - - const nextState = { - suggestedEmojis: [], - colonIndex, - shouldShowEmojiSuggestionMenu: false, - isAutoSuggestionPickerLarge, - }; - const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); - - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); - } - - setSuggestionValues((prevState) => ({...prevState, ...nextState})); - }, - [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale], - ); - - const getMentionOptions = useCallback( - (personalDetailsParam, searchValue = '') => { - const suggestions = []; - - if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { - suggestions.push({ - text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, - alternateText: translate('mentionSuggestions.hereAlternateText'), - icons: [ - { - source: Expensicons.Megaphone, - type: 'avatar', - }, - ], - }); - } - - const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => { - // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail.login) { - return false; - } - if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { - return false; - } - return true; - }); - - const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); - _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { - suggestions.push({ - text: detail.displayName, - alternateText: detail.login, - icons: [ - { - name: detail.login, - source: UserUtils.getAvatar(detail.avatar, detail.accountID), - type: 'avatar', - }, - ], - }); - }); - - return suggestions; - }, - [translate], - ); - - const calculateMentionSuggestion = useCallback( - (selectionEnd) => { - if (shouldBlockMentionCalc.current) { - shouldBlockMentionCalc.current = false; - return; - } - - const valueAfterTheCursor = value.substring(selectionEnd); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); - - let indexOfLastNonWhitespaceCharAfterTheCursor; - if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { - // we didn't find a whitespace/emoji after the cursor, so we will use the entire string - indexOfLastNonWhitespaceCharAfterTheCursor = value.length; - } else { - indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd; - } - - const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const lastWord = _.last(words); - - let atSignIndex; - if (lastWord.startsWith('@')) { - atSignIndex = leftString.lastIndexOf(lastWord); - } - - const prefix = lastWord.substring(1); - - const nextState = { - suggestedMentions: [], - atSignIndex, - mentionPrefix: prefix, - }; - - const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); - - if (!isCursorBeforeTheMention && isMentionCode(lastWord)) { - const suggestions = getMentionOptions(personalDetails, prefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); - } - - setSuggestionValues((prevState) => ({ - ...prevState, - ...nextState, - })); - }, - [getMentionOptions, personalDetails, value], - ); - - const onSelectionChange = useCallback( - (e) => { - if (!value || e.nativeEvent.selection.end < 1) { - resetSuggestions(); - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; - return true; - } + const triggerHotkeyActions = useCallback((e) => { + const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e); + const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e); + return emojiHandler || mentionHandler; + }, []); - /** - * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion - * because in other case calculateEmojiSuggestion will have an old calculation value - * of suggestion instead of current one - */ - calculateEmojiSuggestion(e.nativeEvent.selection.end); - calculateMentionSuggestion(e.nativeEvent.selection.end); - }, - [calculateEmojiSuggestion, calculateMentionSuggestion, resetSuggestions, value], - ); + const onSelectionChange = useCallback((e) => { + const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e); + const mentionHandler = suggestionMentionRef.current.onSelectionChange(e); + return emojiHandler || mentionHandler; + }, []); // eslint-disable-next-line rulesdir/prefer-early-return const updateShouldShowSuggestionMenuToFalse = useCallback(() => { @@ -418,39 +111,50 @@ function Suggestions({ return ( <> - {isEmojiSuggestionsMenuVisible && ( - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))} - highlightedEmojiIndex={highlightedEmojiIndex} - emojis={suggestionValues.suggestedEmojis} - comment={value} - updateComment={(newComment) => setValue(newComment)} - colonIndex={suggestionValues.colonIndex} - prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)} - onSelect={insertSelectedEmoji} - isComposerFullSize={isComposerFullSize} - preferredSkinToneIndex={preferredSkinTone} - isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - )} - {isMentionSuggestionsMenuVisible && ( - setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))} - highlightedMentionIndex={highlightedMentionIndex} - mentions={suggestionValues.suggestedMentions} - comment={value} - updateComment={(newComment) => setValue(newComment)} - colonIndex={suggestionValues.colonIndex} - prefix={suggestionValues.mentionPrefix} - onSelect={insertSelectedMention} - isComposerFullSize={isComposerFullSize} - isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} - composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} - /> - )} + + ); } From e247161fe45fe55d8de1d05933ff923a8962c683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 8 Aug 2023 16:39:32 +0200 Subject: [PATCH 13/85] fix split implementations of suggestions --- src/libs/SuggestionUtils.js | 29 ++++ .../ReportActionCompose.js | 4 +- .../ReportActionCompose/SuggestionEmoji.js | 127 +++++++++++++++--- .../ReportActionCompose/SuggestionMention.js | 68 +++------- .../report/ReportActionCompose/Suggestions.js | 23 +--- 5 files changed, 161 insertions(+), 90 deletions(-) create mode 100644 src/libs/SuggestionUtils.js diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.js new file mode 100644 index 00000000000..aa2640d006c --- /dev/null +++ b/src/libs/SuggestionUtils.js @@ -0,0 +1,29 @@ +import CONST from '../CONST'; + +/** + * Return the max available index for arrow manager. + * @param {Number} numRows + * @param {Boolean} isAutoSuggestionPickerLarge + * @returns {Number} + */ +function getMaxArrowIndex(numRows, isAutoSuggestionPickerLarge) { + // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items + // and for large we show up to 20 items for mentions/emojis + const rowCount = isAutoSuggestionPickerLarge + ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) + : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); + + // -1 because we start at 0 + return rowCount - 1; +} + +/** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ +function trimLeadingSpace(str) { + return str.slice(0, 1) === ' ' ? str.slice(1) : str; +} + +export {getMaxArrowIndex, trimLeadingSpace}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 605f5541f68..6247337f68d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -645,8 +645,8 @@ function ReportActionCompose({ if (!RNTextInputReset) { return; } - RNTextInputReset.resetKeyboardInput(findNodeHandle(textInput)); - }, [textInput]); + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); + }, [textInputRef]); useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index ac06eeae10d..39fc18e2705 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; -import MentionSuggestions from '../../../../components/MentionSuggestions'; -import * as UserUtils from '../../../../libs/UserUtils'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; +import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import EmojiSuggestions from '../../../../components/EmojiSuggestions'; /** * Check if this piece of string looks like an emoji @@ -19,15 +19,73 @@ const isEmojiCode = (str, pos) => { return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; }; -function SuggestionEmoji() { +const defaultSuggestionsValues = { + suggestedEmojis: [], + colonSignIndex: -1, + shouldShowSuggestionMenu: false, + mentionPrefix: '', + isAutoSuggestionPickerLarge: false, +}; + +const propTypes = { + // Onyx/Hooks + preferredSkinTone: PropTypes.number.isRequired, + windowHeight: PropTypes.number.isRequired, + isSmallScreenWidth: PropTypes.bool.isRequired, + preferredLocale: PropTypes.string.isRequired, + personalDetails: PropTypes.object.isRequired, + translate: PropTypes.func.isRequired, + // Input + value: PropTypes.string.isRequired, + setValue: PropTypes.func.isRequired, + selection: PropTypes.shape({ + start: PropTypes.number.isRequired, + end: PropTypes.number.isRequired, + }).isRequired, + setSelection: PropTypes.func.isRequired, + // Esoteric props + isComposerFullSize: PropTypes.bool.isRequired, + updateComment: PropTypes.func.isRequired, + composerHeight: PropTypes.number.isRequired, + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + // Custom added + forwardedRef: PropTypes.object.isRequired, + resetKeyboardInput: PropTypes.func.isRequired, + onInsertedEmoji: PropTypes.func.isRequired, +}; + +function SuggestionEmoji({ + isComposerFullSize, + windowHeight, + preferredLocale, + isSmallScreenWidth, + preferredSkinTone, + personalDetails, + translate, + value, + setValue, + selection, + setSelection, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, + resetKeyboardInput, + onInsertedEmoji, +}) { + const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); + const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; const [highlightedEmojiIndex] = useArrowKeyFocusManager({ isActive: isEmojiSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, }); + // Used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockCalc = useRef(false); + /** * Replace the code of emoji and update selection * @param {Number} selectedEmoji @@ -39,7 +97,7 @@ function SuggestionEmoji() { const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - updateComment(`${commentBeforeColon}${emojiCode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is @@ -57,6 +115,22 @@ function SuggestionEmoji() { [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], ); + /** + * Clean data related to suggestions + */ + const resetSuggestions = useCallback(() => { + setSuggestionValues(defaultSuggestionsValues); + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + setSuggestionValues((prevState) => { + if (prevState.shouldShowSuggestionMenu) { + return {...prevState, shouldShowSuggestionMenu: false}; + } + return prevState; + }); + }, []); + /** * Listens for keyboard shortcuts and applies the action * @@ -84,15 +158,7 @@ function SuggestionEmoji() { return true; } }, - [ - highlightedEmojiIndex, - highlightedMentionIndex, - insertSelectedEmoji, - insertSelectedMention, - resetSuggestions, - suggestionValues.suggestedEmojis.length, - suggestionValues.suggestedMentions.length, - ], + [highlightedEmojiIndex, insertSelectedEmoji, resetSuggestions, suggestionValues.suggestedEmojis.length], ); /** @@ -100,8 +166,8 @@ function SuggestionEmoji() { */ const calculateEmojiSuggestion = useCallback( (selectionEnd) => { - if (shouldBlockEmojiCalc.current) { - shouldBlockEmojiCalc.current = false; + if (shouldBlockCalc.current) { + shouldBlockCalc.current = false; return; } const leftString = value.substring(0, selectionEnd); @@ -134,8 +200,7 @@ function SuggestionEmoji() { (e) => { if (!value || e.nativeEvent.selection.end < 1) { resetSuggestions(); - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; + shouldBlockCalc.current = false; return true; } @@ -145,9 +210,27 @@ function SuggestionEmoji() { * of suggestion instead of current one */ calculateEmojiSuggestion(e.nativeEvent.selection.end); - calculateMentionSuggestion(e.nativeEvent.selection.end); }, - [calculateEmojiSuggestion, calculateMentionSuggestion, resetSuggestions, value], + [calculateEmojiSuggestion, resetSuggestions, value], + ); + + const setShouldBlockSuggestionCalc = useCallback( + (shouldBlockSuggestionCalc) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }, + [shouldBlockCalc], + ); + + useImperativeHandle( + forwardedRef, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); if (!isEmojiSuggestionsMenuVisible) { @@ -173,6 +256,8 @@ function SuggestionEmoji() { ); } +SuggestionEmoji.propTypes = propTypes; + const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( { - // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items - // and for large we show up to 20 items for mentions/emojis - const rowCount = isAutoSuggestionPickerLarge - ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) - : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); - - // -1 because we start at 0 - return rowCount - 1; -}; - -/** - * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} - */ -const trimLeadingSpace = (str) => (str.slice(0, 1) === ' ' ? str.slice(1) : str); +import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; /** * Check if this piece of string looks like a mention @@ -100,13 +76,12 @@ function SuggestionMention({ const [highlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, - maxIndex: getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, }); - // These variables are used to decide whether to block the suggestions list from showing to prevent flickering - const shouldBlockEmojiCalc = useRef(false); - const shouldBlockMentionCalc = useRef(false); + // Used to decide whether to block the suggestions list from showing to prevent flickering + const shouldBlockCalc = useRef(false); /** * Replace the code of mention and update selection @@ -119,7 +94,7 @@ function SuggestionMention({ const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; const commentAfterAtSignWithMentionRemoved = value.slice(suggestionValues.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - updateComment(`${commentBeforeAtSign}${mentionCode} ${trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); + updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); setSelection({ start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, @@ -133,7 +108,7 @@ function SuggestionMention({ ); /** - * Clean data related to EmojiSuggestions + * Clean data related to suggestions */ const resetSuggestions = useCallback(() => { setSuggestionValues(defaultSuggestionsValues); @@ -146,7 +121,7 @@ function SuggestionMention({ */ const triggerHotkeyActions = useCallback( (e) => { - const suggestionsExist = suggestionValues.suggestedEmojis.length > 0 || suggestionValues.suggestedMentions.length > 0; + const suggestionsExist = suggestionValues.suggestedMentions.length > 0; if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { e.preventDefault(); @@ -166,7 +141,7 @@ function SuggestionMention({ return true; } }, - [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedEmojis.length, suggestionValues.suggestedMentions.length], + [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length], ); const getMentionOptions = useCallback( @@ -219,8 +194,8 @@ function SuggestionMention({ const calculateMentionSuggestion = useCallback( (selectionEnd) => { - if (shouldBlockMentionCalc.current) { - shouldBlockMentionCalc.current = false; + if (shouldBlockCalc.current) { + shouldBlockCalc.current = false; return; } @@ -272,8 +247,7 @@ function SuggestionMention({ (e) => { if (!value || e.nativeEvent.selection.end < 1) { resetSuggestions(); - shouldBlockEmojiCalc.current = false; - shouldBlockMentionCalc.current = false; + shouldBlockCalc.current = false; return true; } @@ -282,22 +256,20 @@ function SuggestionMention({ [calculateMentionSuggestion, resetSuggestions, value], ); - // eslint-disable-next-line rulesdir/prefer-early-return const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (suggestionValues.shouldShowEmojiSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); - } - if (suggestionValues.shouldShowSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowSuggestionMenu: false})); - } - }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowSuggestionMenu]); + setSuggestionValues((prevState) => { + if (prevState.shouldShowSuggestionMenu) { + return {...prevState, shouldShowSuggestionMenu: false}; + } + return prevState; + }); + }, []); const setShouldBlockSuggestionCalc = useCallback( (shouldBlockSuggestionCalc) => { - shouldBlockEmojiCalc.current = shouldBlockSuggestionCalc; - shouldBlockMentionCalc.current = shouldBlockSuggestionCalc; + shouldBlockCalc.current = shouldBlockSuggestionCalc; }, - [shouldBlockEmojiCalc, shouldBlockMentionCalc], + [shouldBlockCalc], ); const onClose = useCallback(() => { diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index dede9939099..4e325302ec7 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -1,6 +1,5 @@ import React, {useRef, useCallback, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; -import CONST from '../../../../CONST'; import SuggestionMention from './SuggestionMention'; import SuggestionEmoji from './SuggestionEmoji'; @@ -79,23 +78,10 @@ function Suggestions({ return emojiHandler || mentionHandler; }, []); - // eslint-disable-next-line rulesdir/prefer-early-return const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (suggestionValues.shouldShowEmojiSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowEmojiSuggestionMenu: false})); - } - if (suggestionValues.shouldShowMentionSuggestionMenu) { - setSuggestionValues((prevState) => ({...prevState, shouldShowMentionSuggestionMenu: false})); - } - }, [suggestionValues.shouldShowEmojiSuggestionMenu, suggestionValues.shouldShowMentionSuggestionMenu]); - - const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc) => { - shouldBlockEmojiCalc.current = shouldBlockSuggestionCalc; - shouldBlockMentionCalc.current = shouldBlockSuggestionCalc; - }, - [shouldBlockEmojiCalc, shouldBlockMentionCalc], - ); + suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse(); + suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); + }, []); useImperativeHandle( forwardedRef, @@ -103,10 +89,9 @@ function Suggestions({ resetSuggestions, onSelectionChange, triggerHotkeyActions, - setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); return ( From 9739c086ee3d3f0372d982dab20b302fe3ebfa43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 8 Aug 2023 19:14:52 +0200 Subject: [PATCH 14/85] wip: split out SendButton & AttachmentPickerWithMenu --- .../AttachmentPickerWithMenu.js | 207 +++++++++++++++ .../ReportActionCompose.js | 247 ++---------------- .../report/ReportActionCompose/SendButton.js | 55 ++++ .../report/ReportActionCompose/Suggestions.js | 8 +- 4 files changed, 298 insertions(+), 219 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js create mode 100644 src/pages/home/report/ReportActionCompose/SendButton.js diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js new file mode 100644 index 00000000000..fa3c1d3aa13 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js @@ -0,0 +1,207 @@ +import React, {useRef, useMemo} from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../../../../styles/styles'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../../components/AttachmentPicker'; +import * as Report from '../../../../libs/actions/Report'; +import PopoverMenu from '../../../../components/PopoverMenu'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import CONST from '../../../../CONST'; +import Tooltip from '../../../../components/Tooltip'; +import * as Browser from '../../../../libs/Browser'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import useLocalize from '../../../../hooks/useLocalize'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import * as IOU from '../../../../libs/actions/IOU'; +import * as Task from '../../../../libs/actions/Task'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import Permissions from '../../../../libs/Permissions'; + +function AttachmentPickerWithMenu({ + // Onyx + betas, + // Other props + report, + reportParticipants, + suggestionsRef, + displayFileInModal, + isFullSizeComposerAvailable, + isComposerFullSize, + updateShouldShowSuggestionMenuToFalse, + reportID, + disabled, + setMenuVisibility, + isMenuVisible, +}) { + const actionButtonRef = useRef(null); + const {translate} = useLocalize(); + const {windowHeight} = useWindowDimensions; + + /** + * Returns the list of IOU Options + * @returns {Array} + */ + const moneyRequestOptions = useMemo(() => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: translate('iou.splitBill'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: translate('iou.requestMoney'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: translate('iou.sendMoney'), + }, + }; + + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ + ...options[option], + onSelected: () => IOU.startMoneyRequest(option, reportID), + })); + }, [betas, report, reportID, reportParticipants, translate]); + + /** + * Determines if we can show the task option + * @returns {Boolean} + */ + const taskOption = useMemo(() => { + // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email + if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + return []; + } + + return [ + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), + }, + ]; + }, [betas, report, reportID, translate]); + + return ( + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + suggestionsRef.current.setShouldBlockSuggestionCalc(true); + } + openPicker({ + onPicked: displayFileInModal, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); + }, + }, + ]; + return ( + <> + + {isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + > + + + + )} + {!isComposerFullSize && isFullSizeComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButtonRef.current.blur(); + setMenuVisibility(!isMenuVisible); + }} + style={styles.composerSizeButton} + disabled={disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.addAction')} + > + + + + + setMenuVisibility(false)} + onItemSelected={(item, index) => { + setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + triggerAttachmentPicker(); + } + }} + anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + menuItems={menuItems} + withoutOverlay + anchorRef={actionButtonRef} + /> + + ); + }} + + ); +} + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, +})(AttachmentPickerWithMenu); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6247337f68d..98602f9cdb7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,8 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import {View, InteractionManager, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native'; -import {runOnJS} from 'react-native-reanimated'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -10,14 +8,10 @@ import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import ONYXKEYS from '../../../../ONYXKEYS'; -import Icon from '../../../../components/Icon'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; -import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; import compose from '../../../../libs/compose'; -import PopoverMenu from '../../../../components/PopoverMenu'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; @@ -31,7 +25,6 @@ import ParticipantLocalTime from '../ParticipantLocalTime'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; import {withNetwork} from '../../../../components/OnyxProvider'; import * as User from '../../../../libs/actions/User'; -import Tooltip from '../../../../components/Tooltip'; import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import OfflineIndicator from '../../../../components/OfflineIndicator'; @@ -45,18 +38,15 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../../libs/ComposerUtils'; import * as Welcome from '../../../../libs/actions/Welcome'; -import Permissions from '../../../../libs/Permissions'; import containerComposeStyles from '../../../../styles/containerComposeStyles'; -import * as Task from '../../../../libs/actions/Task'; import * as Browser from '../../../../libs/Browser'; -import * as IOU from '../../../../libs/actions/IOU'; -import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; -import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; import Suggestions from './Suggestions'; +import SendButton from './SendButton'; +import AttachmentPickerWithMenu from './AttachmentPickerWithMenu'; const {RNTextInputReset} = NativeModules; @@ -252,7 +242,6 @@ function ReportActionCompose({ const commentRef = useRef(comment); const textInputRef = useRef(null); - const actionButtonRef = useRef(null); const suggestionsRef = useRef(null); @@ -311,6 +300,10 @@ function ReportActionCompose({ }); }, []); + const focusWithDelay = useCallback(() => { + focus(true); + }, [focus]); + /** * Update the value of the comment in Onyx * @@ -467,51 +460,6 @@ function ReportActionCompose({ [animatedRef], ); - /** - * Returns the list of IOU Options - * @returns {Array} - */ - const moneyRequestOptions = useMemo(() => { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: translate('iou.splitBill'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: translate('iou.requestMoney'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: translate('iou.sendMoney'), - }, - }; - - return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ - ...options[option], - onSelected: () => IOU.startMoneyRequest(option, report.reportID), - })); - }, [betas, report, reportParticipants, translate]); - - /** - * Determines if we can show the task option - * @returns {Boolean} - */ - const taskOption = useMemo(() => { - // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email - if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - return []; - } - - return [ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), - }, - ]; - }, [betas, report, reportID, translate]); - /** * Update the number of lines for a comment in Onyx * @param {Number} numberOfLines @@ -719,19 +667,7 @@ function ReportActionCompose({ const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const Tap = Gesture.Tap() - .enabled(!(isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength)) - .onEnd(() => { - 'worklet'; - - const viewTag = animatedRef(); - const viewName = 'RCTMultilineTextInputView'; - const updates = {text: ''}; - // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setIsCommentEmpty)(true); - updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread - runOnJS(submitForm)(); - }); + const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; return ( @@ -758,123 +694,19 @@ function ReportActionCompose({ > {({displayFileInModal}) => ( <> - - {({openPicker}) => { - const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; - } - openPicker({ - onPicked: displayFileInModal, - }); - }; - const menuItems = [ - ...moneyRequestOptions, - ...taskOption, - { - icon: Expensicons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, - }, - ]; - return ( - <> - - {isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - > - - - - )} - {!isComposerFullSize && isFullSizeComposerAvailable && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - > - - - - )} - - { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - actionButtonRef.current.blur(); - setMenuVisibility(!isMenuVisible); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.addAction')} - > - - - - - setMenuVisibility(false)} - onItemSelected={(item, index) => { - setMenuVisibility(false); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - triggerAttachmentPicker(); - } - }} - anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} - menuItems={menuItems} - withoutOverlay - anchorRef={actionButtonRef} - /> - - ); - }} - + focus(true)} - onEmojiSelected={replaceSelectionWithText} + onModalHide={focusWithDelay} + onEmojiSelected={() => {}} /> )} - e.preventDefault()} - > - - - [ - styles.chatItemSubmitButton, - isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess, - ]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('common.send')} - > - {({pressed}) => ( - - )} - - - - + `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, }, diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js new file mode 100644 index 00000000000..aa8818ba0b7 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -0,0 +1,55 @@ +import React from 'react'; +import {View} from 'react-native'; +import {runOnJS} from 'react-native-reanimated'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import CONST from '../../../../CONST'; +import Tooltip from '../../../../components/Tooltip'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; + +function SendButton({isDisabled: isDisabledProp, animatedRef, translate, setIsCommentEmpty, submitForm}) { + const Tap = Gesture.Tap() + .enabled() + .onEnd(() => { + 'worklet'; + + const viewTag = animatedRef(); + const viewName = 'RCTMultilineTextInputView'; + const updates = {text: ''}; + // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state + runOnJS(setIsCommentEmpty)(true); + updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread + runOnJS(submitForm)(); + }); + + return ( + e.preventDefault()} + > + + + [styles.chatItemSubmitButton, isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + + + + ); +} + +export default SendButton; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 4e325302ec7..0fc4cff4272 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -83,15 +83,21 @@ function Suggestions({ suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); }, []); + const setShouldBlockSuggestionCalc = useCallback((blockCalculations) => { + suggestionEmojiRef.current.setShouldBlockSuggestionCalc(blockCalculations); + suggestionMentionRef.current.setShouldBlockSuggestionCalc(blockCalculations); + }, []); + useImperativeHandle( forwardedRef, () => ({ resetSuggestions, onSelectionChange, triggerHotkeyActions, + setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, }), - [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); return ( From eb9baf83bb51a8543e4fc768e7fb59c706b97e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 8 Aug 2023 19:24:18 +0200 Subject: [PATCH 15/85] wip: simplify code --- .../ReportActionCompose/AttachmentPickerWithMenu.js | 9 +++------ .../report/ReportActionCompose/ReportActionCompose.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js index fa3c1d3aa13..4c97cf7a7b2 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js @@ -8,7 +8,6 @@ import * as Expensicons from '../../../../components/Icon/Expensicons'; import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import PopoverMenu from '../../../../components/PopoverMenu'; -import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; import CONST from '../../../../CONST'; import Tooltip from '../../../../components/Tooltip'; import * as Browser from '../../../../libs/Browser'; @@ -36,6 +35,8 @@ function AttachmentPickerWithMenu({ disabled, setMenuVisibility, isMenuVisible, + // Added + onTriggerAttachmentPicker, }) { const actionButtonRef = useRef(null); const {translate} = useLocalize(); @@ -90,11 +91,7 @@ function AttachmentPickerWithMenu({ {({openPicker}) => { const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - suggestionsRef.current.setShouldBlockSuggestionCalc(true); - } + onTriggerAttachmentPicker(); openPicker({ onPicked: displayFileInModal, }); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 98602f9cdb7..0586532ff39 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -596,6 +596,15 @@ function ReportActionCompose({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); }, [textInputRef]); + const onTriggerAttachmentPicker = useCallback(() => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (!willBlurTextInputOnTapOutsideFunc) { + return; + } + suggestionsRef.current.setShouldBlockSuggestionCalc(true); + }, [suggestionsRef]); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -706,6 +715,7 @@ function ReportActionCompose({ disabled={isBlockedFromConcierge || disabled} setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} + onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> Date: Wed, 9 Aug 2023 08:17:18 +0200 Subject: [PATCH 16/85] Revert "wip: simplify code" This reverts commit eb9baf83bb51a8543e4fc768e7fb59c706b97e15. --- .../ReportActionCompose/AttachmentPickerWithMenu.js | 9 ++++++--- .../report/ReportActionCompose/ReportActionCompose.js | 10 ---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js index 4c97cf7a7b2..fa3c1d3aa13 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js @@ -8,6 +8,7 @@ import * as Expensicons from '../../../../components/Icon/Expensicons'; import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import PopoverMenu from '../../../../components/PopoverMenu'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; import CONST from '../../../../CONST'; import Tooltip from '../../../../components/Tooltip'; import * as Browser from '../../../../libs/Browser'; @@ -35,8 +36,6 @@ function AttachmentPickerWithMenu({ disabled, setMenuVisibility, isMenuVisible, - // Added - onTriggerAttachmentPicker, }) { const actionButtonRef = useRef(null); const {translate} = useLocalize(); @@ -91,7 +90,11 @@ function AttachmentPickerWithMenu({ {({openPicker}) => { const triggerAttachmentPicker = () => { - onTriggerAttachmentPicker(); + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + suggestionsRef.current.setShouldBlockSuggestionCalc(true); + } openPicker({ onPicked: displayFileInModal, }); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 0586532ff39..98602f9cdb7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -596,15 +596,6 @@ function ReportActionCompose({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); }, [textInputRef]); - const onTriggerAttachmentPicker = useCallback(() => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (!willBlurTextInputOnTapOutsideFunc) { - return; - } - suggestionsRef.current.setShouldBlockSuggestionCalc(true); - }, [suggestionsRef]); - useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -715,7 +706,6 @@ function ReportActionCompose({ disabled={isBlockedFromConcierge || disabled} setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} - onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> Date: Wed, 9 Aug 2023 08:17:30 +0200 Subject: [PATCH 17/85] Revert "wip: split out SendButton & AttachmentPickerWithMenu" This reverts commit 9739c086ee3d3f0372d982dab20b302fe3ebfa43. --- .../AttachmentPickerWithMenu.js | 207 --------------- .../ReportActionCompose.js | 247 ++++++++++++++++-- .../report/ReportActionCompose/SendButton.js | 55 ---- .../report/ReportActionCompose/Suggestions.js | 8 +- 4 files changed, 219 insertions(+), 298 deletions(-) delete mode 100644 src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js delete mode 100644 src/pages/home/report/ReportActionCompose/SendButton.js diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js deleted file mode 100644 index fa3c1d3aa13..00000000000 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenu.js +++ /dev/null @@ -1,207 +0,0 @@ -import React, {useRef, useMemo} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import {withOnyx} from 'react-native-onyx'; -import styles from '../../../../styles/styles'; -import Icon from '../../../../components/Icon'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; -import AttachmentPicker from '../../../../components/AttachmentPicker'; -import * as Report from '../../../../libs/actions/Report'; -import PopoverMenu from '../../../../components/PopoverMenu'; -import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; -import CONST from '../../../../CONST'; -import Tooltip from '../../../../components/Tooltip'; -import * as Browser from '../../../../libs/Browser'; -import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; -import useLocalize from '../../../../hooks/useLocalize'; -import useWindowDimensions from '../../../../hooks/useWindowDimensions'; -import * as ReportUtils from '../../../../libs/ReportUtils'; -import * as IOU from '../../../../libs/actions/IOU'; -import * as Task from '../../../../libs/actions/Task'; -import ONYXKEYS from '../../../../ONYXKEYS'; -import Permissions from '../../../../libs/Permissions'; - -function AttachmentPickerWithMenu({ - // Onyx - betas, - // Other props - report, - reportParticipants, - suggestionsRef, - displayFileInModal, - isFullSizeComposerAvailable, - isComposerFullSize, - updateShouldShowSuggestionMenuToFalse, - reportID, - disabled, - setMenuVisibility, - isMenuVisible, -}) { - const actionButtonRef = useRef(null); - const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions; - - /** - * Returns the list of IOU Options - * @returns {Array} - */ - const moneyRequestOptions = useMemo(() => { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: translate('iou.splitBill'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: translate('iou.requestMoney'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: translate('iou.sendMoney'), - }, - }; - - return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ - ...options[option], - onSelected: () => IOU.startMoneyRequest(option, reportID), - })); - }, [betas, report, reportID, reportParticipants, translate]); - - /** - * Determines if we can show the task option - * @returns {Boolean} - */ - const taskOption = useMemo(() => { - // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email - if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - return []; - } - - return [ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), - }, - ]; - }, [betas, report, reportID, translate]); - - return ( - - {({openPicker}) => { - const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - suggestionsRef.current.setShouldBlockSuggestionCalc(true); - } - openPicker({ - onPicked: displayFileInModal, - }); - }; - const menuItems = [ - ...moneyRequestOptions, - ...taskOption, - { - icon: Expensicons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, - }, - ]; - return ( - <> - - {isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - > - - - - )} - {!isComposerFullSize && isFullSizeComposerAvailable && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - > - - - - )} - - { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - actionButtonRef.current.blur(); - setMenuVisibility(!isMenuVisible); - }} - style={styles.composerSizeButton} - disabled={disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.addAction')} - > - - - - - setMenuVisibility(false)} - onItemSelected={(item, index) => { - setMenuVisibility(false); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - triggerAttachmentPicker(); - } - }} - anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} - menuItems={menuItems} - withoutOverlay - anchorRef={actionButtonRef} - /> - - ); - }} - - ); -} - -export default withOnyx({ - betas: { - key: ONYXKEYS.BETAS, - }, -})(AttachmentPickerWithMenu); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 98602f9cdb7..6247337f68d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,6 +1,8 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import {View, InteractionManager, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native'; +import {runOnJS} from 'react-native-reanimated'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -8,10 +10,14 @@ import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import ONYXKEYS from '../../../../ONYXKEYS'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; import compose from '../../../../libs/compose'; +import PopoverMenu from '../../../../components/PopoverMenu'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; @@ -25,6 +31,7 @@ import ParticipantLocalTime from '../ParticipantLocalTime'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; import {withNetwork} from '../../../../components/OnyxProvider'; import * as User from '../../../../libs/actions/User'; +import Tooltip from '../../../../components/Tooltip'; import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import OfflineIndicator from '../../../../components/OfflineIndicator'; @@ -38,15 +45,18 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../../libs/ComposerUtils'; import * as Welcome from '../../../../libs/actions/Welcome'; +import Permissions from '../../../../libs/Permissions'; import containerComposeStyles from '../../../../styles/containerComposeStyles'; +import * as Task from '../../../../libs/actions/Task'; import * as Browser from '../../../../libs/Browser'; +import * as IOU from '../../../../libs/actions/IOU'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; +import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; import Suggestions from './Suggestions'; -import SendButton from './SendButton'; -import AttachmentPickerWithMenu from './AttachmentPickerWithMenu'; const {RNTextInputReset} = NativeModules; @@ -242,6 +252,7 @@ function ReportActionCompose({ const commentRef = useRef(comment); const textInputRef = useRef(null); + const actionButtonRef = useRef(null); const suggestionsRef = useRef(null); @@ -300,10 +311,6 @@ function ReportActionCompose({ }); }, []); - const focusWithDelay = useCallback(() => { - focus(true); - }, [focus]); - /** * Update the value of the comment in Onyx * @@ -460,6 +467,51 @@ function ReportActionCompose({ [animatedRef], ); + /** + * Returns the list of IOU Options + * @returns {Array} + */ + const moneyRequestOptions = useMemo(() => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: translate('iou.splitBill'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: translate('iou.requestMoney'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: translate('iou.sendMoney'), + }, + }; + + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ + ...options[option], + onSelected: () => IOU.startMoneyRequest(option, report.reportID), + })); + }, [betas, report, reportParticipants, translate]); + + /** + * Determines if we can show the task option + * @returns {Boolean} + */ + const taskOption = useMemo(() => { + // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email + if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + return []; + } + + return [ + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), + }, + ]; + }, [betas, report, reportID, translate]); + /** * Update the number of lines for a comment in Onyx * @param {Number} numberOfLines @@ -667,7 +719,19 @@ function ReportActionCompose({ const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; + const Tap = Gesture.Tap() + .enabled(!(isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength)) + .onEnd(() => { + 'worklet'; + + const viewTag = animatedRef(); + const viewName = 'RCTMultilineTextInputView'; + const updates = {text: ''}; + // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state + runOnJS(setIsCommentEmpty)(true); + updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread + runOnJS(submitForm)(); + }); return ( @@ -694,19 +758,123 @@ function ReportActionCompose({ > {({displayFileInModal}) => ( <> - + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + shouldBlockEmojiCalc.current = true; + shouldBlockMentionCalc.current = true; + } + openPicker({ + onPicked: displayFileInModal, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); + }, + }, + ]; + return ( + <> + + {isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + > + + + + )} + {!isComposerFullSize && isFullSizeComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButtonRef.current.blur(); + setMenuVisibility(!isMenuVisible); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.addAction')} + > + + + + + setMenuVisibility(false)} + onItemSelected={(item, index) => { + setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + triggerAttachmentPicker(); + } + }} + anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + menuItems={menuItems} + withoutOverlay + anchorRef={actionButtonRef} + /> + + ); + }} + {}} + onModalHide={() => focus(true)} + onEmojiSelected={replaceSelectionWithText} /> )} - + e.preventDefault()} + > + + + [ + styles.chatItemSubmitButton, + isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess, + ]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + + + `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, }, diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js deleted file mode 100644 index aa8818ba0b7..00000000000 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import {runOnJS} from 'react-native-reanimated'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import styles from '../../../../styles/styles'; -import themeColors from '../../../../styles/themes/default'; -import Icon from '../../../../components/Icon'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; -import CONST from '../../../../CONST'; -import Tooltip from '../../../../components/Tooltip'; -import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; -import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; - -function SendButton({isDisabled: isDisabledProp, animatedRef, translate, setIsCommentEmpty, submitForm}) { - const Tap = Gesture.Tap() - .enabled() - .onEnd(() => { - 'worklet'; - - const viewTag = animatedRef(); - const viewName = 'RCTMultilineTextInputView'; - const updates = {text: ''}; - // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setIsCommentEmpty)(true); - updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread - runOnJS(submitForm)(); - }); - - return ( - e.preventDefault()} - > - - - [styles.chatItemSubmitButton, isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('common.send')} - > - {({pressed}) => ( - - )} - - - - - ); -} - -export default SendButton; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 0fc4cff4272..4e325302ec7 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -83,21 +83,15 @@ function Suggestions({ suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); }, []); - const setShouldBlockSuggestionCalc = useCallback((blockCalculations) => { - suggestionEmojiRef.current.setShouldBlockSuggestionCalc(blockCalculations); - suggestionMentionRef.current.setShouldBlockSuggestionCalc(blockCalculations); - }, []); - useImperativeHandle( forwardedRef, () => ({ resetSuggestions, onSelectionChange, triggerHotkeyActions, - setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); return ( From b846cbe43dd4eeb8545f00b3f6ccd4b47ee11e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 9 Aug 2023 08:43:50 +0200 Subject: [PATCH 18/85] Split out SendButton component --- .../ReportActionCompose.js | 49 ++---------- .../report/ReportActionCompose/SendButton.js | 76 +++++++++++++++++++ 2 files changed, 84 insertions(+), 41 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/SendButton.js diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6247337f68d..665fcfe0811 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,8 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import {View, InteractionManager, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native'; -import {runOnJS} from 'react-native-reanimated'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -55,8 +53,8 @@ import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; -import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; import Suggestions from './Suggestions'; +import SendButton from './SendButton'; const {RNTextInputReset} = NativeModules; @@ -719,19 +717,7 @@ function ReportActionCompose({ const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const Tap = Gesture.Tap() - .enabled(!(isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength)) - .onEnd(() => { - 'worklet'; - - const viewTag = animatedRef(); - const viewName = 'RCTMultilineTextInputView'; - const updates = {text: ''}; - // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setIsCommentEmpty)(true); - updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread - runOnJS(submitForm)(); - }); + const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; return ( @@ -936,31 +922,12 @@ function ReportActionCompose({ onEmojiSelected={replaceSelectionWithText} /> )} - e.preventDefault()} - > - - - [ - styles.chatItemSubmitButton, - isCommentEmpty || hasExceededMaxCommentLength || pressed || isDisabled ? undefined : styles.buttonSuccess, - ]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('common.send')} - > - {({pressed}) => ( - - )} - - - - + { + 'worklet'; + + const viewTag = animatedRef(); + const viewName = 'RCTMultilineTextInputView'; + const updates = {text: ''}; + // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state + runOnJS(setIsCommentEmpty)(true); + updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread + runOnJS(submitForm)(); + }); + + return ( + e.preventDefault()} + > + + + [styles.chatItemSubmitButton, isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + + + + ); +} + +SendButton.propTypes = propTypes; +SendButton.displayName = 'SendButton'; + +export default SendButton; From 33b8e4be133da325a0a3ecffaff504232ca02061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 9 Aug 2023 08:48:19 +0200 Subject: [PATCH 19/85] wip: splitting out AttachmentPicker --- .../ReportActionCompose.js | 265 ++++++++++-------- 1 file changed, 148 insertions(+), 117 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 665fcfe0811..468f5ea462b 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -55,6 +55,8 @@ import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction' import withAnimatedRef from '../../../../components/withAnimatedRef'; import Suggestions from './Suggestions'; import SendButton from './SendButton'; +import useLocalize from '../../../../hooks/useLocalize'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; const {RNTextInputReset} = NativeModules; @@ -744,123 +746,20 @@ function ReportActionCompose({ > {({displayFileInModal}) => ( <> - - {({openPicker}) => { - const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; - } - openPicker({ - onPicked: displayFileInModal, - }); - }; - const menuItems = [ - ...moneyRequestOptions, - ...taskOption, - { - icon: Expensicons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, - }, - ]; - return ( - <> - - {isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - > - - - - )} - {!isComposerFullSize && isFullSizeComposerAvailable && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - > - - - - )} - - { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - actionButtonRef.current.blur(); - setMenuVisibility(!isMenuVisible); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.addAction')} - > - - - - - setMenuVisibility(false)} - onItemSelected={(item, index) => { - setMenuVisibility(false); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - triggerAttachmentPicker(); - } - }} - anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} - menuItems={menuItems} - withoutOverlay - anchorRef={actionButtonRef} - /> - - ); - }} - + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + shouldBlockEmojiCalc.current = true; + shouldBlockMentionCalc.current = true; + } + openPicker({ + onPicked: displayFileInModal, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); + }, + }, + ]; + return ( + <> + + {isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + > + + + + )} + {!isComposerFullSize && isFullSizeComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButtonRef.current.blur(); + setMenuVisibility(!isMenuVisible); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.addAction')} + > + + + + + setMenuVisibility(false)} + onItemSelected={(item, index) => { + setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + triggerAttachmentPicker(); + } + }} + anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + menuItems={menuItems} + withoutOverlay + anchorRef={actionButtonRef} + /> + + ); + }} + + ); +} From 45a884284b978db53d6408ae211dda1e3560fbed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 9 Aug 2023 08:49:18 +0200 Subject: [PATCH 20/85] wip: splitting out AttachmentPicker --- .../AttachmentPickerWithMenuItems.js | 149 ++++++++++++++++++ .../ReportActionCompose.js | 140 +--------------- 2 files changed, 150 insertions(+), 139 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js new file mode 100644 index 00000000000..57dd9aa5261 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -0,0 +1,149 @@ +import React from 'react'; +import {View} from 'react-native'; +import styles from '../../../../styles/styles'; +import Icon from '../../../../components/Icon'; +import * as Expensicons from '../../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../../components/AttachmentPicker'; +import * as Report from '../../../../libs/actions/Report'; +import PopoverMenu from '../../../../components/PopoverMenu'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import CONST from '../../../../CONST'; +import Tooltip from '../../../../components/Tooltip'; +import * as Browser from '../../../../libs/Browser'; +import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; +import useLocalize from '../../../../hooks/useLocalize'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; + +function AttachmentPickerWithMenuItems({ + displayFileInModal, + moneyRequestOptions, + taskOption, + isFullSizeComposerAvailable, + isComposerFullSize, + updateShouldShowSuggestionMenuToFalse, + reportID, + isBlockedFromConcierge, + disabled, + actionButtonRef, + setMenuVisibility, + isMenuVisible, +}) { + const {translate} = useLocalize(); + const {windowHeight} = useWindowDimensions(); + + return ( + + {({openPicker}) => { + const triggerAttachmentPicker = () => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (willBlurTextInputOnTapOutsideFunc) { + shouldBlockEmojiCalc.current = true; + shouldBlockMentionCalc.current = true; + } + openPicker({ + onPicked: displayFileInModal, + }); + }; + const menuItems = [ + ...moneyRequestOptions, + ...taskOption, + { + icon: Expensicons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + triggerAttachmentPicker(); + }, + }, + ]; + return ( + <> + + {isComposerFullSize && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, false); + }} + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.collapse')} + > + + + + )} + {!isComposerFullSize && isFullSizeComposerAvailable && ( + + { + e.preventDefault(); + updateShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(reportID, true); + }} + // Keep focus on the composer when Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.expand')} + > + + + + )} + + { + e.preventDefault(); + + // Drop focus to avoid blue focus ring. + actionButtonRef.current.blur(); + setMenuVisibility(!isMenuVisible); + }} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || disabled} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('reportActionCompose.addAction')} + > + + + + + setMenuVisibility(false)} + onItemSelected={(item, index) => { + setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + triggerAttachmentPicker(); + } + }} + anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + menuItems={menuItems} + withoutOverlay + anchorRef={actionButtonRef} + /> + + ); + }} + + ); +} + +export default AttachmentPickerWithMenuItems; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 468f5ea462b..c532867fd41 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -8,14 +8,11 @@ import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import ONYXKEYS from '../../../../ONYXKEYS'; -import Icon from '../../../../components/Icon'; import * as Expensicons from '../../../../components/Icon/Expensicons'; -import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; import compose from '../../../../libs/compose'; -import PopoverMenu from '../../../../components/PopoverMenu'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; @@ -29,7 +26,6 @@ import ParticipantLocalTime from '../ParticipantLocalTime'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; import {withNetwork} from '../../../../components/OnyxProvider'; import * as User from '../../../../libs/actions/User'; -import Tooltip from '../../../../components/Tooltip'; import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerButton'; import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import OfflineIndicator from '../../../../components/OfflineIndicator'; @@ -48,15 +44,13 @@ import containerComposeStyles from '../../../../styles/containerComposeStyles'; import * as Task from '../../../../libs/actions/Task'; import * as Browser from '../../../../libs/Browser'; import * as IOU from '../../../../libs/actions/IOU'; -import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; import Suggestions from './Suggestions'; import SendButton from './SendButton'; -import useLocalize from '../../../../hooks/useLocalize'; -import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; const {RNTextInputReset} = NativeModules; @@ -915,135 +909,3 @@ export default compose( }, }), )(ReportActionCompose); - -function AttachmentPickerWithMenuItems({ - displayFileInModal, - moneyRequestOptions, - taskOption, - isFullSizeComposerAvailable, - isComposerFullSize, - updateShouldShowSuggestionMenuToFalse, - reportID, - isBlockedFromConcierge, - disabled, - actionButtonRef, - setMenuVisibility, - isMenuVisible, -}) { - const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); - - return ( - - {({openPicker}) => { - const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; - } - openPicker({ - onPicked: displayFileInModal, - }); - }; - const menuItems = [ - ...moneyRequestOptions, - ...taskOption, - { - icon: Expensicons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } - triggerAttachmentPicker(); - }, - }, - ]; - return ( - <> - - {isComposerFullSize && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - > - - - - )} - {!isComposerFullSize && isFullSizeComposerAvailable && ( - - { - e.preventDefault(); - updateShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - > - - - - )} - - { - e.preventDefault(); - - // Drop focus to avoid blue focus ring. - actionButtonRef.current.blur(); - setMenuVisibility(!isMenuVisible); - }} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.addAction')} - > - - - - - setMenuVisibility(false)} - onItemSelected={(item, index) => { - setMenuVisibility(false); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - triggerAttachmentPicker(); - } - }} - anchorPosition={styles.createMenuPositionReportActionCompose(windowHeight)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} - menuItems={menuItems} - withoutOverlay - anchorRef={actionButtonRef} - /> - - ); - }} - - ); -} From ba38175bb46ce8a06d4a41519b1bba7429b75f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 9 Aug 2023 08:53:20 +0200 Subject: [PATCH 21/85] wip: split out SendButton & AttachmentPickerWithMenu move actionButtonRef --- .../ReportActionCompose/AttachmentPickerWithMenuItems.js | 5 +++-- .../home/report/ReportActionCompose/ReportActionCompose.js | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 57dd9aa5261..9fdc56c3569 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import styles from '../../../../styles/styles'; import Icon from '../../../../components/Icon'; @@ -24,13 +24,14 @@ function AttachmentPickerWithMenuItems({ reportID, isBlockedFromConcierge, disabled, - actionButtonRef, setMenuVisibility, isMenuVisible, }) { const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); + const actionButtonRef = useRef(null); + return ( {({openPicker}) => { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c532867fd41..38e6292ea86 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -246,7 +246,6 @@ function ReportActionCompose({ const commentRef = useRef(comment); const textInputRef = useRef(null); - const actionButtonRef = useRef(null); const suggestionsRef = useRef(null); @@ -750,7 +749,6 @@ function ReportActionCompose({ reportID={reportID} isBlockedFromConcierge={isBlockedFromConcierge} disabled={disabled} - actionButtonRef={actionButtonRef} setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} /> From 433d616f1be63199f6ed7f09ded5d6799fdf7793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 9 Aug 2023 09:11:58 +0200 Subject: [PATCH 22/85] wip: move menu items calc to separate component --- .../AttachmentPickerWithMenuItems.js | 134 ++++++++++++++++-- .../ReportActionCompose.js | 73 ++-------- 2 files changed, 136 insertions(+), 71 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 9fdc56c3569..65db434981e 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -1,5 +1,7 @@ -import React, {useRef} from 'react'; +import React, {useRef, useMemo} from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import styles from '../../../../styles/styles'; import Icon from '../../../../components/Icon'; import * as Expensicons from '../../../../components/Icon/Expensicons'; @@ -13,11 +15,74 @@ import * as Browser from '../../../../libs/Browser'; import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import useLocalize from '../../../../hooks/useLocalize'; import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import * as IOU from '../../../../libs/actions/IOU'; +import * as Task from '../../../../libs/actions/Task'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import Permissions from '../../../../libs/Permissions'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** The report currently being looked at */ + report: PropTypes.shape({ + /** ID of the report */ + reportID: PropTypes.number, + + /** Whether or not the report is in the process of being created */ + loading: PropTypes.bool, + }).isRequired, + + /** The personal details of everyone in the report */ + reportParticipants: PropTypes.objectOf( + PropTypes.shape({ + /** Display name of the participant */ + displayName: PropTypes.string, + }), + ), + + /** Callback to open the file in the modal */ + displayFileInModal: PropTypes.func.isRequired, + + /** Whether or not the full size composer is available */ + isFullSizeComposerAvailable: PropTypes.bool.isRequired, + + /** Whether or not the composer is full size */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Updates the isComposerFullSize value */ + updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, + + /** Whether or not the user is blocked from concierge */ + isBlockedFromConcierge: PropTypes.bool.isRequired, + + /** Whether or not the attachment picker is disabled */ + disabled: PropTypes.bool.isRequired, + + /** Sets the menu visibility */ + setMenuVisibility: PropTypes.func.isRequired, + + /** Whether or not the menu is visible */ + isMenuVisible: PropTypes.bool.isRequired, + + /** Report ID */ + reportID: PropTypes.number.isRequired, + + /** Called when opening the attachment picker */ + onTriggerAttachmentPicker: PropTypes.func.isRequired, +}; + +const defaultProps = { + betas: [], + reportParticipants: {}, +}; function AttachmentPickerWithMenuItems({ + betas, + report, + reportParticipants, displayFileInModal, - moneyRequestOptions, - taskOption, isFullSizeComposerAvailable, isComposerFullSize, updateShouldShowSuggestionMenuToFalse, @@ -26,22 +91,62 @@ function AttachmentPickerWithMenuItems({ disabled, setMenuVisibility, isMenuVisible, + onTriggerAttachmentPicker, }) { const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); - const actionButtonRef = useRef(null); + /** + * Returns the list of IOU Options + * @returns {Array} + */ + const moneyRequestOptions = useMemo(() => { + const options = { + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { + icon: Expensicons.Receipt, + text: translate('iou.splitBill'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { + icon: Expensicons.MoneyCircle, + text: translate('iou.requestMoney'), + }, + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { + icon: Expensicons.Send, + text: translate('iou.sendMoney'), + }, + }; + + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ + ...options[option], + onSelected: () => IOU.startMoneyRequest(option, report.reportID), + })); + }, [betas, report, reportParticipants, translate]); + + /** + * Determines if we can show the task option + * @returns {Boolean} + */ + const taskOption = useMemo(() => { + // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email + if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + return []; + } + + return [ + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), + }, + ]; + }, [betas, report, reportID, translate]); + return ( {({openPicker}) => { const triggerAttachmentPicker = () => { - // Set a flag to block suggestion calculation until we're finished using the file picker, - // which will stop any flickering as the file picker opens on non-native devices. - if (willBlurTextInputOnTapOutsideFunc) { - shouldBlockEmojiCalc.current = true; - shouldBlockMentionCalc.current = true; - } + onTriggerAttachmentPicker(); openPicker({ onPicked: displayFileInModal, }); @@ -147,4 +252,11 @@ function AttachmentPickerWithMenuItems({ ); } -export default AttachmentPickerWithMenuItems; +AttachmentPickerWithMenuItems.propTypes = propTypes; +AttachmentPickerWithMenuItems.defaultProps = defaultProps; + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, +})(AttachmentPickerWithMenuItems); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 38e6292ea86..30e88ff99c9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -8,7 +8,6 @@ import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import ONYXKEYS from '../../../../ONYXKEYS'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; @@ -39,11 +38,8 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../../libs/ComposerUtils'; import * as Welcome from '../../../../libs/actions/Welcome'; -import Permissions from '../../../../libs/Permissions'; import containerComposeStyles from '../../../../styles/containerComposeStyles'; -import * as Task from '../../../../libs/actions/Task'; import * as Browser from '../../../../libs/Browser'; -import * as IOU from '../../../../libs/actions/IOU'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; @@ -55,9 +51,6 @@ import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; const {RNTextInputReset} = NativeModules; const propTypes = { - /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), - /** A method to call when the form is submitted */ onSubmit: PropTypes.func.isRequired, @@ -125,7 +118,6 @@ const propTypes = { }; const defaultProps = { - betas: [], comment: '', numberOfLines: undefined, modal: {}, @@ -171,7 +163,6 @@ const isMobileSafari = Browser.isMobileSafari(); function ReportActionCompose({ animatedRef, - betas, blockedFromConcierge, comment, currentUserPersonalDetails, @@ -460,51 +451,6 @@ function ReportActionCompose({ [animatedRef], ); - /** - * Returns the list of IOU Options - * @returns {Array} - */ - const moneyRequestOptions = useMemo(() => { - const options = { - [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: { - icon: Expensicons.Receipt, - text: translate('iou.splitBill'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: { - icon: Expensicons.MoneyCircle, - text: translate('iou.requestMoney'), - }, - [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: { - icon: Expensicons.Send, - text: translate('iou.sendMoney'), - }, - }; - - return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ - ...options[option], - onSelected: () => IOU.startMoneyRequest(option, report.reportID), - })); - }, [betas, report, reportParticipants, translate]); - - /** - * Determines if we can show the task option - * @returns {Boolean} - */ - const taskOption = useMemo(() => { - // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email - if (!Permissions.canUseTasks(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - return []; - } - - return [ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID), - }, - ]; - }, [betas, report, reportID, translate]); - /** * Update the number of lines for a comment in Onyx * @param {Number} numberOfLines @@ -641,6 +587,15 @@ function ReportActionCompose({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); }, [textInputRef]); + const onTriggerAttachmentPicker = useCallback(() => { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (!willBlurTextInputOnTapOutsideFunc) { + return; + } + suggestionsRef.current.setShouldBlockEmojiCalc(true); + }, []); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -741,16 +696,17 @@ function ReportActionCompose({ <> `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, }, From 575046d63e77e59e1cdccb12a9e955212b3b3e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 13:14:15 +0200 Subject: [PATCH 23/85] wip: move composer to its own component --- .../ReportActionCompose.js | 73 +++++++---------- .../ReportActionCompose/SoloComposer.js | 81 +++++++++++++++++++ 2 files changed, 109 insertions(+), 45 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/SoloComposer.js diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 30e88ff99c9..538ea054f05 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -5,8 +5,6 @@ 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 * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; @@ -38,7 +36,6 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as ComposerUtils from '../../../../libs/ComposerUtils'; import * as Welcome from '../../../../libs/actions/Welcome'; -import containerComposeStyles from '../../../../styles/containerComposeStyles'; import * as Browser from '../../../../libs/Browser'; import usePrevious from '../../../../hooks/usePrevious'; import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; @@ -47,6 +44,7 @@ import withAnimatedRef from '../../../../components/withAnimatedRef'; import Suggestions from './Suggestions'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import SoloComposer from './SoloComposer'; const {RNTextInputReset} = NativeModules; @@ -708,48 +706,33 @@ function ReportActionCompose({ isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> - - updateComment(commentValue, true)} - onKeyPress={triggerHotkeyActions} - style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={maxComposerLines} - onFocus={() => setIsFocused(true)} - onBlur={() => { - setIsFocused(false); - suggestionsRef.current.resetSuggestions(); - }} - onClick={updateShouldShowSuggestionMenuToFalse} - onPasteFile={displayFileInModal} - shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} - isDisabled={isBlockedFromConcierge || disabled} - selection={selection} - onSelectionChange={onSelectionChange} - isFullComposerAvailable={isFullSizeComposerAvailable} - setIsFullComposerAvailable={setIsFullComposerAvailable} - isComposerFullSize={isComposerFullSize} - value={value} - numberOfLines={numberOfLines} - onNumberOfLinesChange={updateNumberOfLines} - shouldCalculateCaretPosition - onLayout={(e) => { - const composerLayoutHeight = e.nativeEvent.layout.height; - if (composerHeight === composerLayoutHeight) { - return; - } - setComposerHeight(composerLayoutHeight); - }} - onScroll={updateShouldShowSuggestionMenuToFalse} - /> - + { if (isAttachmentPreviewActive) { diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js new file mode 100644 index 00000000000..6bc7635e2fd --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -0,0 +1,81 @@ +import React from 'react'; +import {View} from 'react-native'; +import styles from '../../../../styles/styles'; +import themeColors from '../../../../styles/themes/default'; +import Composer from '../../../../components/Composer'; +import containerComposeStyles from '../../../../styles/containerComposeStyles'; + +function SoloComposer({ + checkComposerVisibility, + shouldAutoFocus, + setTextInputRef, + inputPlaceholder, + updateComment, + triggerHotkeyActions, + isComposerFullSize, + maxComposerLines, + setIsFocused, + suggestionsRef, + updateShouldShowSuggestionMenuToFalse, + displayFileInModal, + textInputShouldClear, + setTextInputShouldClear, + isBlockedFromConcierge, + disabled, + selection, + onSelectionChange, + isFullSizeComposerAvailable, + setIsFullComposerAvailable, + value, + numberOfLines, + updateNumberOfLines, + composerHeight, + setComposerHeight, +}) { + return ( + + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxComposerLines} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + suggestionsRef.current.resetSuggestions(); + }} + onClick={updateShouldShowSuggestionMenuToFalse} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isBlockedFromConcierge || disabled} + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullSizeComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + value={value} + numberOfLines={numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + shouldCalculateCaretPosition + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + onScroll={updateShouldShowSuggestionMenuToFalse} + /> + + ); +} + +export default SoloComposer; From cdefcc62d7505e675196f1c500a0333d46a6f91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 14:07:38 +0200 Subject: [PATCH 24/85] remove unused prop --- src/pages/home/report/ReportActionCompose/SuggestionEmoji.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 39fc18e2705..fdccf4958c4 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -250,7 +250,6 @@ function SuggestionEmoji({ isComposerFullSize={isComposerFullSize} preferredSkinToneIndex={preferredSkinTone} isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} - composerHeight={composerHeight} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} /> ); From e8201940106d08f7215bbbc1ffa1ecd5b2e951eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 14:08:22 +0200 Subject: [PATCH 25/85] wip: move composer to its own component move maxComposerLines --- .../home/report/ReportActionCompose/ReportActionCompose.js | 2 -- src/pages/home/report/ReportActionCompose/SoloComposer.js | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 538ea054f05..7ccc09b4c76 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -663,7 +663,6 @@ function ReportActionCompose({ const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; const isFullSizeComposerAvailable = isFullComposerAvailable && !_.isEmpty(value); const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); - const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; @@ -714,7 +713,6 @@ function ReportActionCompose({ updateComment={updateComment} triggerHotkeyActions={triggerHotkeyActions} isComposerFullSize={isComposerFullSize} - maxComposerLines={maxComposerLines} setIsFocused={setIsFocused} suggestionsRef={suggestionsRef} updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js index 6bc7635e2fd..a05ec3502f5 100644 --- a/src/pages/home/report/ReportActionCompose/SoloComposer.js +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -4,6 +4,8 @@ import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import containerComposeStyles from '../../../../styles/containerComposeStyles'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import CONST from '../../../../CONST'; function SoloComposer({ checkComposerVisibility, @@ -13,7 +15,6 @@ function SoloComposer({ updateComment, triggerHotkeyActions, isComposerFullSize, - maxComposerLines, setIsFocused, suggestionsRef, updateShouldShowSuggestionMenuToFalse, @@ -32,6 +33,9 @@ function SoloComposer({ composerHeight, setComposerHeight, }) { + const {isSmallScreenWidth} = useWindowDimensions(); + const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + return ( Date: Thu, 10 Aug 2023 16:56:24 +0200 Subject: [PATCH 26/85] wip: Split out composer First working and performing solution --- .../ReportActionCompose.js | 515 +++--------------- .../ReportActionCompose/SoloComposer.js | 433 ++++++++++++++- .../debouncedSaveReportComment.js | 13 + 3 files changed, 503 insertions(+), 458 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 7ccc09b4c76..d2dfdb34edc 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; -import {View, InteractionManager, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native'; +import {View, LayoutAnimation} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -10,14 +10,11 @@ import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; import compose from '../../../../libs/compose'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import CONST from '../../../../CONST'; -import reportActionPropTypes from '../reportActionPropTypes'; import * as ReportUtils from '../../../../libs/ReportUtils'; -import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; import participantPropTypes from '../../../../components/participantPropTypes'; import ParticipantLocalTime from '../ParticipantLocalTime'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../../components/withCurrentUserPersonalDetails'; @@ -27,64 +24,35 @@ import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerBut import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import OfflineIndicator from '../../../../components/OfflineIndicator'; import ExceededCommentLength from '../../../../components/ExceededCommentLength'; -import withNavigationFocus from '../../../../components/withNavigationFocus'; -import withNavigation from '../../../../components/withNavigation'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; import ReportDropUI from '../ReportDropUI'; import reportPropTypes from '../../../reportPropTypes'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; -import * as ComposerUtils from '../../../../libs/ComposerUtils'; import * as Welcome from '../../../../libs/actions/Welcome'; -import * as Browser from '../../../../libs/Browser'; -import usePrevious from '../../../../hooks/usePrevious'; -import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; -import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import withAnimatedRef from '../../../../components/withAnimatedRef'; import Suggestions from './Suggestions'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import SoloComposer from './SoloComposer'; - -const {RNTextInputReset} = NativeModules; +import debouncedSaveReportComment from './debouncedSaveReportComment'; +import withWindowDimensions from '../../../../components/withWindowDimensions'; const propTypes = { /** A method to call when the form is submitted */ onSubmit: PropTypes.func.isRequired, - /** The comment left by the user */ - comment: PropTypes.string, - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - /** The ID of the report actions will be created for */ reportID: PropTypes.string.isRequired, - /** Details about any modals being used */ - modal: PropTypes.shape({ - /** Indicates if there is a modal currently visible or not */ - isVisible: PropTypes.bool, - }), - /** Personal details of all the users */ personalDetails: PropTypes.objectOf(participantPropTypes), /** The report currently being looked at */ report: reportPropTypes, - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - - /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - /** Is the window width narrow, like on a mobile device */ isSmallScreenWidth: PropTypes.bool.isRequired, - /** Is composer screen focused */ - isFocused: PropTypes.bool.isRequired, - /** Is composer full size */ isComposerFullSize: PropTypes.bool, @@ -100,28 +68,19 @@ const propTypes = { /** Whether the composer input should be shown */ shouldShowComposeInput: PropTypes.bool, - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), /** animated ref from react-native-reanimated */ animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, - ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, - ...keyboardStatePropTypes, }; const defaultProps = { - comment: '', - numberOfLines: undefined, modal: {}, report: {}, - reportActions: [], - parentReportActions: {}, blockedFromConcierge: {}, personalDetails: {}, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, @@ -131,87 +90,44 @@ const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, }; -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); -/** - * Save draft report comment. Debounced to happen at most once per second. - * @param {String} reportID - * @param {String} comment - */ -const debouncedSaveReportComment = _.debounce((reportID, comment) => { - Report.saveReportComment(reportID, comment || ''); -}, 1000); - -/** - * Broadcast that the user is typing. Debounced to limit how often we publish client events. - * @param {String} reportID - */ -const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { - Report.broadcastUserIsTyping(reportID); -}, 100); - -// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus -// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), -// so we need to ensure that it is only updated after focus. -const isMobileSafari = Browser.isMobileSafari(); - function ReportActionCompose({ animatedRef, blockedFromConcierge, - comment, currentUserPersonalDetails, disabled, isComposerFullSize, - isFocused: isFocusedProp, - isKeyboardShown, isMediumScreenWidth, isSmallScreenWidth, - modal, - navigation, network, - numberOfLines, onSubmit, - parentReportActions, pendingAction, personalDetails, - preferredLocale, - preferredSkinTone, report, - reportActions, reportID, shouldShowComposeInput, translate, - windowHeight, + isCommentEmpty: isCommentEmptyProp, }) { /** * Updates the Highlight state of the composer */ - const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && !modal.isVisible && !modal.willAlertModalBecomeVisible && shouldShowComposeInput); + const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && shouldShowComposeInput /* TODO: && !modal.isVisible && !modal.willAlertModalBecomeVisible */); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); - - const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; - /** * Updates the should clear state of the composer */ const [textInputShouldClear, setTextInputShouldClear] = useState(false); - const [isCommentEmpty, setIsCommentEmpty] = useState(comment.length === 0); + const [isCommentEmpty, setIsCommentEmpty] = useState(isCommentEmptyProp); /** * Updates the visibility state of the menu */ const [isMenuVisible, setMenuVisibility] = useState(false); - const [selection, setSelection] = useState({ - start: isMobileSafari && !shouldAutoFocus ? 0 : comment.length, - end: isMobileSafari && !shouldAutoFocus ? 0 : comment.length, - }); - const [value, setValue] = useState(comment); const [composerHeight, setComposerHeight] = useState(0); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); @@ -233,9 +149,6 @@ function ReportActionCompose({ */ const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); - const commentRef = useRef(comment); - const textInputRef = useRef(null); - const suggestionsRef = useRef(null); const reportParticipants = useMemo(() => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report]); @@ -268,80 +181,6 @@ function ReportActionCompose({ return translate('reportActionCompose.writeSomething'); }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); - /** - * Focus the composer text input - * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose - */ - const focus = useCallback((shouldDelay = false) => { - // There could be other animations running while we trigger manual focus. - // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!textInputRef.current) { - return; - } - - if (!shouldDelay) { - textInputRef.current.focus(); - } else { - // Keyboard is not opened after Emoji Picker is closed - // SetTimeout is used as a workaround - // https://github.com/react-native-modal/react-native-modal/issues/114 - // We carefully choose a delay. 100ms is found enough for keyboard to open. - setTimeout(() => textInputRef.current.focus(), 100); - } - }); - }, []); - - /** - * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment - */ - const updateComment = useCallback( - (commentValue, shouldDebounceSaveComment) => { - const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale); - - if (!_.isEmpty(emojis)) { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); - insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; - debouncedUpdateFrequentlyUsedEmojis(); - } - - setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); - setValue(newComment); - if (commentValue !== newComment) { - const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment); - setSelection({ - start: newComment.length - remainder, - end: newComment.length - remainder, - }); - } - - // Indicate that draft has been created. - if (commentRef.current.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(reportID, true); - } - - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(reportID, false); - } - - commentRef.current = newComment; - if (shouldDebounceSaveComment) { - debouncedSaveReportComment(reportID, newComment); - } else { - Report.saveReportComment(reportID, newComment || ''); - } - if (newComment) { - debouncedBroadcastUserIsTyping(reportID); - } - }, - [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID], - ); - /** * Used to show Popover menu on Workspace chat at first sign-in * @returns {Boolean} @@ -355,132 +194,17 @@ function ReportActionCompose({ [], ); - /** - * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) - * @param {String} text - * @param {Boolean} shouldAddTrailSpace - */ - const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); - }, - [selection, updateComment], - ); - - /** - * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. - * @returns {Boolean} - */ - const checkComposerVisibility = useCallback(() => { - const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible; - return !isComposerCoveredUp; - }, [isMenuVisible, modal.isVisible]); - - const focusComposerOnKeyPress = useCallback( - (e) => { - const isComposerVisible = checkComposerVisibility(); - if (!isComposerVisible) { - return; - } - - // If the key pressed is non-character keys like Enter, Shift, ... do not focus - if (e.key.length > 1) { - return; - } - - // If a key is pressed in combination with Meta, Control or Alt do not focus - if (e.metaKey || e.ctrlKey || e.altKey) { - return; - } - - // if we're typing on another input/text area, do not focus - if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { - return; - } - - focus(); - replaceSelectionWithText(e.key, false); - }, - [checkComposerVisibility, focus, replaceSelectionWithText], - ); - const onSelectionChange = useCallback((e) => { LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - if (suggestionsRef.current.onSelectionChange(e)) { - return; - } + // if (suggestionsRef.current.onSelectionChange(e)) { + // return; + // } - setSelection(e.nativeEvent.selection); + // TODO: set selection + // setSelection(e.nativeEvent.selection); }, []); - const setUpComposeFocusManager = useCallback(() => { - // This callback is used in the contextMenuActions to manage giving focus back to the compose input. - // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component - ReportActionComposeFocusManager.onComposerFocus(() => { - if (!willBlurTextInputOnTapOutside || !isFocusedProp) { - return; - } - - focus(false); - }); - }, [focus, isFocusedProp]); - - /** - * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose - */ - const setTextInputRef = useCallback( - (el) => { - ReportActionComposeFocusManager.composerRef.current = el; - textInputRef.current = el; - if (_.isFunction(animatedRef)) { - animatedRef(el); - } - }, - [animatedRef], - ); - - /** - * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines - */ - const updateNumberOfLines = useCallback( - (newNumberOfLines) => { - Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); - }, - [reportID], - ); - - /** - * @returns {String} - */ - const prepareCommentAndResetComposer = useCallback(() => { - const trimmedComment = commentRef.current.trim(); - const commentLength = ReportUtils.getCommentLength(trimmedComment); - - // Don't submit empty comments or comments that exceed the character limit - if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - updateComment(''); - setTextInputShouldClear(true); - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - return trimmedComment; - }, [reportID, updateComment, isComposerFullSize]); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (!suggestionsRef.current) { return; @@ -488,64 +212,6 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - /** - * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] - */ - const submitForm = useCallback( - (e) => { - if (e) { - e.preventDefault(); - } - - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - debouncedSaveReportComment.cancel(); - - const newComment = prepareCommentAndResetComposer(); - if (!newComment) { - return; - } - - onSubmit(newComment); - }, - [onSubmit, prepareCommentAndResetComposer], - ); - - const triggerHotkeyActions = useCallback( - (e) => { - if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { - return; - } - - if (suggestionsRef.current.triggerHotkeyActions(e)) { - return; - } - - // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); - submitForm(); - } - - // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants - if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) { - e.preventDefault(); - - const parentReportActionID = lodashGet(report, 'parentReportActionID', ''); - const parentReportAction = lodashGet(parentReportActions, [parentReportActionID], {}); - const lastReportAction = _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action)); - - if (lastReportAction !== -1 && lastReportAction) { - Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); - } - } - }, - [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, value.length], - ); - /** * @param {Object} file */ @@ -559,7 +225,7 @@ function ReportActionCompose({ Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); }, - [reportID, prepareCommentAndResetComposer], + [reportID], ); /** @@ -578,12 +244,31 @@ function ReportActionCompose({ [debouncedUpdateFrequentlyUsedEmojis], ); - const resetKeyboardInput = useCallback(() => { - if (!RNTextInputReset) { - return; - } - RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); - }, [textInputRef]); + /** + * Add a new comment to this chat + * + * @param {SyntheticEvent} [e] + */ + const submitForm = useCallback( + (e) => { + if (e) { + e.preventDefault(); + } + + // Since we're submitting the form here which should clear the composer + // We don't really care about saving the draft the user was typing + // We need to make sure an empty draft gets saved instead + debouncedSaveReportComment.cancel(); + + const newComment = prepareCommentAndResetComposer(); + if (!newComment) { + return; + } + + onSubmit(newComment); + }, + [onSubmit], + ); const onTriggerAttachmentPicker = useCallback(() => { // Set a flag to block suggestion calculation until we're finished using the file picker, @@ -594,74 +279,28 @@ function ReportActionCompose({ suggestionsRef.current.setShouldBlockEmojiCalc(true); }, []); - useEffect(() => { - const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); - const unsubscribeNavigationFocus = navigation.addListener('focus', () => { - KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); - setUpComposeFocusManager(); - }); - KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); - - setUpComposeFocusManager(); - - updateComment(commentRef.current); - - // Shows Popover Menu on Workspace Chat at first sign-in - if (!disabled) { - Welcome.show({ - routes: lodashGet(navigation.getState(), 'routes', []), - showPopoverMenu, - }); - } - - if (comment.length !== 0) { - Report.setReportWithDraft(reportID, true); - } - - return () => { - ReportActionComposeFocusManager.clear(); - - KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); - unsubscribeNavigationBlur(); - unsubscribeNavigationFocus(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const prevIsModalVisible = usePrevious(modal.isVisible); - const prevIsFocused = usePrevious(isFocusedProp); - useEffect(() => { - // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (!willBlurTextInputOnTapOutside || modal.isVisible || !isFocusedProp || prevIsModalVisible || !prevIsFocused) { - return; - } - - focus(); - }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); + // TODO: migrate me + // useEffect(() => { + // updateComment(commentRef.current); - const prevCommentProp = usePrevious(comment); - const prevPreferredLocale = usePrevious(preferredLocale); - const prevReportId = usePrevious(report.reportId); - useEffect(() => { - // Value state does not have the same value as comment props when the comment gets changed from another tab. - // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevCommentProp !== comment && value === comment; + // // Shows Popover Menu on Workspace Chat at first sign-in + // if (!disabled) { + // Welcome.show({ + // routes: lodashGet(navigation.getState(), 'routes', []), + // showPopoverMenu, + // }); + // } - // As the report IDs change, make sure to update the composer comment as we need to make sure - // we do not show incorrect data in there (ie. draft of message from other report). - if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { - return; - } - - updateComment(commentRef.current); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value]); + // if (comment.length !== 0) { + // Report.setReportWithDraft(reportID, true); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, []); // Prevents focusing and showing the keyboard while the drawer is covering the chat. const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; - const isFullSizeComposerAvailable = isFullComposerAvailable && !_.isEmpty(value); + const isFullSizeComposerAvailable = isFullComposerAvailable; // && !_.isEmpty(value); const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; @@ -706,12 +345,11 @@ function ReportActionCompose({ onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> { @@ -747,7 +383,7 @@ function ReportActionCompose({ focus(true)} - onEmojiSelected={replaceSelectionWithText} + onEmojiSelected={() => replaceSelectionWithText} /> )} {!isSmallScreenWidth && } - + /> */} - + /> */} ); } @@ -804,40 +441,24 @@ ReportActionCompose.propTypes = propTypes; ReportActionCompose.defaultProps = defaultProps; export default compose( - withWindowDimensions, - withNavigation, - withNavigationFocus, withLocalize, withNetwork(), + withWindowDimensions, withCurrentUserPersonalDetails, - withKeyboardState, withAnimatedRef, withOnyx({ - comment: { + isCommentEmpty: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - }, - numberOfLines: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, - }, - modal: { - key: ONYXKEYS.MODAL, + selector: (comment) => _.isEmpty(comment), }, blockedFromConcierge: { key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, shouldShowComposeInput: { key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - canEvict: false, - }, }), )(ReportActionCompose); diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js index a05ec3502f5..e7d5e1edb2f 100644 --- a/src/pages/home/report/ReportActionCompose/SoloComposer.js +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -1,19 +1,109 @@ -import React from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useCallback, useState, useRef, useMemo} from 'react'; +import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native'; +import Onyx, {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; import containerComposeStyles from '../../../../styles/containerComposeStyles'; import useWindowDimensions from '../../../../hooks/useWindowDimensions'; import CONST from '../../../../CONST'; +import * as Browser from '../../../../libs/Browser'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import * as KeyDownListener from '../../../../libs/KeyboardShortcut/KeyDownPressListener'; +import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; +import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; +import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager'; +import * as ComposerUtils from '../../../../libs/ComposerUtils'; +import * as Report from '../../../../libs/actions/Report'; +import usePrevious from '../../../../hooks/usePrevious'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; +import * as User from '../../../../libs/actions/User'; +import * as ReportUtils from '../../../../libs/ReportUtils'; +import withNavigation from '../../../../components/withNavigation'; +import withNavigationFocus from '../../../../components/withNavigationFocus'; +import compose from '../../../../libs/compose'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; +import reportActionPropTypes from '../reportActionPropTypes'; +import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; +import debouncedSaveReportComment from './debouncedSaveReportComment'; + +const {RNTextInputReset} = NativeModules; + +// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus +// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), +// so we need to ensure that it is only updated after focus. +const isMobileSafari = Browser.isMobileSafari(); + +/** + * Broadcast that the user is typing. Debounced to limit how often we publish client events. + * @param {String} reportID + */ +const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { + Report.broadcastUserIsTyping(reportID); +}, 100); + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will +// prevent auto focus on existing chat for mobile device +const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + +const draftCommentMap = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + callback: (val, key) => { + draftCommentMap[key] = val; + }, +}); + +const propTypes = { + /** A method to call when the form is submitted */ + submitForm: PropTypes.func.isRequired, + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** Number of lines for the comment */ + numberOfLines: PropTypes.number, + + /** Details about any modals being used */ + modal: PropTypes.shape({ + /** Indicates if there is a modal currently visible or not */ + isVisible: PropTypes.bool, + }), + + /** The actions from the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + ...withLocalizePropTypes, + ...keyboardStatePropTypes, +}; + +const defaultProps = { + comment: '', + numberOfLines: undefined, + parentReportActions: {}, + reportActions: [], + modal: {}, +}; function SoloComposer({ - checkComposerVisibility, - shouldAutoFocus, - setTextInputRef, + modal, + isFocused: isFocusedProp, + preferredLocale, + preferredSkinTone, + report, + navigation, + animatedRef, + isMenuVisible, + isKeyboardShown, + parentReportActions, + reportActions, inputPlaceholder, - updateComment, - triggerHotkeyActions, isComposerFullSize, setIsFocused, suggestionsRef, @@ -23,19 +113,316 @@ function SoloComposer({ setTextInputShouldClear, isBlockedFromConcierge, disabled, - selection, onSelectionChange, isFullSizeComposerAvailable, setIsFullComposerAvailable, - value, numberOfLines, - updateNumberOfLines, composerHeight, setComposerHeight, + reportID, + setIsCommentEmpty, + submitForm, + + // Focus stuff + shouldShowComposeInput, }) { + const initialComment = draftCommentMap[reportID] || ''; + const commentRef = useRef(initialComment); + const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; + const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); + const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; + + const [value, setValue] = useState(initialComment); + const [selection, setSelection] = useState({ + start: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, + end: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, + }); + + const textInputRef = useRef(null); + + /** + * Set the TextInput Ref + * + * @param {Element} el + * @memberof ReportActionCompose + */ + const setTextInputRef = useCallback( + (el) => { + ReportActionComposeFocusManager.composerRef.current = el; + textInputRef.current = el; + if (_.isFunction(animatedRef)) { + animatedRef(el); + } + }, + [animatedRef], + ); + + const resetKeyboardInput = useCallback(() => { + if (!RNTextInputReset) { + return; + } + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); + }, [textInputRef]); + + /** + * Update the value of the comment in Onyx + * + * @param {String} comment + * @param {Boolean} shouldDebounceSaveComment + */ + const updateComment = useCallback( + (commentValue, shouldDebounceSaveComment) => { + const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale); + + if (!_.isEmpty(emojis)) { + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); + // TODO: insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; + // TODO: debouncedUpdateFrequentlyUsedEmojis(); + } + + setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + setValue(newComment); + if (commentValue !== newComment) { + const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment); + setSelection({ + start: newComment.length - remainder, + end: newComment.length - remainder, + }); + } + + // Indicate that draft has been created. + if (commentRef.current.length === 0 && newComment.length !== 0) { + Report.setReportWithDraft(reportID, true); + } + + // The draft has been deleted. + if (newComment.length === 0) { + Report.setReportWithDraft(reportID, false); + } + + commentRef.current = newComment; + if (shouldDebounceSaveComment) { + debouncedSaveReportComment(reportID, newComment); + } else { + Report.saveReportComment(reportID, newComment || ''); + } + if (newComment) { + debouncedBroadcastUserIsTyping(reportID); + } + }, + [preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty], + ); + + /** + * Update the number of lines for a comment in Onyx + * @param {Number} numberOfLines + */ + const updateNumberOfLines = useCallback( + (newNumberOfLines) => { + Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + }, + [reportID], + ); + + /** + * @returns {String} + */ + const prepareCommentAndResetComposer = useCallback(() => { + const trimmedComment = commentRef.current.trim(); + const commentLength = ReportUtils.getCommentLength(trimmedComment); + + // Don't submit empty comments or comments that exceed the character limit + if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { + return ''; + } + + updateComment(''); + setTextInputShouldClear(true); + if (isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + setIsFullComposerAvailable(false); + return trimmedComment; + }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, reportID]); + + /** + * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) + * @param {String} text + * @param {Boolean} shouldAddTrailSpace + */ + const replaceSelectionWithText = useCallback( + (text, shouldAddTrailSpace = true) => { + const updatedText = shouldAddTrailSpace ? `${text} ` : text; + const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; + updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); + setSelection((prevSelection) => ({ + start: prevSelection.start + text.length + selectionSpaceLength, + end: prevSelection.start + text.length + selectionSpaceLength, + })); + }, + [selection, updateComment], + ); + + const triggerHotkeyActions = useCallback( + (e) => { + if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { + return; + } + + // TODO: enable me again :3 + // if (suggestionsRef.current.triggerHotkeyActions(e)) { + // return; + // } + + // Submit the form when Enter is pressed + if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { + e.preventDefault(); + submitForm(); + } + + // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants + if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) { + e.preventDefault(); + + const parentReportActionID = lodashGet(report, 'parentReportActionID', ''); + const parentReportAction = lodashGet(parentReportActions, [parentReportActionID], {}); + const lastReportAction = _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action)); + + if (lastReportAction !== -1 && lastReportAction) { + Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + } + } + }, + [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, suggestionsRef, value.length], + ); + + /** + * Focus the composer text input + * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer + * @memberof ReportActionCompose + */ + const focus = useCallback((shouldDelay = false) => { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + if (!textInputRef.current) { + return; + } + + if (!shouldDelay) { + textInputRef.current.focus(); + } else { + // Keyboard is not opened after Emoji Picker is closed + // SetTimeout is used as a workaround + // https://github.com/react-native-modal/react-native-modal/issues/114 + // We carefully choose a delay. 100ms is found enough for keyboard to open. + setTimeout(() => textInputRef.current.focus(), 100); + } + }); + }, []); + + const setUpComposeFocusManager = useCallback(() => { + // This callback is used in the contextMenuActions to manage giving focus back to the compose input. + // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component + ReportActionComposeFocusManager.onComposerFocus(() => { + if (!willBlurTextInputOnTapOutside || !isFocusedProp) { + return; + } + + focus(false); + }); + }, [focus, isFocusedProp]); + + /** + * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. + * @returns {Boolean} + */ + const checkComposerVisibility = useCallback(() => { + const isComposerCoveredUp = EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible; + return !isComposerCoveredUp; + }, [isMenuVisible, modal.isVisible]); + + const focusComposerOnKeyPress = useCallback( + (e) => { + const isComposerVisible = checkComposerVisibility(); + if (!isComposerVisible) { + return; + } + + // If the key pressed is non-character keys like Enter, Shift, ... do not focus + if (e.key.length > 1) { + return; + } + + // If a key is pressed in combination with Meta, Control or Alt do not focus + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + // if we're typing on another input/text area, do not focus + if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { + return; + } + + focus(); + replaceSelectionWithText(e.key, false); + }, + [checkComposerVisibility, focus, replaceSelectionWithText], + ); + + useEffect(() => { + const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); + const unsubscribeNavigationFocus = navigation.addListener('focus', () => { + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + setUpComposeFocusManager(); + }); + KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + + setUpComposeFocusManager(); + + return () => { + ReportActionComposeFocusManager.clear(); + + KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); + unsubscribeNavigationBlur(); + unsubscribeNavigationFocus(); + }; + }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]); + + const prevIsModalVisible = usePrevious(modal.isVisible); + const prevIsFocused = usePrevious(isFocusedProp); + useEffect(() => { + // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. + // We avoid doing this on native platforms since the software keyboard popping + // open creates a jarring and broken UX. + if (!willBlurTextInputOnTapOutside || modal.isVisible || !isFocusedProp || prevIsModalVisible || !prevIsFocused) { + return; + } + + focus(); + }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); + + // TODO: this could be moved to its own sub component + // const prevCommentProp = usePrevious(comment); + // const prevPreferredLocale = usePrevious(preferredLocale); + // const prevReportId = usePrevious(report.reportId); + // useEffect(() => { + // // Value state does not have the same value as comment props when the comment gets changed from another tab. + // // In this case, we should synchronize the value between tabs. + // const shouldSyncComment = prevCommentProp !== comment && value === comment; + + // // As the report IDs change, make sure to update the composer comment as we need to make sure + // // we do not show incorrect data in there (ie. draft of message from other report). + // if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { + // return; + // } + + // updateComment(commentRef.current); + // }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value]); + return ( `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, + }, + modal: { + key: ONYXKEYS.MODAL, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + canEvict: false, + }, + }), +)(SoloComposer); diff --git a/src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js b/src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js new file mode 100644 index 00000000000..b8569041e8d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js @@ -0,0 +1,13 @@ +import _ from 'underscore'; +import * as Report from '../../../../libs/actions/Report'; + +/** + * Save draft report comment. Debounced to happen at most once per second. + * @param {String} reportID + * @param {String} comment + */ +const debouncedSaveReportComment = _.debounce((reportID, comment) => { + Report.saveReportComment(reportID, comment || ''); +}, 1000); + +export default debouncedSaveReportComment; From 7f7376974cd9f6e4e42bc43bf7f729d7f088de16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 21:39:30 +0200 Subject: [PATCH 27/85] Add UpdateComment component --- .../ReportActionCompose/SoloComposer.js | 27 ++----- .../ReportActionCompose/UpdateComment.js | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/UpdateComment.js diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js index e7d5e1edb2f..fa07763740a 100644 --- a/src/pages/home/report/ReportActionCompose/SoloComposer.js +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -30,6 +30,7 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import reportActionPropTypes from '../reportActionPropTypes'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import debouncedSaveReportComment from './debouncedSaveReportComment'; +import UpdateComment from './UpdateComment'; const {RNTextInputReset} = NativeModules; @@ -84,7 +85,6 @@ const propTypes = { }; const defaultProps = { - comment: '', numberOfLines: undefined, parentReportActions: {}, reportActions: [], @@ -405,24 +405,6 @@ function SoloComposer({ focus(); }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); - // TODO: this could be moved to its own sub component - // const prevCommentProp = usePrevious(comment); - // const prevPreferredLocale = usePrevious(preferredLocale); - // const prevReportId = usePrevious(report.reportId); - // useEffect(() => { - // // Value state does not have the same value as comment props when the comment gets changed from another tab. - // // In this case, we should synchronize the value between tabs. - // const shouldSyncComment = prevCommentProp !== comment && value === comment; - - // // As the report IDs change, make sure to update the composer comment as we need to make sure - // // we do not show incorrect data in there (ie. draft of message from other report). - // if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { - // return; - // } - - // updateComment(commentRef.current); - // }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value]); - return ( + ); } diff --git a/src/pages/home/report/ReportActionCompose/UpdateComment.js b/src/pages/home/report/ReportActionCompose/UpdateComment.js new file mode 100644 index 00000000000..fdecedf3d5b --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/UpdateComment.js @@ -0,0 +1,73 @@ +import {useEffect} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import usePrevious from '../../../../hooks/usePrevious'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import compose from '../../../../libs/compose'; +import withLocalize from '../../../../components/withLocalize'; + +const propTypes = { + /** The comment of the report */ + comment: PropTypes.string, + + /** The preferred locale of the user */ + preferredLocale: PropTypes.string.isRequired, + + /** The report associated with the comment */ + report: PropTypes.shape({ + /** The ID of the report */ + reportID: PropTypes.number, + }).isRequired, + + /** The value of the comment */ + value: PropTypes.string.isRequired, + + /** The ref of the comment */ + commentRef: PropTypes.shape({ + /** The current value of the comment */ + current: PropTypes.string, + }).isRequired, + + /** Updates the comment */ + updateComment: PropTypes.func.isRequired, +}; + +const defaultProps = { + comment: '', +}; + +function UpdateComment({comment, commentRef, preferredLocale, report, value, updateComment}) { + const prevCommentProp = usePrevious(comment); + const prevPreferredLocale = usePrevious(preferredLocale); + const prevReportId = usePrevious(report.reportId); + + useEffect(() => { + // Value state does not have the same value as comment props when the comment gets changed from another tab. + // In this case, we should synchronize the value between tabs. + const shouldSyncComment = prevCommentProp !== comment && value === comment; + + // As the report IDs change, make sure to update the composer comment as we need to make sure + // we do not show incorrect data in there (ie. draft of message from other report). + if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { + return; + } + + // TODO: Why commentRef? Can't we also use comment here? + updateComment(commentRef.current); + }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]); + + return null; +} + +UpdateComment.propTypes = propTypes; +UpdateComment.defaultProps = defaultProps; +UpdateComment.displayName = 'UpdateComment'; + +export default compose( + withLocalize, + withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, + }), +)(UpdateComment); From 1a5454f46488097c2bf1f1c76e5d473609db004a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 21:41:19 +0200 Subject: [PATCH 28/85] don't make triggerHotkeyActions depend on value --- src/pages/home/report/ReportActionCompose/SoloComposer.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js index fa07763740a..2806ab08fb2 100644 --- a/src/pages/home/report/ReportActionCompose/SoloComposer.js +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -136,6 +136,7 @@ function SoloComposer({ const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; const [value, setValue] = useState(initialComment); + const valueRef = usePrevious(value); const [selection, setSelection] = useState({ start: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, end: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, @@ -284,7 +285,8 @@ function SoloComposer({ } // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants - if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && value.length === 0 && !ReportUtils.chatIncludesChronos(report)) { + const valueLength = valueRef.current.length; + if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !ReportUtils.chatIncludesChronos(report)) { e.preventDefault(); const parentReportActionID = lodashGet(report, 'parentReportActionID', ''); @@ -296,7 +298,7 @@ function SoloComposer({ } } }, - [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, suggestionsRef, value.length], + [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, valueRef], ); /** From 0bfc0b764d1b38c96bdba5ed3643bd72d0e8bc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 10 Aug 2023 21:45:34 +0200 Subject: [PATCH 29/85] Suggestions remove unused props --- .../ReportActionCompose/SuggestionEmoji.js | 28 +++++++++++------- .../ReportActionCompose/SuggestionMention.js | 29 ++----------------- .../report/ReportActionCompose/Suggestions.js | 7 ----- 3 files changed, 21 insertions(+), 43 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index fdccf4958c4..359cdf13c84 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -1,11 +1,16 @@ import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; import EmojiSuggestions from '../../../../components/EmojiSuggestions'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import compose from '../../../../libs/compose'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; /** * Check if this piece of string looks like an emoji @@ -28,13 +33,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - // Onyx/Hooks - preferredSkinTone: PropTypes.number.isRequired, - windowHeight: PropTypes.number.isRequired, - isSmallScreenWidth: PropTypes.bool.isRequired, - preferredLocale: PropTypes.string.isRequired, - personalDetails: PropTypes.object.isRequired, - translate: PropTypes.func.isRequired, // Input value: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, @@ -52,6 +50,9 @@ const propTypes = { forwardedRef: PropTypes.object.isRequired, resetKeyboardInput: PropTypes.func.isRequired, onInsertedEmoji: PropTypes.func.isRequired, + + ...windowDimensionsPropTypes, + ...withLocalizePropTypes, }; function SuggestionEmoji({ @@ -60,8 +61,6 @@ function SuggestionEmoji({ preferredLocale, isSmallScreenWidth, preferredSkinTone, - personalDetails, - translate, value, setValue, selection, @@ -265,4 +264,13 @@ const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( /> )); -export default SuggestionEmojiWithRef; +export default compose( + withLocalize, + withWindowDimensions, + withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, + }), +)(SuggestionEmojiWithRef); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 7077dcb0395..bf0193dd3f8 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -7,6 +7,7 @@ import MentionSuggestions from '../../../../components/MentionSuggestions'; import * as UserUtils from '../../../../libs/UserUtils'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; +import useLocalize from '../../../../hooks/useLocalize'; /** * Check if this piece of string looks like a mention @@ -24,13 +25,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - // Onyx/Hooks - preferredSkinTone: PropTypes.number.isRequired, - windowHeight: PropTypes.number.isRequired, - isSmallScreenWidth: PropTypes.bool.isRequired, - preferredLocale: PropTypes.string.isRequired, - personalDetails: PropTypes.object.isRequired, - translate: PropTypes.func.isRequired, // Input value: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, @@ -46,28 +40,11 @@ const propTypes = { shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added forwardedRef: PropTypes.object.isRequired, - resetKeyboardInput: PropTypes.func.isRequired, }; // TODO: split between emoji and mention suggestions -function SuggestionMention({ - isComposerFullSize, - windowHeight, - preferredLocale, - isSmallScreenWidth, - preferredSkinTone, - personalDetails, - translate, - value, - setValue, - selection, - setSelection, - updateComment, - composerHeight, - shouldShowReportRecipientLocalTime, - forwardedRef, - resetKeyboardInput, -}) { +function SuggestionMention({isComposerFullSize, personalDetails, value, setValue, setSelection, updateComment, composerHeight, shouldShowReportRecipientLocalTime, forwardedRef}) { + const {translate} = useLocalize(); // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); // TODO: const valueRef = usePrevious(value); (maybe even pass from parent?) diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 4e325302ec7..714c3ff449a 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -97,13 +97,6 @@ function Suggestions({ return ( <> Date: Fri, 11 Aug 2023 08:55:23 +0200 Subject: [PATCH 30/85] add suggestions back --- .../ReportActionCompose.js | 39 +---- .../ReportActionCompose/SoloComposer.js | 147 +++++++++++------- 2 files changed, 95 insertions(+), 91 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index d2dfdb34edc..1b40cb3eda2 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; -import {View, LayoutAnimation} from 'react-native'; +import {View} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -194,17 +194,6 @@ function ReportActionCompose({ [], ); - const onSelectionChange = useCallback((e) => { - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - - // if (suggestionsRef.current.onSelectionChange(e)) { - // return; - // } - - // TODO: set selection - // setSelection(e.nativeEvent.selection); - }, []); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (!suggestionsRef.current) { return; @@ -353,19 +342,18 @@ function ReportActionCompose({ isComposerFullSize={isComposerFullSize} setIsFocused={setIsFocused} suggestionsRef={suggestionsRef} - updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} displayFileInModal={displayFileInModal} textInputShouldClear={textInputShouldClear} setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} disabled={disabled} - onSelectionChange={onSelectionChange} isFullSizeComposerAvailable={isFullSizeComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} composerHeight={composerHeight} setComposerHeight={setComposerHeight} setIsCommentEmpty={setIsCommentEmpty} submitForm={submitForm} + shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} /> { @@ -410,29 +398,6 @@ function ReportActionCompose({ /> */} - {/* */} ); } diff --git a/src/pages/home/report/ReportActionCompose/SoloComposer.js b/src/pages/home/report/ReportActionCompose/SoloComposer.js index 2806ab08fb2..e0e148f8c80 100644 --- a/src/pages/home/report/ReportActionCompose/SoloComposer.js +++ b/src/pages/home/report/ReportActionCompose/SoloComposer.js @@ -1,5 +1,5 @@ import React, {useEffect, useCallback, useState, useRef, useMemo} from 'react'; -import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native'; +import {View, InteractionManager, NativeModules, findNodeHandle, LayoutAnimation} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; @@ -31,6 +31,7 @@ import reportActionPropTypes from '../reportActionPropTypes'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import debouncedSaveReportComment from './debouncedSaveReportComment'; import UpdateComment from './UpdateComment'; +import Suggestions from './Suggestions'; const {RNTextInputReset} = NativeModules; @@ -107,13 +108,11 @@ function SoloComposer({ isComposerFullSize, setIsFocused, suggestionsRef, - updateShouldShowSuggestionMenuToFalse, displayFileInModal, textInputShouldClear, setTextInputShouldClear, isBlockedFromConcierge, disabled, - onSelectionChange, isFullSizeComposerAvailable, setIsFullComposerAvailable, numberOfLines, @@ -122,6 +121,7 @@ function SoloComposer({ reportID, setIsCommentEmpty, submitForm, + shouldShowReportRecipientLocalTime, // Focus stuff shouldShowComposeInput, @@ -136,7 +136,9 @@ function SoloComposer({ const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; const [value, setValue] = useState(initialComment); - const valueRef = usePrevious(value); + const valueRef = useRef(value); + valueRef.current = value; + const [selection, setSelection] = useState({ start: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, end: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, @@ -273,10 +275,9 @@ function SoloComposer({ return; } - // TODO: enable me again :3 - // if (suggestionsRef.current.triggerHotkeyActions(e)) { - // return; - // } + if (suggestionsRef.current.triggerHotkeyActions(e)) { + return; + } // Submit the form when Enter is pressed if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { @@ -298,9 +299,29 @@ function SoloComposer({ } } }, - [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, valueRef], + [isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, submitForm, suggestionsRef, valueRef], ); + const onSelectionChange = useCallback( + (e) => { + LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); + + if (suggestionsRef.current.onSelectionChange(e)) { + return; + } + + setSelection(e.nativeEvent.selection); + }, + [suggestionsRef], + ); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + if (!suggestionsRef.current) { + return; + } + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + }, [suggestionsRef]); + /** * Focus the composer text input * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer @@ -408,55 +429,73 @@ function SoloComposer({ }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); return ( - - updateComment(commentValue, true)} - onKeyPress={triggerHotkeyActions} - style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={maxComposerLines} - onFocus={() => setIsFocused(true)} - onBlur={() => { - setIsFocused(false); - suggestionsRef.current.resetSuggestions(); - }} - onClick={updateShouldShowSuggestionMenuToFalse} - onPasteFile={displayFileInModal} - shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} - isDisabled={isBlockedFromConcierge || disabled} + <> + + updateComment(commentValue, true)} + onKeyPress={triggerHotkeyActions} + style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxComposerLines} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + suggestionsRef.current.resetSuggestions(); + }} + onClick={updateShouldShowSuggestionMenuToFalse} + onPasteFile={displayFileInModal} + shouldClear={textInputShouldClear} + onClear={() => setTextInputShouldClear(false)} + isDisabled={isBlockedFromConcierge || disabled} + selection={selection} + onSelectionChange={onSelectionChange} + isFullComposerAvailable={isFullSizeComposerAvailable} + setIsFullComposerAvailable={setIsFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + value={value} + numberOfLines={numberOfLines} + onNumberOfLinesChange={updateNumberOfLines} + shouldCalculateCaretPosition + onLayout={(e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + onScroll={updateShouldShowSuggestionMenuToFalse} + /> + + + + { - const composerLayoutHeight = e.nativeEvent.layout.height; - if (composerHeight === composerLayoutHeight) { - return; - } - setComposerHeight(composerLayoutHeight); - }} - onScroll={updateShouldShowSuggestionMenuToFalse} - /> - - + ); } From ad4b5713163f23b7b2f9b768cc8e59dc851043eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 09:08:48 +0200 Subject: [PATCH 31/85] moved over updating frequently used emojis --- .../ReportActionCompose.js | 25 +----- .../ReportActionCompose/SoloComposer.js | 85 ++++++++++++++----- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 1b40cb3eda2..6843caa06ad 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -132,17 +132,6 @@ function ReportActionCompose({ const [composerHeight, setComposerHeight] = useState(0); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const insertedEmojisRef = useRef([]); - - /** - * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis - * API is not called too often. - */ - const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); - insertedEmojisRef.current = []; - }, []); - /** * Updates the composer when the comment length is exceeded * Shows red borders and prevents the comment from being sent @@ -150,6 +139,7 @@ function ReportActionCompose({ const [hasExceededMaxCommentLength, setExceededMaxCommentLength] = useState(false); const suggestionsRef = useRef(null); + const composerRef = useRef(null); const reportParticipants = useMemo(() => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report]); const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipants]); @@ -210,7 +200,7 @@ function ReportActionCompose({ // 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(); + const newComment = composerRef.current.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); }, @@ -225,14 +215,6 @@ function ReportActionCompose({ setIsAttachmentPreviewActive(false); }, [updateShouldShowSuggestionMenuToFalse]); - const onInsertedEmoji = useCallback( - (emojiObject) => { - insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; - debouncedUpdateFrequentlyUsedEmojis(emojiObject); - }, - [debouncedUpdateFrequentlyUsedEmojis], - ); - /** * Add a new comment to this chat * @@ -249,7 +231,7 @@ function ReportActionCompose({ // We need to make sure an empty draft gets saved instead debouncedSaveReportComment.cancel(); - const newComment = prepareCommentAndResetComposer(); + const newComment = composerRef.current.prepareCommentAndResetComposer(); if (!newComment) { return; } @@ -334,6 +316,7 @@ function ReportActionCompose({ onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> { + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current)); + insertedEmojisRef.current = []; + }, []); + + const onInsertedEmoji = useCallback( + (emojiObject) => { + insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; + debouncedUpdateFrequentlyUsedEmojis(emojiObject); + }, + [debouncedUpdateFrequentlyUsedEmojis], + ); + /** * Set the TextInput Ref * @@ -182,8 +206,8 @@ function SoloComposer({ if (!_.isEmpty(emojis)) { User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); - // TODO: insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; - // TODO: debouncedUpdateFrequentlyUsedEmojis(); + insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; + debouncedUpdateFrequentlyUsedEmojis(); } setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); @@ -216,7 +240,7 @@ function SoloComposer({ debouncedBroadcastUserIsTyping(reportID); } }, - [preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty], + [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty], ); /** @@ -428,6 +452,15 @@ function SoloComposer({ focus(); }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); + useImperativeHandle( + forwardedRef, + () => ({ + focus, + prepareCommentAndResetComposer, + }), + [focus, prepareCommentAndResetComposer], + ); + return ( <> @@ -443,6 +476,7 @@ function SoloComposer({ onKeyPress={triggerHotkeyActions} style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxComposerLines} + // TODO: would it be cleaner to forward onFocus and onBlur functions? onFocus={() => setIsFocused(true)} onBlur={() => { setIsFocused(false); @@ -481,24 +515,31 @@ function SoloComposer({ ); } +const SoloComposerRefForwardingComponent = React.forwardRef((props, ref) => ( + +)); + SoloComposer.propTypes = propTypes; SoloComposer.defaultProps = defaultProps; @@ -523,4 +564,4 @@ export default compose( canEvict: false, }, }), -)(SoloComposer); +)(SoloComposerRefForwardingComponent); From 3ee30c934377a936b07b864ad9fdecc1fa7a40b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 09:23:09 +0200 Subject: [PATCH 32/85] fix show popover menu --- .../ReportActionCompose.js | 66 +++++++++---------- ...er.js => ReportComposerWithSuggestions.js} | 26 ++++++-- 2 files changed, 51 insertions(+), 41 deletions(-) rename src/pages/home/report/ReportActionCompose/{SoloComposer.js => ReportComposerWithSuggestions.js} (96%) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6843caa06ad..946c6eb4d37 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -24,18 +24,17 @@ import EmojiPickerButton from '../../../../components/EmojiPicker/EmojiPickerBut import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import OfflineIndicator from '../../../../components/OfflineIndicator'; import ExceededCommentLength from '../../../../components/ExceededCommentLength'; -import * as EmojiUtils from '../../../../libs/EmojiUtils'; import ReportDropUI from '../ReportDropUI'; import reportPropTypes from '../../../reportPropTypes'; import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as Welcome from '../../../../libs/actions/Welcome'; import withAnimatedRef from '../../../../components/withAnimatedRef'; -import Suggestions from './Suggestions'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import SoloComposer from './SoloComposer'; +import ReportComposerWithSuggestions from './ReportComposerWithSuggestions'; import debouncedSaveReportComment from './debouncedSaveReportComment'; import withWindowDimensions from '../../../../components/withWindowDimensions'; +import withNavigation, {withNavigationPropTypes} from '../../../../components/withNavigation'; const propTypes = { /** A method to call when the form is submitted */ @@ -75,6 +74,7 @@ const propTypes = { animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, ...withLocalizePropTypes, + ...withNavigationPropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -111,6 +111,7 @@ function ReportActionCompose({ shouldShowComposeInput, translate, isCommentEmpty: isCommentEmptyProp, + navigation, }) { /** * Updates the Highlight state of the composer @@ -171,19 +172,6 @@ function ReportActionCompose({ return translate('reportActionCompose.writeSomething'); }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - const showPopoverMenu = useMemo( - () => - _.debounce(() => { - setMenuVisibility(true); - return true; - }), - [], - ); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (!suggestionsRef.current) { return; @@ -250,23 +238,32 @@ function ReportActionCompose({ suggestionsRef.current.setShouldBlockEmojiCalc(true); }, []); - // TODO: migrate me - // useEffect(() => { - // updateComment(commentRef.current); + /** + * Used to show Popover menu on Workspace chat at first sign-in + * @returns {Boolean} + */ + const showPopoverMenu = useMemo( + () => + _.debounce(() => { + setMenuVisibility(true); + return true; + }), + [], + ); + + useEffect(() => { + // Shows Popover Menu on Workspace Chat at first sign-in + if (disabled) { + return; + } - // // Shows Popover Menu on Workspace Chat at first sign-in - // if (!disabled) { - // Welcome.show({ - // routes: lodashGet(navigation.getState(), 'routes', []), - // showPopoverMenu, - // }); - // } + Welcome.show({ + routes: lodashGet(navigation.getState(), 'routes', []), + showPopoverMenu, + }); - // if (comment.length !== 0) { - // Report.setReportWithDraft(reportID, true); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Prevents focusing and showing the keyboard while the drawer is covering the chat. const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; @@ -315,7 +312,7 @@ function ReportActionCompose({ isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} /> - focus(true)} - onEmojiSelected={() => replaceSelectionWithText} + onModalHide={() => composerRef.current.focus(true)} + onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)} /> )} { + // TODO: I don't know why this line is needed, it just feels wrong + updateComment(commentRef.current); + + // TODO: NOTE, this was changed from comment.length to value.length + if (value.length !== 0) { + Report.setReportWithDraft(reportID, true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useImperativeHandle( forwardedRef, () => ({ focus, + replaceSelectionWithText, prepareCommentAndResetComposer, }), - [focus, prepareCommentAndResetComposer], + [focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); return ( @@ -532,16 +544,16 @@ function SoloComposer({ ); } -const SoloComposerRefForwardingComponent = React.forwardRef((props, ref) => ( - ( + )); -SoloComposer.propTypes = propTypes; -SoloComposer.defaultProps = defaultProps; +ReportComposerWithSuggestions.propTypes = propTypes; +ReportComposerWithSuggestions.defaultProps = defaultProps; export default compose( withLocalize, @@ -564,4 +576,4 @@ export default compose( canEvict: false, }, }), -)(SoloComposerRefForwardingComponent); +)(RefForwardingComponent); From 557c8d05cb5a2a7b69c83f72f787f779647d515b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 09:31:01 +0200 Subject: [PATCH 33/85] enable ExceededCommentLength again --- src/components/ExceededCommentLength.js | 22 ++++++++++++++++--- .../ReportActionCompose.js | 7 +++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index c403aa63c17..88fd250082c 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -1,19 +1,29 @@ import React, {useEffect, useState, useMemo} from 'react'; import PropTypes from 'prop-types'; import {debounce} from 'lodash'; +import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import * as ReportUtils from '../libs/ReportUtils'; import Text from './Text'; import styles from '../styles/styles'; +import ONYXKEYS from '../ONYXKEYS'; const propTypes = { + /** Report ID to get the comment from */ + // eslint-disable-next-line react/no-unused-prop-types + reportID: PropTypes.number.isRequired, + /** Text Comment */ - comment: PropTypes.string.isRequired, + comment: PropTypes.string, /** Update UI on parent when comment length is exceeded */ onExceededMaxCommentLength: PropTypes.func.isRequired, }; +const defaultProps = { + comment: '', +}; + function ExceededCommentLength(props) { const [commentLength, setCommentLength] = useState(0); const updateCommentLength = useMemo( @@ -38,5 +48,11 @@ function ExceededCommentLength(props) { } ExceededCommentLength.propTypes = propTypes; - -export default ExceededCommentLength; +ExceededCommentLength.defaultProps = defaultProps; +ExceededCommentLength.displayName = 'ExceededCommentLength'; + +export default withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, +})(ExceededCommentLength); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 946c6eb4d37..b4f3561981a 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -371,11 +371,10 @@ function ReportActionCompose({ > {!isSmallScreenWidth && } - {/* TODO: Maybe subscribe this to comment on its own? */} - {/* */} + /> From 3f591e211544d2859f33dce0b769718350507ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 09:50:00 +0200 Subject: [PATCH 34/85] forward reportActions --- .../ReportActionCompose.js | 30 +++++++++++++------ .../ReportComposerWithSuggestions.js | 17 +++++------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index b4f3561981a..e5a360960ed 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -10,7 +10,6 @@ import * as Report from '../../../../libs/actions/Report'; import ReportTypingIndicator from '../ReportTypingIndicator'; import AttachmentModal from '../../../../components/AttachmentModal'; import compose from '../../../../libs/compose'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import CONST from '../../../../CONST'; @@ -35,6 +34,8 @@ import ReportComposerWithSuggestions from './ReportComposerWithSuggestions'; import debouncedSaveReportComment from './debouncedSaveReportComment'; import withWindowDimensions from '../../../../components/withWindowDimensions'; import withNavigation, {withNavigationPropTypes} from '../../../../components/withNavigation'; +import reportActionPropTypes from '../reportActionPropTypes'; +import useLocalize from '../../../../hooks/useLocalize'; const propTypes = { /** A method to call when the form is submitted */ @@ -43,6 +44,9 @@ const propTypes = { /** The ID of the report actions will be created for */ reportID: PropTypes.string.isRequired, + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + /** Personal details of all the users */ personalDetails: PropTypes.objectOf(participantPropTypes), @@ -73,7 +77,6 @@ const propTypes = { /** animated ref from react-native-reanimated */ animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, - ...withLocalizePropTypes, ...withNavigationPropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -108,11 +111,13 @@ function ReportActionCompose({ personalDetails, report, reportID, + reportActions, shouldShowComposeInput, - translate, isCommentEmpty: isCommentEmptyProp, navigation, }) { + const {translate} = useLocalize(); + /** * Updates the Highlight state of the composer */ @@ -129,8 +134,6 @@ function ReportActionCompose({ * Updates the visibility state of the menu */ const [isMenuVisible, setMenuVisibility] = useState(false); - - const [composerHeight, setComposerHeight] = useState(0); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); /** @@ -238,6 +241,15 @@ function ReportActionCompose({ suggestionsRef.current.setShouldBlockEmojiCalc(true); }, []); + const onBlur = useCallback(() => { + setIsFocused(false); + suggestionsRef.current.resetSuggestions(); + }, []); + + const onFocus = useCallback(() => { + setIsFocused(true); + }, []); + /** * Used to show Popover menu on Workspace chat at first sign-in * @returns {Boolean} @@ -314,10 +326,11 @@ function ReportActionCompose({ /> { @@ -385,7 +398,6 @@ ReportActionCompose.propTypes = propTypes; ReportActionCompose.defaultProps = defaultProps; export default compose( - withLocalize, withNetwork(), withNavigation, withWindowDimensions, diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index a7609b7cf44..f1522be9bb9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -108,12 +108,14 @@ function ReportComposerWithSuggestions({ reportID, report, reportActions, + // Focus + onFocus, + onBlur, // Unclassified isComposerFullSize, animatedRef, isMenuVisible, inputPlaceholder, - setIsFocused, suggestionsRef, displayFileInModal, textInputShouldClear, @@ -122,8 +124,6 @@ function ReportComposerWithSuggestions({ disabled, isFullSizeComposerAvailable, setIsFullComposerAvailable, - composerHeight, - setComposerHeight, setIsCommentEmpty, submitForm, shouldShowReportRecipientLocalTime, @@ -149,8 +149,9 @@ function ReportComposerWithSuggestions({ end: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, }); - const textInputRef = useRef(null); + const [composerHeight, setComposerHeight] = useState(0); + const textInputRef = useRef(null); const insertedEmojisRef = useRef([]); /** @@ -488,12 +489,8 @@ function ReportComposerWithSuggestions({ onKeyPress={triggerHotkeyActions} style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxComposerLines} - // TODO: would it be cleaner to forward onFocus and onBlur functions? - onFocus={() => setIsFocused(true)} - onBlur={() => { - setIsFocused(false); - suggestionsRef.current.resetSuggestions(); - }} + onFocus={onFocus} + onBlur={onBlur} onClick={updateShouldShowSuggestionMenuToFalse} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} From 52fc6e8b3fd4c2085f2f98327cf545dcf40a6ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 09:59:33 +0200 Subject: [PATCH 35/85] cleanup forward ref code according to common pattern --- .../ReportComposerWithSuggestions.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index f1522be9bb9..4f34f32e1ef 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -541,14 +541,6 @@ function ReportComposerWithSuggestions({ ); } -const RefForwardingComponent = React.forwardRef((props, ref) => ( - -)); - ReportComposerWithSuggestions.propTypes = propTypes; ReportComposerWithSuggestions.defaultProps = defaultProps; @@ -573,4 +565,12 @@ export default compose( canEvict: false, }, }), -)(RefForwardingComponent); +)( + React.forwardRef((props, ref) => ( + + )), +); From 040688cb50d5eaaf0c391d8cefdd8e65c19e089e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 10:38:20 +0200 Subject: [PATCH 36/85] clean --- .../home/report/ReportActionCompose/ReportActionCompose.js | 5 ++--- .../ReportActionCompose/ReportComposerWithSuggestions.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index e5a360960ed..53daac2635d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -280,7 +280,7 @@ function ReportActionCompose({ // Prevents focusing and showing the keyboard while the drawer is covering the chat. const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; - const isFullSizeComposerAvailable = isFullComposerAvailable; // && !_.isEmpty(value); + const isFullSizeComposerAvailable = isFullComposerAvailable; // && !_.isEmpty(value); // TODO: fix this somehow again const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; @@ -327,14 +327,13 @@ function ReportActionCompose({ Date: Fri, 11 Aug 2023 11:56:57 +0200 Subject: [PATCH 37/85] Apply fixes from origin PR --- .../ReportActionCompose/ReportComposerWithSuggestions.js | 2 +- src/pages/home/report/ReportActionCompose/SendButton.js | 2 +- src/pages/home/report/ReportActionCompose/SuggestionEmoji.js | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 26f63b92f37..c7c07df423a 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -446,7 +446,7 @@ function ReportComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!willBlurTextInputOnTapOutside || modal.isVisible || !isFocusedProp || prevIsModalVisible || !prevIsFocused) { + if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { return; } diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index a7fe3a19d28..81687a9d3da 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -56,7 +56,7 @@ function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, style={({pressed, isDisabled}) => [ styles.chatItemSubmitButton, isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabled && styles.cursorDisabled, + isDisabledProp ? styles.cursorDisabled : undefined, ]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('common.send')} diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 359cdf13c84..5291d29736a 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -76,7 +76,7 @@ function SuggestionEmoji({ const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; - const [highlightedEmojiIndex] = useArrowKeyFocusManager({ + const [highlightedEmojiIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isEmojiSuggestionsMenuVisible, maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, @@ -191,8 +191,9 @@ function SuggestionEmoji({ } setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedMentionIndex(0); }, - [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale], + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedMentionIndex], ); const onSelectionChange = useCallback( From 0b5f770324ac6ceac4653cd6b2c3c15b94e68632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 14:21:13 +0200 Subject: [PATCH 38/85] fix update component updating too often --- .../ReportComposerWithSuggestions.js | 15 ++++++++------- .../report/ReportActionCompose/UpdateComment.js | 8 ++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index c7c07df423a..189a482a365 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -514,13 +514,6 @@ function ReportComposerWithSuggestions({ }} onScroll={updateShouldShowSuggestionMenuToFalse} /> - + + ); } diff --git a/src/pages/home/report/ReportActionCompose/UpdateComment.js b/src/pages/home/report/ReportActionCompose/UpdateComment.js index fdecedf3d5b..785503eb5a0 100644 --- a/src/pages/home/report/ReportActionCompose/UpdateComment.js +++ b/src/pages/home/report/ReportActionCompose/UpdateComment.js @@ -39,12 +39,12 @@ const defaultProps = { function UpdateComment({comment, commentRef, preferredLocale, report, value, updateComment}) { const prevCommentProp = usePrevious(comment); const prevPreferredLocale = usePrevious(preferredLocale); - const prevReportId = usePrevious(report.reportId); + const prevReportId = usePrevious(report.reportID); useEffect(() => { // Value state does not have the same value as comment props when the comment gets changed from another tab. // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevCommentProp !== comment && value === comment; + const shouldSyncComment = prevCommentProp !== comment && value !== comment; // As the report IDs change, make sure to update the composer comment as we need to make sure // we do not show incorrect data in there (ie. draft of message from other report). @@ -52,8 +52,8 @@ function UpdateComment({comment, commentRef, preferredLocale, report, value, upd return; } - // TODO: Why commentRef? Can't we also use comment here? - updateComment(commentRef.current); + console.log('UpdateComment.js: Updating from', comment, 'to comment', commentRef.current); + updateComment(comment); }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]); return null; From 5b9336dbe5baac5ca9989244014e45ffee908d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 17:31:06 +0200 Subject: [PATCH 39/85] fix crash --- .../report/ReportActionCompose/AttachmentPickerWithMenuItems.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 65db434981e..e2adf235dd0 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -2,13 +2,13 @@ import React, {useRef, useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; +import _ from 'underscore'; import styles from '../../../../styles/styles'; import Icon from '../../../../components/Icon'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import PopoverMenu from '../../../../components/PopoverMenu'; -import willBlurTextInputOnTapOutsideFunc from '../../../../libs/willBlurTextInputOnTapOutside'; import CONST from '../../../../CONST'; import Tooltip from '../../../../components/Tooltip'; import * as Browser from '../../../../libs/Browser'; From 5b61e127de25ac20a858ea48ee466e6501bbb8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:33:22 +0200 Subject: [PATCH 40/85] reapply https://github.com/Expensify/App/pull/18648/commits/8554863faf5d041bd6532eb8979eeafc252d3ea9 --- .../ReportActionCompose/ReportComposerWithSuggestions.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 189a482a365..1b319b36bb8 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -410,6 +410,11 @@ function ReportComposerWithSuggestions({ return; } + // If the space key is pressed, do not focus + if (e.code === 'Space') { + return; + } + // if we're typing on another input/text area, do not focus if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { return; From af6d104d7eb99904fa64b6276c76e47d44a4e961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:34:36 +0200 Subject: [PATCH 41/85] reapply https://github.com/Expensify/App/pull/18648/commits/e209754a3a819930afa8844eeb358bc309c30755 --- .../home/report/ReportActionCompose/SuggestionEmoji.js | 6 +++--- .../home/report/ReportActionCompose/SuggestionMention.js | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 5291d29736a..170c5d2915a 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -76,7 +76,7 @@ function SuggestionEmoji({ const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowEmojiSuggestionMenu; - const [highlightedEmojiIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ + const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({ isActive: isEmojiSuggestionsMenuVisible, maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, @@ -191,9 +191,9 @@ function SuggestionEmoji({ } setSuggestionValues((prevState) => ({...prevState, ...nextState})); - setHighlightedMentionIndex(0); + setHighlightedEmojiIndex(0); }, - [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedMentionIndex], + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex], ); const onSelectionChange = useCallback( diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index bf0193dd3f8..fc63a747f7f 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -51,7 +51,7 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; - const [highlightedMentionIndex] = useArrowKeyFocusManager({ + const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, @@ -216,8 +216,9 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue ...prevState, ...nextState, })); + setHighlightedMentionIndex(0); }, - [getMentionOptions, personalDetails, value], + [getMentionOptions, personalDetails, setHighlightedMentionIndex, value], ); const onSelectionChange = useCallback( From 45947ddc61775ddf77f25977ed6b2905c5a14540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:36:35 +0200 Subject: [PATCH 42/85] reapply https://github.com/Expensify/App/pull/18648/commits/267188322403c5b29bd7b8d746f4c04ffe7d1b31 --- .../report/ReportActionCompose/AttachmentPickerWithMenuItems.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index e2adf235dd0..68842222b4f 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -235,7 +235,7 @@ function AttachmentPickerWithMenuItems({ // In order for the file picker to open dynamically, the click // function must be called from within a event handler that was initiated // by the user on Safari. - if (index === menuItems.length - 1) { + if (index === menuItems.length - 1 && Browser.isSafari()) { triggerAttachmentPicker(); } }} From eaa3440aa06c27791adc21067ab9ef54dba46358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:37:10 +0200 Subject: [PATCH 43/85] reapply https://github.com/Expensify/App/pull/18648/commits/479f3171405681da9d05c81d040214b361eadb7b --- .../home/report/ReportActionCompose/ReportActionCompose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 53daac2635d..dc206028390 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -201,7 +201,7 @@ function ReportActionCompose({ /** * Event handler to update the state after the attachment preview is closed. */ - const attachmentPreviewClosed = useCallback(() => { + const onAttachmentPreviewClose = useCallback(() => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); }, [updateShouldShowSuggestionMenuToFalse]); @@ -306,7 +306,7 @@ function ReportActionCompose({ headerTitle={translate('reportActionCompose.sendAttachment')} onConfirm={addAttachment} onModalShow={() => setIsAttachmentPreviewActive(true)} - onModalHide={attachmentPreviewClosed} + onModalHide={onAttachmentPreviewClose} > {({displayFileInModal}) => ( <> From 1641dc0c4b6d130ea0c4ce352bf6c3e0614cb70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:37:32 +0200 Subject: [PATCH 44/85] reapply https://github.com/Expensify/App/pull/18648/commits/cd6d115eaf4b0036db007673d6b3a94cee7082c1 --- src/pages/home/report/ReportActionCompose/ReportActionCompose.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index dc206028390..b0862f992c7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -277,7 +277,6 @@ function ReportActionCompose({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Prevents focusing and showing the keyboard while the drawer is covering the chat. const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; const isFullSizeComposerAvailable = isFullComposerAvailable; // && !_.isEmpty(value); // TODO: fix this somehow again From d76106bf58aa276db0771f568db1604ad5bc8dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:37:57 +0200 Subject: [PATCH 45/85] reapply https://github.com/Expensify/App/pull/18648/commits/a96be820f5d181c1521411bd941263304d98796f --- .../report/ReportActionCompose/ReportComposerWithSuggestions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 1b319b36bb8..6eac05757af 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -206,7 +206,6 @@ function ReportComposerWithSuggestions({ const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { - User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; debouncedUpdateFrequentlyUsedEmojis(); } From f174b04ab6411ddb6efbc74cbd606eee2a2357f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:38:24 +0200 Subject: [PATCH 46/85] reapply https://github.com/Expensify/App/pull/18648/commits/dc4ceeaa238757a2bf9e2fb523821ffb9997d29f --- src/pages/home/report/ReportActionCompose/SuggestionEmoji.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 170c5d2915a..b1a74b9f2f5 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -90,9 +90,9 @@ function SuggestionEmoji({ * @param {Number} selectedEmoji */ const insertSelectedEmoji = useCallback( - (selectedEmoji) => { + (highlightedEmojiIndexInner) => { const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const emojiObject = suggestionValues.suggestedEmojis[selectedEmoji]; + const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner]; const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); From 066f980d9d022eed90b416fe08d3f625c11716dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 15 Aug 2023 17:38:57 +0200 Subject: [PATCH 47/85] reapply https://github.com/Expensify/App/pull/18648/commits/a6c309e463cc376cb52c0221f21748778f510485 --- .../ReportActionCompose/ReportActionCompose.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index b0862f992c7..d181ca2b37f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -254,14 +254,10 @@ function ReportActionCompose({ * Used to show Popover menu on Workspace chat at first sign-in * @returns {Boolean} */ - const showPopoverMenu = useMemo( - () => - _.debounce(() => { - setMenuVisibility(true); - return true; - }), - [], - ); + const showPopoverMenu = useCallback(() => { + setMenuVisibility(true); + return true; + }, []); useEffect(() => { // Shows Popover Menu on Workspace Chat at first sign-in From baa5e8bb95fa390ffc68914940bb1c64662b76f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 12:52:16 +0200 Subject: [PATCH 48/85] reapply https://github.com/Expensify/App/commit/6319e59c29bf7304654e4ede3c2215a6e65016bf --- .../report/ReportActionCompose/SuggestionEmoji.js | 13 ++++--------- .../report/ReportActionCompose/SuggestionMention.js | 13 ++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index b1a74b9f2f5..cb794f62558 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -165,8 +165,9 @@ function SuggestionEmoji({ */ const calculateEmojiSuggestion = useCallback( (selectionEnd) => { - if (shouldBlockCalc.current) { + if (shouldBlockCalc.current || !value) { shouldBlockCalc.current = false; + resetSuggestions(); return; } const leftString = value.substring(0, selectionEnd); @@ -193,17 +194,11 @@ function SuggestionEmoji({ setSuggestionValues((prevState) => ({...prevState, ...nextState})); setHighlightedEmojiIndex(0); }, - [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex], + [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], ); const onSelectionChange = useCallback( (e) => { - if (!value || e.nativeEvent.selection.end < 1) { - resetSuggestions(); - shouldBlockCalc.current = false; - return true; - } - /** * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion * because in other case calculateEmojiSuggestion will have an old calculation value @@ -211,7 +206,7 @@ function SuggestionEmoji({ */ calculateEmojiSuggestion(e.nativeEvent.selection.end); }, - [calculateEmojiSuggestion, resetSuggestions, value], + [calculateEmojiSuggestion], ); const setShouldBlockSuggestionCalc = useCallback( diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index fc63a747f7f..2f2e4b96d0b 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -171,8 +171,9 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue const calculateMentionSuggestion = useCallback( (selectionEnd) => { - if (shouldBlockCalc.current) { + if (shouldBlockCalc.current || selectionEnd < 1) { shouldBlockCalc.current = false; + resetSuggestions(); return; } @@ -218,20 +219,14 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue })); setHighlightedMentionIndex(0); }, - [getMentionOptions, personalDetails, setHighlightedMentionIndex, value], + [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value], ); const onSelectionChange = useCallback( (e) => { - if (!value || e.nativeEvent.selection.end < 1) { - resetSuggestions(); - shouldBlockCalc.current = false; - return true; - } - calculateMentionSuggestion(e.nativeEvent.selection.end); }, - [calculateMentionSuggestion, resetSuggestions, value], + [calculateMentionSuggestion], ); const updateShouldShowSuggestionMenuToFalse = useCallback(() => { From 893b7e2175a1c3d99df3b66bff889beb71898d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 14:01:57 +0200 Subject: [PATCH 49/85] fix isFullComposerAvailable --- .../ReportActionCompose/AttachmentPickerWithMenuItems.js | 8 +++++--- .../report/ReportActionCompose/ReportActionCompose.js | 6 +++--- .../ReportActionCompose/ReportComposerWithSuggestions.js | 6 ++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 68842222b4f..25605c9db53 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -71,6 +71,8 @@ const propTypes = { /** Called when opening the attachment picker */ onTriggerAttachmentPicker: PropTypes.func.isRequired, + + isFullComposerAvailable: PropTypes.bool.isRequired, }; const defaultProps = { @@ -83,7 +85,7 @@ function AttachmentPickerWithMenuItems({ report, reportParticipants, displayFileInModal, - isFullSizeComposerAvailable, + isFullComposerAvailable, isComposerFullSize, updateShouldShowSuggestionMenuToFalse, reportID, @@ -167,7 +169,7 @@ function AttachmentPickerWithMenuItems({ ]; return ( <> - + {isComposerFullSize && ( )} - {!isComposerFullSize && isFullSizeComposerAvailable && ( + {!isComposerFullSize && isFullComposerAvailable && ( { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index d181ca2b37f..df9542972e3 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -275,7 +275,7 @@ function ReportActionCompose({ const reportRecipient = personalDetails[participantsWithoutExpensifyAccountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; - const isFullSizeComposerAvailable = isFullComposerAvailable; // && !_.isEmpty(value); // TODO: fix this somehow again + const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; @@ -310,7 +310,7 @@ function ReportActionCompose({ reportID={reportID} report={report} reportParticipants={reportParticipants} - isFullSizeComposerAvailable={isFullSizeComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable} isComposerFullSize={isComposerFullSize} updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} isBlockedFromConcierge={isBlockedFromConcierge} @@ -334,7 +334,7 @@ function ReportActionCompose({ setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} disabled={disabled} - isFullSizeComposerAvailable={isFullSizeComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} setIsCommentEmpty={setIsCommentEmpty} submitForm={submitForm} diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 6eac05757af..eb2f2a0d98c 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -82,6 +82,8 @@ const propTypes = { /** The actions from the parent report */ parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + isFullComposerAvailable: PropTypes.bool.isRequired, + ...withLocalizePropTypes, ...keyboardStatePropTypes, }; @@ -122,7 +124,7 @@ function ReportComposerWithSuggestions({ setTextInputShouldClear, isBlockedFromConcierge, disabled, - isFullSizeComposerAvailable, + isFullComposerAvailable, setIsFullComposerAvailable, setIsCommentEmpty, submitForm, @@ -502,7 +504,7 @@ function ReportComposerWithSuggestions({ isDisabled={isBlockedFromConcierge || disabled} selection={selection} onSelectionChange={onSelectionChange} - isFullComposerAvailable={isFullSizeComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} isComposerFullSize={isComposerFullSize} value={value} From 6870d17e1baa9935267d46b58b7ff29828fda9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 14:30:53 +0200 Subject: [PATCH 50/85] fix initial isFocused state --- .../report/ReportActionCompose/ReportActionCompose.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index df9542972e3..f2027261510 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {View} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; +import Onyx, {withOnyx} from 'react-native-onyx'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; import * as Report from '../../../../libs/actions/Report'; @@ -121,7 +121,11 @@ function ReportActionCompose({ /** * Updates the Highlight state of the composer */ - const [isFocused, setIsFocused] = useState(shouldFocusInputOnScreenFocus && shouldShowComposeInput /* TODO: && !modal.isVisible && !modal.willAlertModalBecomeVisible */); + const [isFocused, setIsFocused] = useState(() => { + // TODO: its for discussion whether we want to use this pattern: https://expensify.slack.com/archives/C01GTK53T8Q/p1692620941376059 + const initialModalState = Onyx.tryGetCachedValue(ONYXKEYS.MODAL) || {}; + return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible; + }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); /** From f1398ce7a6fb63f85faaa1830e1500940fc877ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 14:37:42 +0200 Subject: [PATCH 51/85] Use tryGetCachedValue for draft comment --- .../ReportComposerWithSuggestions.js | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index eb2f2a0d98c..d58d83323db 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -54,15 +54,6 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); -// TODO: This needs to be moved to its own place -const draftCommentMap = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - callback: (val, key) => { - draftCommentMap[key] = val; - }, -}); - const propTypes = { /** A method to call when the form is submitted */ submitForm: PropTypes.func.isRequired, @@ -133,8 +124,8 @@ function ReportComposerWithSuggestions({ forwardedRef, }) { - const initialComment = draftCommentMap[reportID] || ''; - const commentRef = useRef(initialComment); + const [value, setValue] = useState(() => Onyx.tryGetCachedValue(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT + reportID) || ''); + const commentRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -142,14 +133,13 @@ function ReportComposerWithSuggestions({ const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; - const [value, setValue] = useState(initialComment); const valueRef = useRef(value); valueRef.current = value; - const [selection, setSelection] = useState({ - start: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, - end: isMobileSafari && !shouldAutoFocus ? 0 : initialComment.length, - }); + const [selection, setSelection] = useState(() => ({ + start: isMobileSafari && !shouldAutoFocus ? 0 : value.length, + end: isMobileSafari && !shouldAutoFocus ? 0 : value.length, + })); const [composerHeight, setComposerHeight] = useState(0); From ea6f00bc639d64b98db259d9c1a6fd56becf9673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 14:48:51 +0200 Subject: [PATCH 52/85] remove todo --- .../report/ReportActionCompose/ReportComposerWithSuggestions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index d58d83323db..61e598604a0 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -453,7 +453,6 @@ function ReportComposerWithSuggestions({ // TODO: I don't know why this line is needed, it just feels wrong updateComment(commentRef.current); - // TODO: NOTE, this was changed from comment.length to value.length. Does that break functionality? if (value.length !== 0) { Report.setReportWithDraft(reportID, true); } From 905c9b503bc018b6eb62c1378d7dbbdada39132b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 15:28:18 +0200 Subject: [PATCH 53/85] remove unneeded code --- .../ReportActionCompose/ReportComposerWithSuggestions.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 61e598604a0..adad772ab92 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -450,12 +450,11 @@ function ReportComposerWithSuggestions({ }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); useEffect(() => { - // TODO: I don't know why this line is needed, it just feels wrong - updateComment(commentRef.current); - if (value.length !== 0) { - Report.setReportWithDraft(reportID, true); + return; } + Report.setReportWithDraft(reportID, true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 10c55b303b42e21de7a5b6a0952f32bf66b88a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 16:55:23 +0200 Subject: [PATCH 54/85] move `personalDetails` to onyx --- .../ReportActionCompose/SuggestionMention.js | 28 +++++++++++-------- .../report/ReportActionCompose/Suggestions.js | 10 ++----- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 2f2e4b96d0b..44b606b7376 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,6 +1,7 @@ import React, {useState, useCallback, useRef, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import MentionSuggestions from '../../../../components/MentionSuggestions'; @@ -8,6 +9,7 @@ import * as UserUtils from '../../../../libs/UserUtils'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import useLocalize from '../../../../hooks/useLocalize'; +import ONYXKEYS from '../../../../ONYXKEYS'; /** * Check if this piece of string looks like a mention @@ -39,15 +41,13 @@ const propTypes = { composerHeight: PropTypes.number.isRequired, shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added - forwardedRef: PropTypes.object.isRequired, + forwardedRef: PropTypes.func.isRequired, }; -// TODO: split between emoji and mention suggestions function SuggestionMention({isComposerFullSize, personalDetails, value, setValue, setSelection, updateComment, composerHeight, shouldShowReportRecipientLocalTime, forwardedRef}) { const {translate} = useLocalize(); // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); - // TODO: const valueRef = usePrevious(value); (maybe even pass from parent?) const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; @@ -285,12 +285,16 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue SuggestionMention.propTypes = propTypes; -const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( - -)); - -export default SuggestionMentionWithRef; +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, +})( + React.forwardRef((props, ref) => ( + + )), +); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 714c3ff449a..b8c4a8a766b 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -9,7 +9,6 @@ const propTypes = { windowHeight: PropTypes.number.isRequired, isSmallScreenWidth: PropTypes.bool.isRequired, preferredLocale: PropTypes.string.isRequired, - personalDetails: PropTypes.object.isRequired, translate: PropTypes.func.isRequired, // Input value: PropTypes.string.isRequired, @@ -25,19 +24,17 @@ const propTypes = { composerHeight: PropTypes.number.isRequired, shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added - forwardedRef: PropTypes.object.isRequired, + forwardedRef: PropTypes.func.isRequired, onInsertedEmoji: PropTypes.func.isRequired, resetKeyboardInput: PropTypes.func.isRequired, }; -// TODO: split between emoji and mention suggestions function Suggestions({ isComposerFullSize, windowHeight, preferredLocale, isSmallScreenWidth, preferredSkinTone, - personalDetails, translate, value, setValue, @@ -118,7 +115,6 @@ function Suggestions({ windowHeight={windowHeight} isSmallScreenWidth={isSmallScreenWidth} preferredLocale={preferredLocale} - personalDetails={personalDetails} translate={translate} // Input value={value} @@ -139,12 +135,10 @@ function Suggestions({ Suggestions.propTypes = propTypes; -const SuggestionsWithRef = React.forwardRef((props, ref) => ( +export default React.forwardRef((props, ref) => ( )); - -export default SuggestionsWithRef; From f4b9079b7921c773466f5c180ee3983e9a871c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 18:14:55 +0200 Subject: [PATCH 55/85] fix mention suggestion picker size --- .../BaseAutoCompleteSuggestions.js | 4 ++ .../ReportActionCompose/SuggestionEmoji.js | 49 +++++++------------ .../ReportActionCompose/SuggestionMention.js | 29 +++++++++-- .../report/ReportActionCompose/Suggestions.js | 26 ++++------ 4 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index b4710f1f343..8bdde9941a3 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -73,6 +73,10 @@ function BaseAutoCompleteSuggestions(props) { const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, props.shouldIncludeReportRecipientLocalTimeHeight)); useEffect(() => { + console.log({ + length: props.suggestions.length, + isSuggestionPickerLarge: props.isSuggestionPickerLarge, + }); rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { duration: 100, easing: Easing.inOut(Easing.ease), diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index cb794f62558..11c55223661 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -8,9 +8,7 @@ import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; import EmojiSuggestions from '../../../../components/EmojiSuggestions'; import ONYXKEYS from '../../../../ONYXKEYS'; -import compose from '../../../../libs/compose'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import useLocalize from '../../../../hooks/useLocalize'; /** * Check if this piece of string looks like an emoji @@ -28,11 +26,11 @@ const defaultSuggestionsValues = { suggestedEmojis: [], colonSignIndex: -1, shouldShowSuggestionMenu: false, - mentionPrefix: '', - isAutoSuggestionPickerLarge: false, }; const propTypes = { + // Onyx + preferredSkinTone: PropTypes.number.isRequired, // Input value: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, @@ -44,33 +42,29 @@ const propTypes = { // Esoteric props isComposerFullSize: PropTypes.bool.isRequired, updateComment: PropTypes.func.isRequired, - composerHeight: PropTypes.number.isRequired, shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added - forwardedRef: PropTypes.object.isRequired, + forwardedRef: PropTypes.func.isRequired, resetKeyboardInput: PropTypes.func.isRequired, onInsertedEmoji: PropTypes.func.isRequired, - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, + /** Whether to use the small or the big suggestion picker */ + isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, }; function SuggestionEmoji({ isComposerFullSize, - windowHeight, - preferredLocale, - isSmallScreenWidth, preferredSkinTone, value, setValue, selection, setSelection, updateComment, - composerHeight, shouldShowReportRecipientLocalTime, forwardedRef, resetKeyboardInput, onInsertedEmoji, + isAutoSuggestionPickerLarge, }) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -78,10 +72,12 @@ function SuggestionEmoji({ const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({ isActive: isEmojiSuggestionsMenuVisible, - maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, suggestionValues.isAutoSuggestionPickerLarge), + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedEmojis.length, isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, }); + const {preferredLocale} = useLocalize(); + // Used to decide whether to block the suggestions list from showing to prevent flickering const shouldBlockCalc = useRef(false); @@ -174,15 +170,10 @@ function SuggestionEmoji({ const colonIndex = leftString.lastIndexOf(':'); const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); - // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 - const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; - const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - const nextState = { suggestedEmojis: [], colonIndex, shouldShowEmojiSuggestionMenu: false, - isAutoSuggestionPickerLarge, }; const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); @@ -194,7 +185,7 @@ function SuggestionEmoji({ setSuggestionValues((prevState) => ({...prevState, ...nextState})); setHighlightedEmojiIndex(0); }, - [value, windowHeight, composerHeight, isSmallScreenWidth, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], + [value, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], ); const onSelectionChange = useCallback( @@ -244,7 +235,7 @@ function SuggestionEmoji({ onSelect={insertSelectedEmoji} isComposerFullSize={isComposerFullSize} preferredSkinToneIndex={preferredSkinTone} - isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + isEmojiPickerLarge={isAutoSuggestionPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} /> ); @@ -260,13 +251,9 @@ const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( /> )); -export default compose( - withLocalize, - withWindowDimensions, - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, - }), -)(SuggestionEmojiWithRef); +export default withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, +})(SuggestionEmojiWithRef); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 44b606b7376..6f0f9dc652f 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -10,6 +10,7 @@ import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import useLocalize from '../../../../hooks/useLocalize'; import ONYXKEYS from '../../../../ONYXKEYS'; +import personalDetailsPropType from '../../../personalDetailsPropType'; /** * Check if this piece of string looks like a mention @@ -23,10 +24,11 @@ const defaultSuggestionsValues = { atSignIndex: -1, shouldShowSuggestionMenu: false, mentionPrefix: '', - isAutoSuggestionPickerLarge: false, }; const propTypes = { + // Onyx + personalDetails: PropTypes.objectOf(personalDetailsPropType), // Input value: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, @@ -42,9 +44,27 @@ const propTypes = { shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, // Custom added forwardedRef: PropTypes.func.isRequired, + + /** Whether to use the small or the big suggestion picker */ + isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, +}; + +const defaultProps = { + personalDetails: {}, }; -function SuggestionMention({isComposerFullSize, personalDetails, value, setValue, setSelection, updateComment, composerHeight, shouldShowReportRecipientLocalTime, forwardedRef}) { +function SuggestionMention({ + isComposerFullSize, + personalDetails, + value, + setValue, + setSelection, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + forwardedRef, + isAutoSuggestionPickerLarge, +}) { const {translate} = useLocalize(); // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -53,7 +73,7 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, - maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, suggestionValues.isAutoSuggestionPickerLarge), + maxIndex: SuggestionsUtils.getMaxArrowIndex(suggestionValues.suggestedMentions.length, isAutoSuggestionPickerLarge), shouldExcludeTextAreaNodes: false, }); @@ -276,7 +296,7 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue prefix={suggestionValues.mentionPrefix} onSelect={insertSelectedMention} isComposerFullSize={isComposerFullSize} - isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge} + isMentionPickerLarge={isAutoSuggestionPickerLarge} composerHeight={composerHeight} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} /> @@ -284,6 +304,7 @@ function SuggestionMention({isComposerFullSize, personalDetails, value, setValue } SuggestionMention.propTypes = propTypes; +SuggestionMention.defaultProps = defaultProps; export default withOnyx({ personalDetails: { diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index b8c4a8a766b..749d7566934 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -2,14 +2,9 @@ import React, {useRef, useCallback, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; import SuggestionMention from './SuggestionMention'; import SuggestionEmoji from './SuggestionEmoji'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; const propTypes = { - // Onyx/Hooks - preferredSkinTone: PropTypes.number.isRequired, - windowHeight: PropTypes.number.isRequired, - isSmallScreenWidth: PropTypes.bool.isRequired, - preferredLocale: PropTypes.string.isRequired, - translate: PropTypes.func.isRequired, // Input value: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, @@ -31,11 +26,6 @@ const propTypes = { function Suggestions({ isComposerFullSize, - windowHeight, - preferredLocale, - isSmallScreenWidth, - preferredSkinTone, - translate, value, setValue, selection, @@ -91,6 +81,12 @@ function Suggestions({ [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + + // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 + const hasEnoughSpaceForLargeSuggestion = windowHeight / composerHeight >= 6.8; + const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + return ( <> ); From 448d358b70dc087dcae0add4e5f6f73db522fdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 21 Aug 2023 18:25:14 +0200 Subject: [PATCH 56/85] fix crash opening attachment picker --- .../report/ReportActionCompose/ReportActionCompose.js | 2 +- src/pages/home/report/ReportActionCompose/Suggestions.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index f2027261510..18fe2ae3ed5 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -242,7 +242,7 @@ function ReportActionCompose({ if (!willBlurTextInputOnTapOutsideFunc) { return; } - suggestionsRef.current.setShouldBlockEmojiCalc(true); + suggestionsRef.current.setShouldBlockSuggestionCalc(true); }, []); const onBlur = useCallback(() => { diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 749d7566934..ff22478eb85 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -70,6 +70,11 @@ function Suggestions({ suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); }, []); + const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => { + suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock); + suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock); + }, []); + useImperativeHandle( forwardedRef, () => ({ @@ -77,8 +82,9 @@ function Suggestions({ onSelectionChange, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, + setShouldBlockSuggestionCalc, }), - [onSelectionChange, resetSuggestions, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse], ); const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); From 4968fb18ae335b04f03750e8dc8c1d7f3eb7a5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 14:07:17 +0200 Subject: [PATCH 57/85] move to existing pattern --- src/libs/ComposerUtils/getDraftComment.js | 20 +++++++++++++++++++ .../ReportComposerWithSuggestions.js | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/libs/ComposerUtils/getDraftComment.js diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js new file mode 100644 index 00000000000..d39a537dfe5 --- /dev/null +++ b/src/libs/ComposerUtils/getDraftComment.js @@ -0,0 +1,20 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +const draftCommentMap = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + callback: (value, key) => { + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ''); + draftCommentMap[reportID] = value; + }, +}); + +/** + * Returns a draft comment from the onyx collection. + * @param {String} reportID + * @returns {String|undefined} + */ +export default function getDraftComment(reportID) { + return draftCommentMap[reportID]; +} diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index adad772ab92..55d6fa19c50 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -32,6 +32,7 @@ import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFo import debouncedSaveReportComment from './debouncedSaveReportComment'; import UpdateComment from './UpdateComment'; import Suggestions from './Suggestions'; +import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; const {RNTextInputReset} = NativeModules; @@ -89,11 +90,11 @@ const defaultProps = { function ReportComposerWithSuggestions({ // Onyx modal, - preferredLocale, preferredSkinTone, parentReportActions, numberOfLines, // HOCs + preferredLocale, navigation, isKeyboardShown, isFocused: isFocusedProp, @@ -124,7 +125,7 @@ function ReportComposerWithSuggestions({ forwardedRef, }) { - const [value, setValue] = useState(() => Onyx.tryGetCachedValue(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT + reportID) || ''); + const [value, setValue] = useState(() => getDraftComment(reportID) || ''); const commentRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); From a3ea4987e5a0ec71a3c4a119d8f3c5033a8ad382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 14:10:42 +0200 Subject: [PATCH 58/85] migrate also the modal state to established pattern --- src/libs/ComposerUtils/getDraftComment.js | 2 ++ src/libs/getModalState.js | 21 +++++++++++++++++++ .../ReportActionCompose.js | 6 +++--- .../ReportComposerWithSuggestions.js | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/libs/getModalState.js diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js index d39a537dfe5..e6e09433c1a 100644 --- a/src/libs/ComposerUtils/getDraftComment.js +++ b/src/libs/ComposerUtils/getDraftComment.js @@ -12,6 +12,8 @@ Onyx.connect({ /** * Returns a draft comment from the onyx collection. + * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly. + * A valid use case to use this is if the value is only needed once for an initial value. * @param {String} reportID * @returns {String|undefined} */ diff --git a/src/libs/getModalState.js b/src/libs/getModalState.js new file mode 100644 index 00000000000..12023a5bdc4 --- /dev/null +++ b/src/libs/getModalState.js @@ -0,0 +1,21 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; + +let modalState = {}; + +Onyx.connect({ + key: ONYXKEYS.MODAL, + callback: (val) => { + modalState = val; + }, +}); + +/** + * Returns the modal state from onyx. + * Note: You should use the HOCs/hooks to get onyx data, instead of using this directly. + * A valid use case to use this is if the value is only needed once for an initial value. + * @returns {Object} + */ +export default function getModalState() { + return modalState; +} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 18fe2ae3ed5..94468153126 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {View} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; -import Onyx, {withOnyx} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; import * as Report from '../../../../libs/actions/Report'; @@ -36,6 +36,7 @@ import withWindowDimensions from '../../../../components/withWindowDimensions'; import withNavigation, {withNavigationPropTypes} from '../../../../components/withNavigation'; import reportActionPropTypes from '../reportActionPropTypes'; import useLocalize from '../../../../hooks/useLocalize'; +import getModalState from '../../../../libs/getModalState'; const propTypes = { /** A method to call when the form is submitted */ @@ -122,8 +123,7 @@ function ReportActionCompose({ * Updates the Highlight state of the composer */ const [isFocused, setIsFocused] = useState(() => { - // TODO: its for discussion whether we want to use this pattern: https://expensify.slack.com/archives/C01GTK53T8Q/p1692620941376059 - const initialModalState = Onyx.tryGetCachedValue(ONYXKEYS.MODAL) || {}; + const initialModalState = getModalState(); return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 55d6fa19c50..1ced6b54008 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -1,6 +1,6 @@ import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; import {View, InteractionManager, NativeModules, findNodeHandle, LayoutAnimation} from 'react-native'; -import Onyx, {withOnyx} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; From 7081e15c6f7d5e8b9976ce5f746076280b149372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 14:14:13 +0200 Subject: [PATCH 59/85] remove debug log --- .../AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index 8bdde9941a3..b4710f1f343 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -73,10 +73,6 @@ function BaseAutoCompleteSuggestions(props) { const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, props.shouldIncludeReportRecipientLocalTimeHeight)); useEffect(() => { - console.log({ - length: props.suggestions.length, - isSuggestionPickerLarge: props.isSuggestionPickerLarge, - }); rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { duration: 100, easing: Easing.inOut(Easing.ease), From 91d210c6aa8f44b6eadc2d2a2212624e9eeebf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 14:15:24 +0200 Subject: [PATCH 60/85] fix suggestion height position --- src/styles/StyleUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index fe910389c39..048b627d153 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -1061,7 +1061,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight, shouldIncludeRepor 'worklet'; const optionalPadding = shouldIncludeReportRecipientLocalTimeHeight ? CONST.RECIPIENT_LOCAL_TIME_HEIGHT : 0; - const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING - optionalPadding; + const padding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + optionalPadding; const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + borderWidth; From 8596920c0f29314460e5e7efff30be3bc5af3903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 14:50:47 +0200 Subject: [PATCH 61/85] fix suggestions not showing on clicking again into composer --- .../ReportActionCompose/ReportComposerWithSuggestions.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 1ced6b54008..20063bf63d0 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -339,6 +339,13 @@ function ReportComposerWithSuggestions({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, [suggestionsRef]); + const shouldBlockSuggestionCalc = useCallback(() => { + if (!suggestionsRef.current) { + return false; + } + return suggestionsRef.current.shouldBlockSuggestionCalc(); + }, [suggestionsRef]); + /** * Focus the composer text input * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer @@ -486,7 +493,7 @@ function ReportComposerWithSuggestions({ maxLines={maxComposerLines} onFocus={onFocus} onBlur={onBlur} - onClick={updateShouldShowSuggestionMenuToFalse} + onClick={shouldBlockSuggestionCalc} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} onClear={() => setTextInputShouldClear(false)} From 9d05dab0da37d205d260dd38d117019d7ce80817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 15:19:25 +0200 Subject: [PATCH 62/85] use hooks instead of HOCs --- .../ReportActionCompose.js | 23 ++++--------- .../ReportComposerWithSuggestions.js | 32 ++++++++----------- .../ReportActionCompose/UpdateComment.js | 31 +++++++++--------- 3 files changed, 35 insertions(+), 51 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 94468153126..f2b3fbc5bee 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -4,6 +4,8 @@ import {View} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; +import {useNavigation} from '@react-navigation/native'; +import {useAnimatedRef} from 'react-native-reanimated'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; import * as Report from '../../../../libs/actions/Report'; @@ -27,16 +29,14 @@ import ReportDropUI from '../ReportDropUI'; import reportPropTypes from '../../../reportPropTypes'; import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import * as Welcome from '../../../../libs/actions/Welcome'; -import withAnimatedRef from '../../../../components/withAnimatedRef'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ReportComposerWithSuggestions from './ReportComposerWithSuggestions'; import debouncedSaveReportComment from './debouncedSaveReportComment'; -import withWindowDimensions from '../../../../components/withWindowDimensions'; -import withNavigation, {withNavigationPropTypes} from '../../../../components/withNavigation'; import reportActionPropTypes from '../reportActionPropTypes'; import useLocalize from '../../../../hooks/useLocalize'; import getModalState from '../../../../libs/getModalState'; +import useWindowDimensions from '../../../../hooks/useWindowDimensions'; const propTypes = { /** A method to call when the form is submitted */ @@ -54,9 +54,6 @@ const propTypes = { /** The report currently being looked at */ report: reportPropTypes, - /** Is the window width narrow, like on a mobile device */ - isSmallScreenWidth: PropTypes.bool.isRequired, - /** Is composer full size */ isComposerFullSize: PropTypes.bool, @@ -75,10 +72,6 @@ const propTypes = { /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), - /** animated ref from react-native-reanimated */ - animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, - - ...withNavigationPropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -99,13 +92,10 @@ const defaultProps = { const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); function ReportActionCompose({ - animatedRef, blockedFromConcierge, currentUserPersonalDetails, disabled, isComposerFullSize, - isMediumScreenWidth, - isSmallScreenWidth, network, onSubmit, pendingAction, @@ -115,9 +105,11 @@ function ReportActionCompose({ reportActions, shouldShowComposeInput, isCommentEmpty: isCommentEmptyProp, - navigation, }) { const {translate} = useLocalize(); + const navigation = useNavigation(); + const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); + const animatedRef = useAnimatedRef(); /** * Updates the Highlight state of the composer @@ -397,10 +389,7 @@ ReportActionCompose.defaultProps = defaultProps; export default compose( withNetwork(), - withNavigation, - withWindowDimensions, withCurrentUserPersonalDetails, - withAnimatedRef, withOnyx({ isCommentEmpty: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js index 20063bf63d0..f6835958d30 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js @@ -4,6 +4,7 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; import styles from '../../../../styles/styles'; import themeColors from '../../../../styles/themes/default'; import Composer from '../../../../components/Composer'; @@ -22,17 +23,15 @@ import usePrevious from '../../../../hooks/usePrevious'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; import * as User from '../../../../libs/actions/User'; import * as ReportUtils from '../../../../libs/ReportUtils'; -import withNavigation from '../../../../components/withNavigation'; -import withNavigationFocus from '../../../../components/withNavigationFocus'; -import compose from '../../../../libs/compose'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; -import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/withKeyboardState'; import reportActionPropTypes from '../reportActionPropTypes'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import debouncedSaveReportComment from './debouncedSaveReportComment'; import UpdateComment from './UpdateComment'; import Suggestions from './Suggestions'; import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; +import useLocalize from '../../../../hooks/useLocalize'; +import compose from '../../../../libs/compose'; +import withKeyboardState from '../../../../components/withKeyboardState'; const {RNTextInputReset} = NativeModules; @@ -76,8 +75,7 @@ const propTypes = { isFullComposerAvailable: PropTypes.bool.isRequired, - ...withLocalizePropTypes, - ...keyboardStatePropTypes, + isKeyboardShown: PropTypes.bool.isRequired, }; const defaultProps = { @@ -94,10 +92,7 @@ function ReportComposerWithSuggestions({ parentReportActions, numberOfLines, // HOCs - preferredLocale, - navigation, isKeyboardShown, - isFocused: isFocusedProp, // Props: Report reportID, report, @@ -125,6 +120,10 @@ function ReportComposerWithSuggestions({ forwardedRef, }) { + const {preferredLocale} = useLocalize(); + const isFocused = useIsFocused(); + const navigation = useNavigation(); + const [value, setValue] = useState(() => getDraftComment(reportID) || ''); const commentRef = useRef(value); @@ -375,13 +374,13 @@ function ReportComposerWithSuggestions({ // This callback is used in the contextMenuActions to manage giving focus back to the compose input. // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component ReportActionComposeFocusManager.onComposerFocus(() => { - if (!willBlurTextInputOnTapOutside || !isFocusedProp) { + if (!willBlurTextInputOnTapOutside || !isFocused) { return; } focus(false); }); - }, [focus, isFocusedProp]); + }, [focus, isFocused]); /** * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. @@ -445,17 +444,17 @@ function ReportComposerWithSuggestions({ }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]); const prevIsModalVisible = usePrevious(modal.isVisible); - const prevIsFocused = usePrevious(isFocusedProp); + const prevIsFocused = usePrevious(isFocused); useEffect(() => { // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { + if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { return; } focus(); - }, [focus, prevIsFocused, prevIsModalVisible, isFocusedProp, modal.isVisible]); + }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible]); useEffect(() => { if (value.length !== 0) { @@ -548,9 +547,6 @@ ReportComposerWithSuggestions.propTypes = propTypes; ReportComposerWithSuggestions.defaultProps = defaultProps; export default compose( - withLocalize, - withNavigation, - withNavigationFocus, withKeyboardState, withOnyx({ numberOfLines: { diff --git a/src/pages/home/report/ReportActionCompose/UpdateComment.js b/src/pages/home/report/ReportActionCompose/UpdateComment.js index 785503eb5a0..593665cbdf3 100644 --- a/src/pages/home/report/ReportActionCompose/UpdateComment.js +++ b/src/pages/home/report/ReportActionCompose/UpdateComment.js @@ -3,16 +3,12 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import usePrevious from '../../../../hooks/usePrevious'; import ONYXKEYS from '../../../../ONYXKEYS'; -import compose from '../../../../libs/compose'; -import withLocalize from '../../../../components/withLocalize'; +import useLocalize from '../../../../hooks/useLocalize'; const propTypes = { /** The comment of the report */ comment: PropTypes.string, - /** The preferred locale of the user */ - preferredLocale: PropTypes.string.isRequired, - /** The report associated with the comment */ report: PropTypes.shape({ /** The ID of the report */ @@ -36,10 +32,17 @@ const defaultProps = { comment: '', }; -function UpdateComment({comment, commentRef, preferredLocale, report, value, updateComment}) { +/** + * This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions. + * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid + * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. + * @returns {null} + */ +function UpdateComment({comment, commentRef, report, value, updateComment}) { const prevCommentProp = usePrevious(comment); - const prevPreferredLocale = usePrevious(preferredLocale); const prevReportId = usePrevious(report.reportID); + const {preferredLocale} = useLocalize(); + const prevPreferredLocale = usePrevious(preferredLocale); useEffect(() => { // Value state does not have the same value as comment props when the comment gets changed from another tab. @@ -52,7 +55,6 @@ function UpdateComment({comment, commentRef, preferredLocale, report, value, upd return; } - console.log('UpdateComment.js: Updating from', comment, 'to comment', commentRef.current); updateComment(comment); }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]); @@ -63,11 +65,8 @@ UpdateComment.propTypes = propTypes; UpdateComment.defaultProps = defaultProps; UpdateComment.displayName = 'UpdateComment'; -export default compose( - withLocalize, - withOnyx({ - comment: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - }, - }), -)(UpdateComment); +export default withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, +})(UpdateComment); From 0b987fa17b52183bef262a56313b718b35ffb92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 15:24:37 +0200 Subject: [PATCH 63/85] remove debug code --- PERFORMANCE_AUDIT_LOG.md | 57 ---------------------------------------- web/index.html | 1 - 2 files changed, 58 deletions(-) delete mode 100644 PERFORMANCE_AUDIT_LOG.md diff --git a/PERFORMANCE_AUDIT_LOG.md b/PERFORMANCE_AUDIT_LOG.md deleted file mode 100644 index a8435584e29..00000000000 --- a/PERFORMANCE_AUDIT_LOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# Improve the peformance of the composer input - -## Problem / Reproduction - -- Run the desktop app -- Open the developer tools -- Go to performance, and set CPU throttling to 6x and Hardware Concurrency to 8x or 4x -- Open a chat and type something - -You will notice that the input is very badly lacking behind. - -## Findings log - -We start with working from the branch - -- `reapply-onyx-upgrade-use-cache-with-fixes` - -as it contains the Onyx cache fixes, which we want to have in place. - -One thing is certain is, that the composer will lag badly when we re-render the sidebar or the whole report screen. So we want to bring these down as well. - -I was measuring the performance with react devtools, and measured a single key press. -(Note: I put one letter already there, as putting a letter will mark the report as draft, which will cause updates to the SidebarLinks, which i wanted not to shadow the performance investigation for the composer for now). - -- ReportActionCompose re-renders: ~10x -- Composer re-renders: ~15x - -Those components are still class components. Our team has already rewritten them to function components. -I want to apply the performance optimizations to those FCs, so we don't have dupe work. -So i merged the following PRs in my branch: - -- https://github.com/Expensify/App/pull/18648 - - Same amount of re-renders after merging -- https://github.com/Expensify/App/pull/23359 - - Improved performance - -After mergint the PRs: - -- ReportActionCompose re-renders: ~6x -- Composer re-renders: ~8x - -I know want to check if i can even get the component to re-render less, afterwards i want to optimize the children to not re-render if not necessary. - -### Moving the suggestions out - -I figured that there are a lot of state updates just for the suggestions. -I am moving that to a new component. - -When testing to just remove the suggestion logic I get the following: - -- ReportActionCompose re-renders: ~4x -- Composer re-renders: ~6x - -### Loading further messages - -I just made the observation that when we open a new chat the scroll bar on the right gets smaller and smalle. THat probably means we are loading and rendering more and more messages. -I think we should just reduce the size of initially loaded messages, to improve performance. Because after that the chat input is stable. \ No newline at end of file diff --git a/web/index.html b/web/index.html index 93df6f9b38a..ea8cce7a691 100644 --- a/web/index.html +++ b/web/index.html @@ -126,7 +126,6 @@ - <% if (htmlWebpackPlugin.options.usePolyfillIO) { %> From 8e8d5c428068926717c77cdb32174d51b410c397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 15:28:15 +0200 Subject: [PATCH 64/85] rename ReportComposerWithSuggestions -> ComposerWithSuggestions --- ...Suggestions.js => ComposerWithSuggestions.js} | 16 ++++++++++++---- .../ReportActionCompose/ReportActionCompose.js | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) rename src/pages/home/report/ReportActionCompose/{ReportComposerWithSuggestions.js => ComposerWithSuggestions.js} (97%) diff --git a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js similarity index 97% rename from src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index f6835958d30..5e3e033fd94 100644 --- a/src/pages/home/report/ReportActionCompose/ReportComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -85,7 +85,15 @@ const defaultProps = { modal: {}, }; -function ReportComposerWithSuggestions({ +/** + * This component holds the value and selection state. + * If a component really needs access to these state values it should be put here. + * However, double check if the component really needs access, as it will re-render + * on every key press. + * @param {Object} props + * @returns {React.Component} + */ +function ComposerWithSuggestions({ // Onyx modal, preferredSkinTone, @@ -543,8 +551,8 @@ function ReportComposerWithSuggestions({ ); } -ReportComposerWithSuggestions.propTypes = propTypes; -ReportComposerWithSuggestions.defaultProps = defaultProps; +ComposerWithSuggestions.propTypes = propTypes; +ComposerWithSuggestions.defaultProps = defaultProps; export default compose( withKeyboardState, @@ -566,7 +574,7 @@ export default compose( }), )( React.forwardRef((props, ref) => ( - - Date: Tue, 22 Aug 2023 16:51:48 +0200 Subject: [PATCH 65/85] prop types --- .../AttachmentPickerWithMenuItems.js | 4 +- .../ComposerWithSuggestions.js | 111 +++++++++++++++--- .../ReportActionCompose/SuggestionEmoji.js | 40 ++++--- .../ReportActionCompose/SuggestionMention.js | 26 ++-- .../report/ReportActionCompose/Suggestions.js | 68 +++++------ .../ReportActionCompose/suggestionProps.js | 33 ++++++ 6 files changed, 189 insertions(+), 93 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/suggestionProps.js diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 25605c9db53..9927b7ec8ef 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -46,7 +46,7 @@ const propTypes = { displayFileInModal: PropTypes.func.isRequired, /** Whether or not the full size composer is available */ - isFullSizeComposerAvailable: PropTypes.bool.isRequired, + isFullComposerAvailable: PropTypes.bool.isRequired, /** Whether or not the composer is full size */ isComposerFullSize: PropTypes.bool.isRequired, @@ -71,8 +71,6 @@ const propTypes = { /** Called when opening the attachment picker */ onTriggerAttachmentPicker: PropTypes.func.isRequired, - - isFullComposerAvailable: PropTypes.bool.isRequired, }; const defaultProps = { diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 5e3e033fd94..3c8f525b6d3 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -32,6 +32,7 @@ import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; import useLocalize from '../../../../hooks/useLocalize'; import compose from '../../../../libs/compose'; import withKeyboardState from '../../../../components/withKeyboardState'; +import reportPropTypes from '../../../reportPropTypes'; const {RNTextInputReset} = NativeModules; @@ -55,34 +56,114 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); const propTypes = { - /** A method to call when the form is submitted */ - submitForm: PropTypes.func.isRequired, - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - /** Details about any modals being used */ modal: PropTypes.shape({ /** Indicates if there is a modal currently visible or not */ isVisible: PropTypes.bool, }), + /** User's preferred skin tone color */ + preferredSkinTone: PropTypes.number, + + /** Number of lines for the composer */ + numberOfLines: PropTypes.number, + + /** Whether the keyboard is open or not */ + isKeyboardShown: PropTypes.bool.isRequired, + /** The actions from the parent report */ parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** The ID of the report */ + reportID: PropTypes.number.isRequired, + + /** The report currently being looked at */ + report: PropTypes.shape(reportPropTypes).isRequired, + + /** Callback when the input is focused */ + onFocus: PropTypes.func.isRequired, + + /** Callback when the input is blurred */ + onBlur: PropTypes.func.isRequired, + + /** Whether the composer is full size or not */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Whether the menu is visible or not */ + isMenuVisible: PropTypes.bool.isRequired, + + /** Placeholder text for the input */ + inputPlaceholder: PropTypes.string.isRequired, + + /** Function to display a file in the modal */ + displayFileInModal: PropTypes.func.isRequired, + + /** Whether the text input should be cleared or not */ + textInputShouldClear: PropTypes.bool.isRequired, + + /** Function to set whether the text input should be cleared or not */ + setTextInputShouldClear: PropTypes.func.isRequired, + + /** Whether the user is blocked from concierge or not */ + isBlockedFromConcierge: PropTypes.bool.isRequired, + + /** Whether the input is disabled or not */ + disabled: PropTypes.bool.isRequired, + + /** Whether the full composer is available or not */ isFullComposerAvailable: PropTypes.bool.isRequired, - isKeyboardShown: PropTypes.bool.isRequired, + /** Function to set whether the full composer is available or not */ + setIsFullComposerAvailable: PropTypes.func.isRequired, + + /** Function to set whether the comment is empty or not */ + setIsCommentEmpty: PropTypes.func.isRequired, + + /** A method to call when the form is submitted */ + submitForm: PropTypes.func.isRequired, + + /** Whether the recipient local time is shown or not */ + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Whether the compose input is shown or not */ + shouldShowComposeInput: PropTypes.bool.isRequired, + + /** Ref for the suggestions component */ + suggestionsRef: PropTypes.shape({ + current: PropTypes.shape({ + /** Update the shouldShowSuggestionMenuToFalse prop */ + updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, + + /** Trigger hotkey actions */ + triggerHotkeyActions: PropTypes.func.isRequired, + + /** Check if suggestion calculation should be blocked */ + shouldBlockSuggestionCalc: PropTypes.func.isRequired, + + /** Update the comment */ + updateComment: PropTypes.func.isRequired, + + /** Callback when the selection changes */ + onSelectionChange: PropTypes.func.isRequired, + }), + }).isRequired, + + /** Ref for the animated view (text input) */ + animatedRef: PropTypes.func.isRequired, + + /** Ref for the composer */ + forwardedRef: PropTypes.func.isRequired, }; const defaultProps = { + modal: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, numberOfLines: undefined, parentReportActions: {}, reportActions: [], - modal: {}, }; /** @@ -108,12 +189,10 @@ function ComposerWithSuggestions({ // Focus onFocus, onBlur, - // Unclassified + // Composer isComposerFullSize, - animatedRef, isMenuVisible, inputPlaceholder, - suggestionsRef, displayFileInModal, textInputShouldClear, setTextInputShouldClear, @@ -125,7 +204,9 @@ function ComposerWithSuggestions({ submitForm, shouldShowReportRecipientLocalTime, shouldShowComposeInput, - + // Refs + suggestionsRef, + animatedRef, forwardedRef, }) { const {preferredLocale} = useLocalize(); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 11c55223661..18b62fdb4a5 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -9,6 +9,7 @@ import * as EmojiUtils from '../../../../libs/EmojiUtils'; import EmojiSuggestions from '../../../../components/EmojiSuggestions'; import ONYXKEYS from '../../../../ONYXKEYS'; import useLocalize from '../../../../hooks/useLocalize'; +import * as SuggestionProps from './suggestionProps'; /** * Check if this piece of string looks like an emoji @@ -29,42 +30,44 @@ const defaultSuggestionsValues = { }; const propTypes = { - // Onyx - preferredSkinTone: PropTypes.number.isRequired, - // Input - value: PropTypes.string.isRequired, - setValue: PropTypes.func.isRequired, + /** Preferred skin tone */ + preferredSkinTone: PropTypes.number, + + /** A ref to this component */ + forwardedRef: PropTypes.func.isRequired, + + /** Function to clear the input */ + resetKeyboardInput: PropTypes.func.isRequired, + + /** Callback when a emoji was inserted */ + onInsertedEmoji: PropTypes.func.isRequired, + + /** The current selection */ selection: PropTypes.shape({ start: PropTypes.number.isRequired, end: PropTypes.number.isRequired, }).isRequired, - setSelection: PropTypes.func.isRequired, - // Esoteric props - isComposerFullSize: PropTypes.bool.isRequired, - updateComment: PropTypes.func.isRequired, - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - // Custom added - forwardedRef: PropTypes.func.isRequired, - resetKeyboardInput: PropTypes.func.isRequired, - onInsertedEmoji: PropTypes.func.isRequired, - /** Whether to use the small or the big suggestion picker */ - isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, + ...SuggestionProps.baseProps, +}; + +const defaultProps = { + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, }; function SuggestionEmoji({ - isComposerFullSize, preferredSkinTone, value, setValue, selection, setSelection, updateComment, + isComposerFullSize, shouldShowReportRecipientLocalTime, + isAutoSuggestionPickerLarge, forwardedRef, resetKeyboardInput, onInsertedEmoji, - isAutoSuggestionPickerLarge, }) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -242,6 +245,7 @@ function SuggestionEmoji({ } SuggestionEmoji.propTypes = propTypes; +SuggestionEmoji.defaultProps = defaultProps; const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( = 6.8; const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + const baseProps = { + value, + setValue, + setSelection, + isComposerFullSize, + updateComment, + composerHeight, + shouldShowReportRecipientLocalTime, + isAutoSuggestionPickerLarge, + }; + return ( <> ); diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js new file mode 100644 index 00000000000..ad1ecd8f756 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; + +const baseProps = { + /** The current input value */ + value: PropTypes.string.isRequired, + + /** Callback to update the current input value */ + setValue: PropTypes.func.isRequired, + + /** Callback to update the current selection */ + setSelection: PropTypes.func.isRequired, + + /** Whether the composer is expanded */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Callback to update the comment draft */ + updateComment: PropTypes.func.isRequired, + + /** Flag whether we need to consider the participents */ + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Whether to use the small or the big suggestion picker */ + isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, +}; + +const implementationBaseProps = { + /** Whether to use the small or the big suggestion picker */ + isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, + + ...baseProps, +}; + +export {baseProps, implementationBaseProps}; From c02fdacdebcec2ac07d7582c3c26736b9beee830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 22 Aug 2023 17:22:46 +0200 Subject: [PATCH 66/85] fix tests --- src/libs/ComposerUtils/getDraftComment.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js index e6e09433c1a..ddcb966bb2a 100644 --- a/src/libs/ComposerUtils/getDraftComment.js +++ b/src/libs/ComposerUtils/getDraftComment.js @@ -5,6 +5,8 @@ const draftCommentMap = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, callback: (value, key) => { + if (!key) return; + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ''); draftCommentMap[reportID] = value; }, From cf5d835e9196f0d65dfa21cd4099f044513db1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 23 Aug 2023 08:04:05 +0200 Subject: [PATCH 67/85] apply changes to ReportActionCompose --- .../report/ReportActionCompose/ComposerWithSuggestions.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 3c8f525b6d3..3d81112896a 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -284,7 +284,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { - const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(commentValue, preferredSkinTone, preferredLocale); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; @@ -468,7 +468,7 @@ function ComposerWithSuggestions({ } focus(false); - }); + }, true); }, [focus, isFocused]); /** @@ -524,7 +524,7 @@ function ComposerWithSuggestions({ setUpComposeFocusManager(); return () => { - ReportActionComposeFocusManager.clear(); + ReportActionComposeFocusManager.clear(true); KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); unsubscribeNavigationBlur(); @@ -586,6 +586,7 @@ function ComposerWithSuggestions({ shouldClear={textInputShouldClear} onClear={() => setTextInputShouldClear(false)} isDisabled={isBlockedFromConcierge || disabled} + isReportActionCompose selection={selection} onSelectionChange={onSelectionChange} isFullComposerAvailable={isFullComposerAvailable} From ebd8e74af9647c60751e0d748ad31d89f97c6929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 23 Aug 2023 09:30:55 +0200 Subject: [PATCH 68/85] perf: don't update lines when we don't have to --- .../ReportActionCompose/ComposerWithSuggestions.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 3d81112896a..42ba2853c02 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -330,9 +330,11 @@ function ComposerWithSuggestions({ */ const updateNumberOfLines = useCallback( (newNumberOfLines) => { - Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + if (newNumberOfLines !== numberOfLines) { + Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + } }, - [reportID], + [reportID, numberOfLines], ); /** @@ -622,13 +624,13 @@ function ComposerWithSuggestions({ resetKeyboardInput={resetKeyboardInput} /> - + /> */} ); } From 28426ce3a5d48ffb9395c116ad115dceded6059e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 23 Aug 2023 11:01:52 +0200 Subject: [PATCH 69/85] change name --- .../ReportActionCompose/ComposerWithSuggestions.js | 6 +++--- .../{UpdateComment.js => SilentCommentUpdater.js} | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/pages/home/report/ReportActionCompose/{UpdateComment.js => SilentCommentUpdater.js} (90%) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 42ba2853c02..140516cc959 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -26,7 +26,7 @@ import * as ReportUtils from '../../../../libs/ReportUtils'; import reportActionPropTypes from '../reportActionPropTypes'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import debouncedSaveReportComment from './debouncedSaveReportComment'; -import UpdateComment from './UpdateComment'; +import SilentCommentUpdater from './SilentCommentUpdater'; import Suggestions from './Suggestions'; import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; import useLocalize from '../../../../hooks/useLocalize'; @@ -624,13 +624,13 @@ function ComposerWithSuggestions({ resetKeyboardInput={resetKeyboardInput} /> - {/* */} + /> ); } diff --git a/src/pages/home/report/ReportActionCompose/UpdateComment.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js similarity index 90% rename from src/pages/home/report/ReportActionCompose/UpdateComment.js rename to src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js index 593665cbdf3..7b489fd56b6 100644 --- a/src/pages/home/report/ReportActionCompose/UpdateComment.js +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js @@ -38,7 +38,7 @@ const defaultProps = { * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. * @returns {null} */ -function UpdateComment({comment, commentRef, report, value, updateComment}) { +function SilentCommentUpdater({comment, commentRef, report, value, updateComment}) { const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(report.reportID); const {preferredLocale} = useLocalize(); @@ -61,12 +61,12 @@ function UpdateComment({comment, commentRef, report, value, updateComment}) { return null; } -UpdateComment.propTypes = propTypes; -UpdateComment.defaultProps = defaultProps; -UpdateComment.displayName = 'UpdateComment'; +SilentCommentUpdater.propTypes = propTypes; +SilentCommentUpdater.defaultProps = defaultProps; +SilentCommentUpdater.displayName = 'SilentCommentUpdater'; export default withOnyx({ comment: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, }, -})(UpdateComment); +})(SilentCommentUpdater); From 95a01935e0723e18c7e6519372fe692d39b1ae6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 23 Aug 2023 11:48:47 +0200 Subject: [PATCH 70/85] fix prop type issues --- src/components/ExceededCommentLength.js | 4 +-- .../AttachmentPickerWithMenuItems.js | 25 ++++++++++--------- .../ComposerWithSuggestions.js | 20 +++++++-------- .../ReportActionCompose.js | 1 + .../SilentCommentUpdater.js | 2 +- .../ReportActionCompose/SuggestionEmoji.js | 2 +- .../ReportActionCompose/SuggestionMention.js | 2 +- .../report/ReportActionCompose/Suggestions.js | 2 +- .../ReportActionCompose/suggestionProps.js | 3 --- 9 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index 88fd250082c..2aa50779e10 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -9,9 +9,9 @@ import styles from '../styles/styles'; import ONYXKEYS from '../ONYXKEYS'; const propTypes = { - /** Report ID to get the comment from */ + /** Report ID to get the comment from (used in withOnyx) */ // eslint-disable-next-line react/no-unused-prop-types - reportID: PropTypes.number.isRequired, + reportID: PropTypes.string.isRequired, /** Text Comment */ comment: PropTypes.string, diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 9927b7ec8ef..66bf07dbe9b 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -28,19 +28,14 @@ const propTypes = { /** The report currently being looked at */ report: PropTypes.shape({ /** ID of the report */ - reportID: PropTypes.number, + reportID: PropTypes.string, /** Whether or not the report is in the process of being created */ loading: PropTypes.bool, }).isRequired, /** The personal details of everyone in the report */ - reportParticipants: PropTypes.objectOf( - PropTypes.shape({ - /** Display name of the participant */ - displayName: PropTypes.string, - }), - ), + reportParticipantIDs: PropTypes.arrayOf(PropTypes.number), /** Callback to open the file in the modal */ displayFileInModal: PropTypes.func.isRequired, @@ -67,7 +62,7 @@ const propTypes = { isMenuVisible: PropTypes.bool.isRequired, /** Report ID */ - reportID: PropTypes.number.isRequired, + reportID: PropTypes.string.isRequired, /** Called when opening the attachment picker */ onTriggerAttachmentPicker: PropTypes.func.isRequired, @@ -75,13 +70,19 @@ const propTypes = { const defaultProps = { betas: [], - reportParticipants: {}, + reportParticipantIDs: [], }; +/** + * This includes the popover of options you see when pressing the + button in the composer. + * It also contains the attachment picker, as the menu items need to be able to open it. + * + * @returns {React.Component} + */ function AttachmentPickerWithMenuItems({ betas, report, - reportParticipants, + reportParticipantIDs, displayFileInModal, isFullComposerAvailable, isComposerFullSize, @@ -117,11 +118,11 @@ function AttachmentPickerWithMenuItems({ }, }; - return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipants, betas), (option) => ({ + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipantIDs, betas), (option) => ({ ...options[option], onSelected: () => IOU.startMoneyRequest(option, report.reportID), })); - }, [betas, report, reportParticipants, translate]); + }, [betas, report, reportParticipantIDs, translate]); /** * Determines if we can show the task option diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 140516cc959..4580de42426 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -32,7 +32,6 @@ import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; import useLocalize from '../../../../hooks/useLocalize'; import compose from '../../../../libs/compose'; import withKeyboardState from '../../../../components/withKeyboardState'; -import reportPropTypes from '../../../reportPropTypes'; const {RNTextInputReset} = NativeModules; @@ -78,10 +77,12 @@ const propTypes = { reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), /** The ID of the report */ - reportID: PropTypes.number.isRequired, + reportID: PropTypes.string.isRequired, /** The report currently being looked at */ - report: PropTypes.shape(reportPropTypes).isRequired, + report: PropTypes.shape({ + parentReportID: PropTypes.string, + }).isRequired, /** Callback when the input is focused */ onFocus: PropTypes.func.isRequired, @@ -141,10 +142,7 @@ const propTypes = { triggerHotkeyActions: PropTypes.func.isRequired, /** Check if suggestion calculation should be blocked */ - shouldBlockSuggestionCalc: PropTypes.func.isRequired, - - /** Update the comment */ - updateComment: PropTypes.func.isRequired, + setShouldBlockSuggestionCalc: PropTypes.func.isRequired, /** Callback when the selection changes */ onSelectionChange: PropTypes.func.isRequired, @@ -155,7 +153,7 @@ const propTypes = { animatedRef: PropTypes.func.isRequired, /** Ref for the composer */ - forwardedRef: PropTypes.func.isRequired, + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), }; const defaultProps = { @@ -429,11 +427,11 @@ function ComposerWithSuggestions({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, [suggestionsRef]); - const shouldBlockSuggestionCalc = useCallback(() => { + const setShouldBlockSuggestionCalc = useCallback(() => { if (!suggestionsRef.current) { return false; } - return suggestionsRef.current.shouldBlockSuggestionCalc(); + return suggestionsRef.current.setShouldBlockSuggestionCalc(true); }, [suggestionsRef]); /** @@ -583,7 +581,7 @@ function ComposerWithSuggestions({ maxLines={maxComposerLines} onFocus={onFocus} onBlur={onBlur} - onClick={shouldBlockSuggestionCalc} + onClick={setShouldBlockSuggestionCalc} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} onClear={() => setTextInputShouldClear(false)} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index b0c9eb1e37c..9d596024c26 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -335,6 +335,7 @@ function ReportActionCompose({ setIsCommentEmpty={setIsCommentEmpty} submitForm={submitForm} shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} + shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} /> diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js index 7b489fd56b6..da5dc326d42 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js @@ -12,7 +12,7 @@ const propTypes = { /** The report associated with the comment */ report: PropTypes.shape({ /** The ID of the report */ - reportID: PropTypes.number, + reportID: PropTypes.string, }).isRequired, /** The value of the comment */ diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 18b62fdb4a5..d64d3f0e5b1 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -34,7 +34,7 @@ const propTypes = { preferredSkinTone: PropTypes.number, /** A ref to this component */ - forwardedRef: PropTypes.func.isRequired, + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), /** Function to clear the input */ resetKeyboardInput: PropTypes.func.isRequired, diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 0ff000ccb1c..9be0643ad01 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -32,7 +32,7 @@ const propTypes = { personalDetails: PropTypes.objectOf(personalDetailsPropType), /** A ref to this component */ - forwardedRef: PropTypes.func.isRequired, + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), ...SuggestionProps.implementationBaseProps, }; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 666325e0c6e..6330e422a30 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -7,7 +7,7 @@ import * as SuggestionProps from './suggestionProps'; const propTypes = { /** A ref to this component */ - forwardedRef: PropTypes.func.isRequired, + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), /** Callback when a emoji was inserted */ onInsertedEmoji: PropTypes.func.isRequired, diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index ad1ecd8f756..50a1d63e39d 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -18,9 +18,6 @@ const baseProps = { /** Flag whether we need to consider the participents */ shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - - /** Whether to use the small or the big suggestion picker */ - isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, }; const implementationBaseProps = { From 3e3c6ed3a3f02b769be4ed9b767d1f2bebc4e812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 23 Aug 2023 11:54:54 +0200 Subject: [PATCH 71/85] fix lint --- .../ReportActionCompose/AttachmentPickerWithMenuItems.js | 1 + .../report/ReportActionCompose/ComposerWithSuggestions.js | 7 +++++-- .../home/report/ReportActionCompose/SuggestionEmoji.js | 2 ++ .../home/report/ReportActionCompose/SuggestionMention.js | 2 ++ src/pages/home/report/ReportActionCompose/Suggestions.js | 6 ++++++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 66bf07dbe9b..bf232b17e9b 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -255,6 +255,7 @@ function AttachmentPickerWithMenuItems({ AttachmentPickerWithMenuItems.propTypes = propTypes; AttachmentPickerWithMenuItems.defaultProps = defaultProps; +AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems'; export default withOnyx({ betas: { diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 4580de42426..14edc886a97 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -162,6 +162,7 @@ const defaultProps = { numberOfLines: undefined, parentReportActions: {}, reportActions: [], + forwardedRef: null, }; /** @@ -328,9 +329,10 @@ function ComposerWithSuggestions({ */ const updateNumberOfLines = useCallback( (newNumberOfLines) => { - if (newNumberOfLines !== numberOfLines) { - Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); + if (newNumberOfLines === numberOfLines) { + return; } + Report.saveReportCommentNumberOfLines(reportID, newNumberOfLines); }, [reportID, numberOfLines], ); @@ -635,6 +637,7 @@ function ComposerWithSuggestions({ ComposerWithSuggestions.propTypes = propTypes; ComposerWithSuggestions.defaultProps = defaultProps; +ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; export default compose( withKeyboardState, diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index d64d3f0e5b1..fcca2e6c987 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -53,6 +53,7 @@ const propTypes = { const defaultProps = { preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + forwardedRef: null, }; function SuggestionEmoji({ @@ -246,6 +247,7 @@ function SuggestionEmoji({ SuggestionEmoji.propTypes = propTypes; SuggestionEmoji.defaultProps = defaultProps; +SuggestionEmoji.displayName = 'SuggestionEmoji'; const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( ( Date: Thu, 24 Aug 2023 08:21:27 +0200 Subject: [PATCH 72/85] Update src/pages/home/report/ReportActionCompose/suggestionProps.js Co-authored-by: Vit Horacek <36083550+mountiny@users.noreply.github.com> --- src/pages/home/report/ReportActionCompose/suggestionProps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 50a1d63e39d..279d2ff5daf 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -16,7 +16,7 @@ const baseProps = { /** Callback to update the comment draft */ updateComment: PropTypes.func.isRequired, - /** Flag whether we need to consider the participents */ + /** Flag whether we need to consider the participants */ shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, }; From 6ab1ccffb0eb05937b1cea73453936e57c035355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 08:21:52 +0200 Subject: [PATCH 73/85] Update src/pages/home/report/ReportActionCompose/SendButton.js Co-authored-by: Vit Horacek <36083550+mountiny@users.noreply.github.com> --- src/pages/home/report/ReportActionCompose/SendButton.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index 81687a9d3da..4f1dc5fff19 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -38,7 +38,7 @@ function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, const viewTag = animatedRef(); const viewName = 'RCTMultilineTextInputView'; const updates = {text: ''}; - // we are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state + // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state runOnJS(setIsCommentEmpty)(true); updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread runOnJS(submitForm)(); From 0dd4773e9a548ac0e33bb606c84e19b6910f29be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 09:02:04 +0200 Subject: [PATCH 74/85] removed todos --- .../home/report/ReportActionCompose/ComposerWithSuggestions.js | 1 - src/pages/home/report/ReportActionCompose/SuggestionMention.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 14edc886a97..59ae8af8472 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -463,7 +463,6 @@ function ComposerWithSuggestions({ const setUpComposeFocusManager = useCallback(() => { // This callback is used in the contextMenuActions to manage giving focus back to the compose input. - // TODO: we should clean up this convoluted code and instead move focus management to something like ReportFooter.js or another higher up component ReportActionComposeFocusManager.onComposerFocus(() => { if (!willBlurTextInputOnTapOutside || !isFocused) { return; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 9aeba3de607..06dcd296e51 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -55,7 +55,6 @@ function SuggestionMention({ isAutoSuggestionPickerLarge, }) { const {translate} = useLocalize(); - // TODO: rewrite suggestion logic to some hook or state machine or util or something to not make it depend on ReportActionComposer const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; From f2f397e5bf8104159e401d7c253ec7129c096059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 15:00:36 +0200 Subject: [PATCH 75/85] fix issues after merge --- .../AttachmentPickerWithMenuItems.js | 20 +++++++++++- .../ComposerWithSuggestions.js | 12 +++++-- .../ReportActionCompose.js | 32 ++++++++++++++++--- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index bf232b17e9b..ebb7f441473 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -66,6 +66,14 @@ const propTypes = { /** Called when opening the attachment picker */ onTriggerAttachmentPicker: PropTypes.func.isRequired, + + /** Called when cancelling the attachment picker */ + onCanceledAttachmentPicker: PropTypes.func.isRequired, + + /** Called when the menu with the items is closed after it was open */ + onMenuClosed: PropTypes.func.isRequired, + + onAddActionPressed: PropTypes.func.isRequired, }; const defaultProps = { @@ -93,6 +101,9 @@ function AttachmentPickerWithMenuItems({ setMenuVisibility, isMenuVisible, onTriggerAttachmentPicker, + onCanceledAttachmentPicker, + onMenuClosed, + onAddActionPressed, }) { const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); @@ -143,6 +154,11 @@ function AttachmentPickerWithMenuItems({ ]; }, [betas, report, reportID, translate]); + const onPopoverMenuClose = () => { + setMenuVisibility(false); + onMenuClosed(); + }; + return ( {({openPicker}) => { @@ -150,6 +166,7 @@ function AttachmentPickerWithMenuItems({ onTriggerAttachmentPicker(); openPicker({ onPicked: displayFileInModal, + onCanceled: onCanceledAttachmentPicker, }); }; const menuItems = [ @@ -212,6 +229,7 @@ function AttachmentPickerWithMenuItems({ ref={actionButtonRef} onPress={(e) => { e.preventDefault(); + onAddActionPressed(); // Drop focus to avoid blue focus ring. actionButtonRef.current.blur(); @@ -229,7 +247,7 @@ function AttachmentPickerWithMenuItems({ setMenuVisibility(false)} + onClose={onPopoverMenuClose} onItemSelected={(item, index) => { setMenuVisibility(false); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 59ae8af8472..9150608de93 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -154,6 +154,9 @@ const propTypes = { /** Ref for the composer */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + /** Ref for the isNextModalWillOpen */ + isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, }; const defaultProps = { @@ -207,6 +210,7 @@ function ComposerWithSuggestions({ suggestionsRef, animatedRef, forwardedRef, + isNextModalWillOpenRef, }) { const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); @@ -536,15 +540,19 @@ function ComposerWithSuggestions({ const prevIsModalVisible = usePrevious(modal.isVisible); const prevIsFocused = usePrevious(isFocused); useEffect(() => { + if (modal.isVisible && !prevIsModalVisible) { + // eslint-disable-next-line no-param-reassign + isNextModalWillOpenRef.current = false; + } // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { + if (!(willBlurTextInputOnTapOutside && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { return; } focus(); - }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible]); + }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]); useEffect(() => { if (value.length !== 0) { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 9d596024c26..8a2ab3b7a2b 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -91,6 +91,8 @@ const defaultProps = { // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + function ReportActionCompose({ blockedFromConcierge, currentUserPersonalDetails, @@ -171,6 +173,22 @@ function ReportActionCompose({ return translate('reportActionCompose.writeSomething'); }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); + const isKeyboardVisibleWhenShowingModalRef = useRef(false); + const restoreKeyboardState = useCallback(() => { + if (!isKeyboardVisibleWhenShowingModalRef.current) { + return; + } + focus(true); + isKeyboardVisibleWhenShowingModalRef.current = false; + }, [focus]); + + const onAddActionPressed = useCallback(() => { + if (!willBlurTextInputOnTapOutside) { + isKeyboardVisibleWhenShowingModalRef.current = textInputRef.current.isFocused(); + } + composerRef.current.blur(); + }, []); + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (!suggestionsRef.current) { return; @@ -200,7 +218,8 @@ function ReportActionCompose({ const onAttachmentPreviewClose = useCallback(() => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); - }, [updateShouldShowSuggestionMenuToFalse]); + restoreKeyboardState(); + }, [updateShouldShowSuggestionMenuToFalse, restoreKeyboardState]); /** * Add a new comment to this chat @@ -228,13 +247,14 @@ function ReportActionCompose({ [onSubmit], ); + const isNextModalWillOpenRef = useRef(false); const onTriggerAttachmentPicker = useCallback(() => { // Set a flag to block suggestion calculation until we're finished using the file picker, // which will stop any flickering as the file picker opens on non-native devices. - if (!willBlurTextInputOnTapOutsideFunc) { - return; + if (willBlurTextInputOnTapOutside) { + suggestionsRef.current.setShouldBlockSuggestionCalc(true); } - suggestionsRef.current.setShouldBlockSuggestionCalc(true); + isNextModalWillOpenRef.current = true; }, []); const onBlur = useCallback(() => { @@ -314,11 +334,15 @@ function ReportActionCompose({ setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} + onCanceledAttachmentPicker={restoreKeyboardState} + onMenuClosed={restoreKeyboardState} + onAddActionPressed={onAddActionPressed} /> Date: Thu, 24 Aug 2023 15:06:17 +0200 Subject: [PATCH 76/85] add blur function --- .../ReportActionCompose/ComposerWithSuggestions.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 9150608de93..0911ac1f45b 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -518,6 +518,13 @@ function ComposerWithSuggestions({ [checkComposerVisibility, focus, replaceSelectionWithText], ); + const blur = useCallback(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.blur(); + }, []); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -566,11 +573,12 @@ function ComposerWithSuggestions({ useImperativeHandle( forwardedRef, () => ({ + blur, focus, replaceSelectionWithText, prepareCommentAndResetComposer, }), - [focus, prepareCommentAndResetComposer, replaceSelectionWithText], + [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); return ( From 9b7c07bd7dd1aee15086a3cc9ffce6dd1978fbca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 15:48:54 +0200 Subject: [PATCH 77/85] foc focus state --- .../AttachmentPickerWithMenuItems.js | 9 ++++++++- .../ReportActionCompose/ComposerWithSuggestions.js | 1 + .../ReportActionCompose/ReportActionCompose.js | 13 +++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index ebb7f441473..58cfeef6140 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -73,7 +73,14 @@ const propTypes = { /** Called when the menu with the items is closed after it was open */ onMenuClosed: PropTypes.func.isRequired, + /** Called when the add action button is pressed */ onAddActionPressed: PropTypes.func.isRequired, + + /** A ref for the add action button */ + actionButtonRef: PropTypes.shape({ + // eslint-disable-next-line react/forbid-prop-types + current: PropTypes.object, + }).isRequired, }; const defaultProps = { @@ -104,10 +111,10 @@ function AttachmentPickerWithMenuItems({ onCanceledAttachmentPicker, onMenuClosed, onAddActionPressed, + actionButtonRef, }) { const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); - const actionButtonRef = useRef(null); /** * Returns the list of IOU Options diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 0911ac1f45b..68af4a414cf 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -577,6 +577,7 @@ function ComposerWithSuggestions({ focus, replaceSelectionWithText, prepareCommentAndResetComposer, + isFocused: () => textInputRef.current.isFocused(), }), [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 8a2ab3b7a2b..50e88d6e6bb 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -112,6 +112,7 @@ function ReportActionCompose({ const navigation = useNavigation(); const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const animatedRef = useAnimatedRef(); + const actionButtonRef = useRef(null); /** * Updates the Highlight state of the composer @@ -178,13 +179,13 @@ function ReportActionCompose({ if (!isKeyboardVisibleWhenShowingModalRef.current) { return; } - focus(true); + composerRef.current.focus(true); isKeyboardVisibleWhenShowingModalRef.current = false; - }, [focus]); + }, []); const onAddActionPressed = useCallback(() => { if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = textInputRef.current.isFocused(); + isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused(); } composerRef.current.blur(); }, []); @@ -257,9 +258,12 @@ function ReportActionCompose({ isNextModalWillOpenRef.current = true; }, []); - const onBlur = useCallback(() => { + const onBlur = useCallback((e) => { setIsFocused(false); suggestionsRef.current.resetSuggestions(); + if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } }, []); const onFocus = useCallback(() => { @@ -337,6 +341,7 @@ function ReportActionCompose({ onCanceledAttachmentPicker={restoreKeyboardState} onMenuClosed={restoreKeyboardState} onAddActionPressed={onAddActionPressed} + actionButtonRef={actionButtonRef} /> Date: Thu, 24 Aug 2023 16:02:11 +0200 Subject: [PATCH 78/85] fix IOU options not showing up --- .../report/ReportActionCompose/ReportActionCompose.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 50e88d6e6bb..8875d9b15d7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -144,8 +144,11 @@ function ReportActionCompose({ const suggestionsRef = useRef(null); const composerRef = useRef(null); - const reportParticipants = useMemo(() => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report]); - const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipants]); + const reportParticipantIDs = useMemo( + () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), + [currentUserPersonalDetails.accountID, report], + ); + const participantsWithoutExpensifyAccountIDs = useMemo(() => _.difference(reportParticipantIDs, CONST.EXPENSIFY_ACCOUNT_IDS), [reportParticipantIDs]); const shouldShowReportRecipientLocalTime = useMemo( () => ReportUtils.canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize, @@ -329,7 +332,7 @@ function ReportActionCompose({ displayFileInModal={displayFileInModal} reportID={reportID} report={report} - reportParticipants={reportParticipants} + reportParticipantIDs={reportParticipantIDs} isFullComposerAvailable={isFullComposerAvailable} isComposerFullSize={isComposerFullSize} updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} From faf1e1572a5df466b0d9688a7a6a00c5498914c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 16:35:31 +0200 Subject: [PATCH 79/85] fix crash --- .../home/report/ReportActionCompose/ComposerWithSuggestions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 68af4a414cf..a03d256e2db 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -276,7 +276,7 @@ function ComposerWithSuggestions({ if (!RNTextInputReset) { return; } - RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef)); + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); }, [textInputRef]); /** From ad30918ac606c7c305db34c6a5baeac411f961b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Aug 2023 16:37:13 +0200 Subject: [PATCH 80/85] remove unused import --- .../report/ReportActionCompose/AttachmentPickerWithMenuItems.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 58cfeef6140..5864b9b0502 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -1,4 +1,4 @@ -import React, {useRef, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; From 5e93ecfdffc0e42ce119f98e868d43abe0664943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 25 Aug 2023 08:40:34 +0200 Subject: [PATCH 81/85] reapply changes --- .../autoCompleteSuggestionsPropTypes.js | 11 ++----- src/components/EmojiSuggestions.js | 13 ++++---- src/components/MentionSuggestions.js | 13 +++----- .../ComposerWithSuggestions.js | 10 ++++-- .../ReportActionCompose.js | 33 ++++++++++++++----- .../ReportActionCompose/SuggestionEmoji.js | 2 ++ .../ReportActionCompose/SuggestionMention.js | 2 ++ .../report/ReportActionCompose/Suggestions.js | 2 ++ .../ReportActionCompose/suggestionProps.js | 3 ++ 9 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index ad3e2babb1c..16040991a3d 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import refPropType from '../refPropTypes'; const propTypes = { /** Array of suggestions */ @@ -29,16 +28,12 @@ const propTypes = { /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - parentContainerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; const defaultProps = { - parentContainerRef: { - current: null, - }, + measureParentContainer: () => {}, }; export {propTypes, defaultProps}; diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index cfde3853784..bfd2170bdd9 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -47,13 +47,14 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - containerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; -const defaultProps = {highlightedEmojiIndex: 0, containerRef: {current: null}}; +const defaultProps = { + highlightedEmojiIndex: 0, + measureParentContainer: () => {}, +}; /** * Create unique keys for each emoji item @@ -104,7 +105,7 @@ function EmojiSuggestions(props) { isSuggestionPickerLarge={props.isEmojiPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} - parentContainerRef={props.containerRef} + measureParentContainer={props.measureParentContainer} /> ); } diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 799fccb74a5..4b012963526 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -10,7 +10,6 @@ import Avatar from './Avatar'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import getStyledTextArray from '../libs/GetStyledTextArray'; import avatarPropTypes from './avatarPropTypes'; -import refPropType from './refPropTypes'; const propTypes = { /** The index of the highlighted mention */ @@ -44,17 +43,13 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Ref of the container enclosing the menu. - * This is needed to render the menu in correct position inside a portal - */ - containerRef: refPropType, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, }; const defaultProps = { highlightedMentionIndex: 0, - containerRef: { - current: null, - }, + measureParentContainer: () => {}, }; /** @@ -131,7 +126,7 @@ function MentionSuggestions(props) { isSuggestionPickerLarge={props.isMentionPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} - parentContainerRef={props.containerRef} + measureParentContainer={props.measureParentContainer} /> ); } diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index a03d256e2db..2a99ef1c3a6 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -1,5 +1,5 @@ import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; -import {View, InteractionManager, NativeModules, findNodeHandle, LayoutAnimation} from 'react-native'; +import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; @@ -132,6 +132,9 @@ const propTypes = { /** Whether the compose input is shown or not */ shouldShowComposeInput: PropTypes.bool.isRequired, + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, + /** Ref for the suggestions component */ suggestionsRef: PropTypes.shape({ current: PropTypes.shape({ @@ -166,6 +169,7 @@ const defaultProps = { parentReportActions: {}, reportActions: [], forwardedRef: null, + measureParentContainer: () => {}, }; /** @@ -206,6 +210,7 @@ function ComposerWithSuggestions({ submitForm, shouldShowReportRecipientLocalTime, shouldShowComposeInput, + measureParentContainer, // Refs suggestionsRef, animatedRef, @@ -415,8 +420,6 @@ function ComposerWithSuggestions({ const onSelectionChange = useCallback( (e) => { - LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); - if (suggestionsRef.current.onSelectionChange(e)) { return; } @@ -632,6 +635,7 @@ function ComposerWithSuggestions({ composerHeight={composerHeight} shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} onInsertedEmoji={onInsertedEmoji} + measureParentContainer={measureParentContainer} // Input value={value} setValue={setValue} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 8875d9b15d7..f8a907431b9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -37,6 +37,7 @@ import reportActionPropTypes from '../reportActionPropTypes'; import useLocalize from '../../../../hooks/useLocalize'; import getModalState from '../../../../libs/getModalState'; import useWindowDimensions from '../../../../hooks/useWindowDimensions'; +import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; const propTypes = { /** A method to call when the form is submitted */ @@ -186,6 +187,14 @@ function ReportActionCompose({ isKeyboardVisibleWhenShowingModalRef.current = false; }, []); + const containerRef = useRef(null); + const measureContainer = useCallback((callback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }, []); + const onAddActionPressed = useCallback(() => { if (!willBlurTextInputOnTapOutside) { isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused(); @@ -284,15 +293,19 @@ function ReportActionCompose({ useEffect(() => { // Shows Popover Menu on Workspace Chat at first sign-in - if (disabled) { - return; + if (!disabled) { + Welcome.show({ + routes: lodashGet(navigation.getState(), 'routes', []), + showPopoverMenu, + }); } - Welcome.show({ - routes: lodashGet(navigation.getState(), 'routes', []), - showPopoverMenu, - }); - + return () => { + if (!EmojiPickerActions.isActive(report.reportID)) { + return; + } + EmojiPickerActions.hideEmojiPicker(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -304,7 +317,10 @@ function ReportActionCompose({ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; return ( - + composerRef.current.focus(true)} onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)} + emojiPickerID={report.reportID} /> )} ); } diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 06dcd296e51..79b5d1d66e3 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -53,6 +53,7 @@ function SuggestionMention({ shouldShowReportRecipientLocalTime, forwardedRef, isAutoSuggestionPickerLarge, + measureParentContainer, }) { const {translate} = useLocalize(); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -287,6 +288,7 @@ function SuggestionMention({ isMentionPickerLarge={isAutoSuggestionPickerLarge} composerHeight={composerHeight} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + measureParentContainer={measureParentContainer} /> ); } diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index aa005fb6f57..ed2ab9586d5 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -40,6 +40,7 @@ function Suggestions({ forwardedRef, onInsertedEmoji, resetKeyboardInput, + measureParentContainer, }) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); @@ -106,6 +107,7 @@ function Suggestions({ composerHeight, shouldShowReportRecipientLocalTime, isAutoSuggestionPickerLarge, + measureParentContainer, }; return ( diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 279d2ff5daf..24cf51b018c 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -18,6 +18,9 @@ const baseProps = { /** Flag whether we need to consider the participants */ shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func.isRequired, }; const implementationBaseProps = { From 1e97af05ce187080029bd731e2d7a629cc781cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 25 Aug 2023 08:41:57 +0200 Subject: [PATCH 82/85] fix lint + pass measure callback --- src/components/EmojiSuggestions.js | 1 - src/pages/home/report/ReportActionCompose/ReportActionCompose.js | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index bfd2170bdd9..b06b0cc63eb 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -8,7 +8,6 @@ import * as EmojiUtils from '../libs/EmojiUtils'; import Text from './Text'; import getStyledTextArray from '../libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; -import refPropType from './refPropTypes'; const propTypes = { /** The index of the highlighted emoji */ diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index f8a907431b9..b67ca1acd1e 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -386,6 +386,7 @@ function ReportActionCompose({ shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} + measureParentContainer={measureContainer} /> { From 0ccd7ae0ba6b7ecc526aea7c1f3df7c079751806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 25 Aug 2023 08:45:57 +0200 Subject: [PATCH 83/85] move prop types to separate file --- .../ComposerWithSuggestions.js | 121 +---------------- .../composerWithSuggestionsProps.js | 123 ++++++++++++++++++ 2 files changed, 124 insertions(+), 120 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 2a99ef1c3a6..571c5fd3c52 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -1,7 +1,6 @@ import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; import {View, InteractionManager, NativeModules, findNodeHandle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {useIsFocused, useNavigation} from '@react-navigation/native'; @@ -23,7 +22,6 @@ import usePrevious from '../../../../hooks/usePrevious'; import * as EmojiUtils from '../../../../libs/EmojiUtils'; import * as User from '../../../../libs/actions/User'; import * as ReportUtils from '../../../../libs/ReportUtils'; -import reportActionPropTypes from '../reportActionPropTypes'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; import debouncedSaveReportComment from './debouncedSaveReportComment'; import SilentCommentUpdater from './SilentCommentUpdater'; @@ -32,6 +30,7 @@ import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; import useLocalize from '../../../../hooks/useLocalize'; import compose from '../../../../libs/compose'; import withKeyboardState from '../../../../components/withKeyboardState'; +import {propTypes, defaultProps} from './composerWithSuggestionsProps'; const {RNTextInputReset} = NativeModules; @@ -54,124 +53,6 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); -const propTypes = { - /** Details about any modals being used */ - modal: PropTypes.shape({ - /** Indicates if there is a modal currently visible or not */ - isVisible: PropTypes.bool, - }), - - /** User's preferred skin tone color */ - preferredSkinTone: PropTypes.number, - - /** Number of lines for the composer */ - numberOfLines: PropTypes.number, - - /** Whether the keyboard is open or not */ - isKeyboardShown: PropTypes.bool.isRequired, - - /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - - /** The ID of the report */ - reportID: PropTypes.string.isRequired, - - /** The report currently being looked at */ - report: PropTypes.shape({ - parentReportID: PropTypes.string, - }).isRequired, - - /** Callback when the input is focused */ - onFocus: PropTypes.func.isRequired, - - /** Callback when the input is blurred */ - onBlur: PropTypes.func.isRequired, - - /** Whether the composer is full size or not */ - isComposerFullSize: PropTypes.bool.isRequired, - - /** Whether the menu is visible or not */ - isMenuVisible: PropTypes.bool.isRequired, - - /** Placeholder text for the input */ - inputPlaceholder: PropTypes.string.isRequired, - - /** Function to display a file in the modal */ - displayFileInModal: PropTypes.func.isRequired, - - /** Whether the text input should be cleared or not */ - textInputShouldClear: PropTypes.bool.isRequired, - - /** Function to set whether the text input should be cleared or not */ - setTextInputShouldClear: PropTypes.func.isRequired, - - /** Whether the user is blocked from concierge or not */ - isBlockedFromConcierge: PropTypes.bool.isRequired, - - /** Whether the input is disabled or not */ - disabled: PropTypes.bool.isRequired, - - /** Whether the full composer is available or not */ - isFullComposerAvailable: PropTypes.bool.isRequired, - - /** Function to set whether the full composer is available or not */ - setIsFullComposerAvailable: PropTypes.func.isRequired, - - /** Function to set whether the comment is empty or not */ - setIsCommentEmpty: PropTypes.func.isRequired, - - /** A method to call when the form is submitted */ - submitForm: PropTypes.func.isRequired, - - /** Whether the recipient local time is shown or not */ - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - - /** Whether the compose input is shown or not */ - shouldShowComposeInput: PropTypes.bool.isRequired, - - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func, - - /** Ref for the suggestions component */ - suggestionsRef: PropTypes.shape({ - current: PropTypes.shape({ - /** Update the shouldShowSuggestionMenuToFalse prop */ - updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, - - /** Trigger hotkey actions */ - triggerHotkeyActions: PropTypes.func.isRequired, - - /** Check if suggestion calculation should be blocked */ - setShouldBlockSuggestionCalc: PropTypes.func.isRequired, - - /** Callback when the selection changes */ - onSelectionChange: PropTypes.func.isRequired, - }), - }).isRequired, - - /** Ref for the animated view (text input) */ - animatedRef: PropTypes.func.isRequired, - - /** Ref for the composer */ - forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - - /** Ref for the isNextModalWillOpen */ - isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, -}; - -const defaultProps = { - modal: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - numberOfLines: undefined, - parentReportActions: {}, - reportActions: [], - forwardedRef: null, - measureParentContainer: () => {}, -}; - /** * This component holds the value and selection state. * If a component really needs access to these state values it should be put here. diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js new file mode 100644 index 00000000000..b8d9f0b6d81 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import reportActionPropTypes from '../reportActionPropTypes'; +import CONST from '../../../../CONST'; + +const propTypes = { + /** Details about any modals being used */ + modal: PropTypes.shape({ + /** Indicates if there is a modal currently visible or not */ + isVisible: PropTypes.bool, + }), + + /** User's preferred skin tone color */ + preferredSkinTone: PropTypes.number, + + /** Number of lines for the composer */ + numberOfLines: PropTypes.number, + + /** Whether the keyboard is open or not */ + isKeyboardShown: PropTypes.bool.isRequired, + + /** The actions from the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + /** Array of report actions for this report */ + reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + + /** The ID of the report */ + reportID: PropTypes.string.isRequired, + + /** The report currently being looked at */ + report: PropTypes.shape({ + parentReportID: PropTypes.string, + }).isRequired, + + /** Callback when the input is focused */ + onFocus: PropTypes.func.isRequired, + + /** Callback when the input is blurred */ + onBlur: PropTypes.func.isRequired, + + /** Whether the composer is full size or not */ + isComposerFullSize: PropTypes.bool.isRequired, + + /** Whether the menu is visible or not */ + isMenuVisible: PropTypes.bool.isRequired, + + /** Placeholder text for the input */ + inputPlaceholder: PropTypes.string.isRequired, + + /** Function to display a file in the modal */ + displayFileInModal: PropTypes.func.isRequired, + + /** Whether the text input should be cleared or not */ + textInputShouldClear: PropTypes.bool.isRequired, + + /** Function to set whether the text input should be cleared or not */ + setTextInputShouldClear: PropTypes.func.isRequired, + + /** Whether the user is blocked from concierge or not */ + isBlockedFromConcierge: PropTypes.bool.isRequired, + + /** Whether the input is disabled or not */ + disabled: PropTypes.bool.isRequired, + + /** Whether the full composer is available or not */ + isFullComposerAvailable: PropTypes.bool.isRequired, + + /** Function to set whether the full composer is available or not */ + setIsFullComposerAvailable: PropTypes.func.isRequired, + + /** Function to set whether the comment is empty or not */ + setIsCommentEmpty: PropTypes.func.isRequired, + + /** A method to call when the form is submitted */ + submitForm: PropTypes.func.isRequired, + + /** Whether the recipient local time is shown or not */ + shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, + + /** Whether the compose input is shown or not */ + shouldShowComposeInput: PropTypes.bool.isRequired, + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: PropTypes.func, + + /** Ref for the suggestions component */ + suggestionsRef: PropTypes.shape({ + current: PropTypes.shape({ + /** Update the shouldShowSuggestionMenuToFalse prop */ + updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, + + /** Trigger hotkey actions */ + triggerHotkeyActions: PropTypes.func.isRequired, + + /** Check if suggestion calculation should be blocked */ + setShouldBlockSuggestionCalc: PropTypes.func.isRequired, + + /** Callback when the selection changes */ + onSelectionChange: PropTypes.func.isRequired, + }), + }).isRequired, + + /** Ref for the animated view (text input) */ + animatedRef: PropTypes.func.isRequired, + + /** Ref for the composer */ + forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), + + /** Ref for the isNextModalWillOpen */ + isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, +}; + +const defaultProps = { + modal: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + numberOfLines: undefined, + parentReportActions: {}, + reportActions: [], + forwardedRef: null, + measureParentContainer: () => {}, +}; + +export {propTypes, defaultProps}; From 89b569d31e88bb04766f00d0e9a371de444be53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 25 Aug 2023 08:48:48 +0200 Subject: [PATCH 84/85] move libs/ComposerUtils/debouncedSaveReportComment --- .../ComposerUtils}/debouncedSaveReportComment.js | 2 +- .../home/report/ReportActionCompose/ComposerWithSuggestions.js | 2 +- .../home/report/ReportActionCompose/ReportActionCompose.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/{pages/home/report/ReportActionCompose => libs/ComposerUtils}/debouncedSaveReportComment.js (85%) diff --git a/src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js b/src/libs/ComposerUtils/debouncedSaveReportComment.js similarity index 85% rename from src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js rename to src/libs/ComposerUtils/debouncedSaveReportComment.js index b8569041e8d..c39da78c2c3 100644 --- a/src/pages/home/report/ReportActionCompose/debouncedSaveReportComment.js +++ b/src/libs/ComposerUtils/debouncedSaveReportComment.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import * as Report from '../../../../libs/actions/Report'; +import * as Report from '../actions/Report'; /** * Save draft report comment. Debounced to happen at most once per second. diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 571c5fd3c52..5923ff1c545 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -23,7 +23,7 @@ import * as EmojiUtils from '../../../../libs/EmojiUtils'; import * as User from '../../../../libs/actions/User'; import * as ReportUtils from '../../../../libs/ReportUtils'; import canFocusInputOnScreenFocus from '../../../../libs/canFocusInputOnScreenFocus'; -import debouncedSaveReportComment from './debouncedSaveReportComment'; +import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment'; import SilentCommentUpdater from './SilentCommentUpdater'; import Suggestions from './Suggestions'; import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index b67ca1acd1e..aa4ecfd4218 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -32,7 +32,7 @@ import * as Welcome from '../../../../libs/actions/Welcome'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import debouncedSaveReportComment from './debouncedSaveReportComment'; +import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment'; import reportActionPropTypes from '../reportActionPropTypes'; import useLocalize from '../../../../hooks/useLocalize'; import getModalState from '../../../../libs/getModalState'; From e64a5d953c27efe20da2c2d5e8be610fe9286771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 25 Aug 2023 08:58:30 +0200 Subject: [PATCH 85/85] fix marking reports as draft when opening --- .../home/report/ReportActionCompose/ComposerWithSuggestions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 5923ff1c545..9f6d3cdac76 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -446,7 +446,7 @@ function ComposerWithSuggestions({ }, [focus, prevIsFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]); useEffect(() => { - if (value.length !== 0) { + if (value.length === 0) { return; } Report.setReportWithDraft(reportID, true);