From 89414f66bceda14b981de2c03ac55e5bcc364240 Mon Sep 17 00:00:00 2001 From: VH Date: Sat, 29 Jul 2023 00:03:32 +0700 Subject: [PATCH 001/340] Only filter personal details having login for displaying in option list --- src/libs/OptionsListUtils.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 31ebab0ea0d2..84a46015ccf5 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -622,12 +622,6 @@ function getOptions( }; } - // We're only picking personal details that have logins set - // This is a temporary fix for all the logic that's been breaking because of the new privacy changes - // See https://github.com/Expensify/Expensify/issues/293465 for more context - // eslint-disable-next-line no-param-reassign - personalDetails = _.pick(personalDetails, (detail) => Boolean(detail.login)); - let recentReportOptions = []; let personalDetailsOptions = []; const reportMapForAccountIDs = {}; @@ -703,7 +697,12 @@ function getOptions( ); }); - let allPersonalDetailsOptions = _.map(personalDetails, (personalDetail) => + // We're only picking personal details that have logins set + // This is a temporary fix for all the logic that's been breaking because of the new privacy changes + // See https://github.com/Expensify/Expensify/issues/293465 for more context + // eslint-disable-next-line no-param-reassign + const havingLoginPersonalDetails = _.pick(personalDetails, (detail) => Boolean(detail.login)); + let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) => createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, { showChatPreviewLine, forcePolicyNamePreview, From 81845f4a271584e154cdf124d24a077cc099dbfe Mon Sep 17 00:00:00 2001 From: David Cardoza Date: Mon, 31 Jul 2023 19:17:36 -0700 Subject: [PATCH 002/340] Create Card-Rev-Share-for-Approved-Partners.md Added new ExpensifyHelp page. Tracking issue is here - https://github.com/Expensify/Expensify/issues/304373 --- docs/Card-Rev-Share-for-Approved-Partners.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/Card-Rev-Share-for-Approved-Partners.md diff --git a/docs/Card-Rev-Share-for-Approved-Partners.md b/docs/Card-Rev-Share-for-Approved-Partners.md new file mode 100644 index 000000000000..9b5647a004d3 --- /dev/null +++ b/docs/Card-Rev-Share-for-Approved-Partners.md @@ -0,0 +1,17 @@ +--- +title: Expensify Card revenue share for ExpensifyApproved! partners +description: Earn money when your clients adopt the Expensify Card +--- + + +# About +Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
+
This program is currently only available to US-based ExpensifyApproved! partner accountants. + +# How-to +To benefit from this program, all you need to do is ensure that you are listed as a domain admin on your client's Expensify account. If you're not currently a domain admin, your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role. +# FAQ +- What if my firm is not permitted to accept revenue share from our clients?
+
We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.

+- What if my firm does not wish to participate in the program?
+
Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients. 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 003/340] 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 000000000000..328616df0c6d --- /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 161f03805f7e..47a35a0df9c8 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 b38fdc853733..70f2dc04d5c3 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 004/340] 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 328616df0c6d..b2bcd4794332 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 47a35a0df9c8..755c8b765ea4 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 000000000000..7660325844bc --- /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 005/340] 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 b2bcd4794332..a8435584e296 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 25dd9dc98826..be821d642409 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 755c8b765ea4..4ecdfa302f4f 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 d207fa54b97a..4dae85ed727d 100644 --- a/web/index.html +++ b/web/index.html @@ -122,6 +122,7 @@ + <% if (htmlWebpackPlugin.options.usePolyfillIO) { %> From 96b35655f18ffe5085cab7ea0d65748f9bde4396 Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 5 Aug 2023 02:46:01 +0700 Subject: [PATCH 006/340] fix: app opens all option when press quickly --- src/hooks/useSingleExecution.js | 35 +++++++++++++++++ src/hooks/useWaitForNavigate.js | 32 ++++++++++++++++ src/pages/settings/InitialSettingsPage.js | 46 +++++++++++++---------- 3 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useSingleExecution.js create mode 100644 src/hooks/useWaitForNavigate.js diff --git a/src/hooks/useSingleExecution.js b/src/hooks/useSingleExecution.js new file mode 100644 index 000000000000..22c2907c9420 --- /dev/null +++ b/src/hooks/useSingleExecution.js @@ -0,0 +1,35 @@ +import {InteractionManager} from 'react-native'; +import {useCallback, useState} from 'react'; + +/** + * With any action passed in, it will only allow 1 such action to occur at a time. + * + * @returns {Object} + */ +export default function useSingleExecution() { + const [isExecuting, setIsExecuting] = useState(false); + + const singleExecution = useCallback( + (action) => () => { + if (isExecuting) { + return; + } + + setIsExecuting(true); + + const execution = action(); + InteractionManager.runAfterInteractions(() => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); + }); + }); + }, + [isExecuting], + ); + + return {isExecuting, singleExecution}; +} diff --git a/src/hooks/useWaitForNavigate.js b/src/hooks/useWaitForNavigate.js new file mode 100644 index 000000000000..fae62599bfc6 --- /dev/null +++ b/src/hooks/useWaitForNavigate.js @@ -0,0 +1,32 @@ +import {useEffect, useRef} from 'react'; +import {useNavigation} from '@react-navigation/native'; + +/** + * Returns a promise that resolves when navigation finishes. + * + * @returns {function} + */ +export default function useWaitForNavigate() { + const navigation = useNavigation(); + const resolvePromises = useRef([]); + + useEffect(() => { + const unsubscribeBlur = navigation.addListener('blur', () => { + resolvePromises.current.forEach((resolve) => { + resolve(); + }); + resolvePromises.current = []; + }); + + return () => { + unsubscribeBlur(); + }; + }, [navigation]); + + return (navigate) => () => { + navigate(); + return new Promise((resolve) => { + resolvePromises.current.push(resolve); + }); + }; +} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 825da39244d8..dc869564b728 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -41,6 +41,8 @@ import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions' import * as CurrencyUtils from '../../libs/CurrencyUtils'; import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback'; import useLocalize from '../../hooks/useLocalize'; +import useSingleExecution from '../../hooks/useSingleExecution'; +import useWaitForNavigate from '../../hooks/useWaitForNavigate'; const propTypes = { /* Onyx Props */ @@ -129,6 +131,8 @@ const defaultProps = { }; function InitialSettingsPage(props) { + const {isExecuting, singleExecution} = useSingleExecution(); + const waitForNavigate = useWaitForNavigate(); const popoverAnchor = useRef(null); const {translate} = useLocalize(); @@ -190,16 +194,16 @@ function InitialSettingsPage(props) { { translationKey: 'common.shareCode', icon: Expensicons.QrCode, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE); - }, + }), }, { translationKey: 'common.workspaces', icon: Expensicons.Building, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); - }, + }), floatRightAvatars: policiesAvatars, shouldStackHorizontally: true, avatarSize: CONST.AVATAR_SIZE.SMALLER, @@ -208,31 +212,31 @@ function InitialSettingsPage(props) { { translationKey: 'common.profile', icon: Expensicons.Profile, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_PROFILE); - }, + }), brickRoadIndicator: profileBrickRoadIndicator, }, { translationKey: 'common.preferences', icon: Expensicons.Gear, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); - }, + }), }, { translationKey: 'initialSettingsPage.security', icon: Expensicons.Lock, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_SECURITY); - }, + }), }, { translationKey: 'common.payments', icon: Expensicons.Wallet, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_PAYMENTS); - }, + }), brickRoadIndicator: PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || !_.isEmpty(props.userWallet.errors) || !_.isEmpty(props.walletTerms.errors) ? 'error' @@ -241,9 +245,9 @@ function InitialSettingsPage(props) { { translationKey: 'initialSettingsPage.help', icon: Expensicons.QuestionMark, - action: () => { + action: waitForNavigate(() => { Link.openExternalLink(CONST.NEWHELP_URL); - }, + }), shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, link: CONST.NEWHELP_URL, @@ -251,16 +255,16 @@ function InitialSettingsPage(props) { { translationKey: 'initialSettingsPage.about', icon: Expensicons.Info, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_ABOUT); - }, + }), }, { translationKey: 'initialSettingsPage.signOut', icon: Expensicons.Exit, - action: () => { + action: waitForNavigate(() => { signOut(false); - }, + }), }, ]; }, [ @@ -275,6 +279,7 @@ function InitialSettingsPage(props) { props.userWallet.errors, props.walletTerms.errors, signOut, + waitForNavigate, ]); const getMenuItems = useMemo(() => { @@ -297,7 +302,8 @@ function InitialSettingsPage(props) { title={keyTitle} icon={item.icon} iconType={item.iconType} - onPress={item.action} + disabled={isExecuting} + onPress={singleExecution(item.action)} iconStyles={item.iconStyles} shouldShowRightIcon iconRight={item.iconRight} @@ -317,7 +323,7 @@ function InitialSettingsPage(props) { })} ); - }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate]); + }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate, isExecuting, singleExecution]); // On the very first sign in or after clearing storage these // details will not be present on the first render so we'll just From 7bc848b9af084b0efd442c576897652e73a637d1 Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 5 Aug 2023 03:05:12 +0700 Subject: [PATCH 007/340] useWaitForNavigation only when navigating by react-navigation --- src/components/Pressable/PressableWithFeedback.js | 15 +++------------ ...WaitForNavigate.js => useWaitForNavigation.js} | 3 ++- src/pages/settings/InitialSettingsPage.js | 12 ++++++------ 3 files changed, 11 insertions(+), 19 deletions(-) rename src/hooks/{useWaitForNavigate.js => useWaitForNavigation.js} (88%) diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 0f33a3685b1c..d31c42ebfa20 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -1,11 +1,11 @@ import React, {forwardRef, useState} from 'react'; import _ from 'underscore'; import propTypes from 'prop-types'; -import {InteractionManager} from 'react-native'; import GenericPressable from './GenericPressable'; import GenericPressablePropTypes from './GenericPressable/PropTypes'; import OpacityView from '../OpacityView'; import variables from '../../styles/variables'; +import useSingleExecution from '../../hooks/useSingleExecution'; const omittedProps = ['wrapperStyle']; @@ -39,7 +39,7 @@ const PressableWithFeedbackDefaultProps = { const PressableWithFeedback = forwardRef((props, ref) => { const propsWithoutWrapperStyles = _.omit(props, omittedProps); - const [isExecuting, setIsExecuting] = useState(false); + const {isExecuting, singleExecution} = useSingleExecution(); const [isPressed, setIsPressed] = useState(false); const [isHovered, setIsHovered] = useState(false); const isDisabled = props.disabled || isExecuting; @@ -73,17 +73,8 @@ const PressableWithFeedback = forwardRef((props, ref) => { if (props.onPressOut) props.onPressOut(); }} onPress={(e) => { - setIsExecuting(true); const onPress = props.onPress(e); - InteractionManager.runAfterInteractions(() => { - if (!(onPress instanceof Promise)) { - setIsExecuting(false); - return; - } - onPress.finally(() => { - setIsExecuting(false); - }); - }); + singleExecution(onPress); }} > {(state) => (_.isFunction(props.children) ? props.children(state) : props.children)} diff --git a/src/hooks/useWaitForNavigate.js b/src/hooks/useWaitForNavigation.js similarity index 88% rename from src/hooks/useWaitForNavigate.js rename to src/hooks/useWaitForNavigation.js index fae62599bfc6..00f4405dff12 100644 --- a/src/hooks/useWaitForNavigate.js +++ b/src/hooks/useWaitForNavigation.js @@ -3,10 +3,11 @@ import {useNavigation} from '@react-navigation/native'; /** * Returns a promise that resolves when navigation finishes. + * Only use when navigating by react-navigation * * @returns {function} */ -export default function useWaitForNavigate() { +export default function useWaitForNavigation() { const navigation = useNavigation(); const resolvePromises = useRef([]); diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index dc869564b728..07d235a25227 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -42,7 +42,7 @@ import * as CurrencyUtils from '../../libs/CurrencyUtils'; import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback'; import useLocalize from '../../hooks/useLocalize'; import useSingleExecution from '../../hooks/useSingleExecution'; -import useWaitForNavigate from '../../hooks/useWaitForNavigate'; +import useWaitForNavigation from '../../hooks/useWaitForNavigation'; const propTypes = { /* Onyx Props */ @@ -132,7 +132,7 @@ const defaultProps = { function InitialSettingsPage(props) { const {isExecuting, singleExecution} = useSingleExecution(); - const waitForNavigate = useWaitForNavigate(); + const waitForNavigate = useWaitForNavigation(); const popoverAnchor = useRef(null); const {translate} = useLocalize(); @@ -245,9 +245,9 @@ function InitialSettingsPage(props) { { translationKey: 'initialSettingsPage.help', icon: Expensicons.QuestionMark, - action: waitForNavigate(() => { + action: () => { Link.openExternalLink(CONST.NEWHELP_URL); - }), + }, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, link: CONST.NEWHELP_URL, @@ -262,9 +262,9 @@ function InitialSettingsPage(props) { { translationKey: 'initialSettingsPage.signOut', icon: Expensicons.Exit, - action: waitForNavigate(() => { + action: () => { signOut(false); - }), + }, }, ]; }, [ 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 008/340] 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 056aceb7b799..d8b1d7778ee1 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 009/340] 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 d8b1d7778ee1..85a5223cb24b 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 010/340] 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 85a5223cb24b..524175be476c 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 011/340] 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 524175be476c..c2f5fbc0bf9c 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 012/340] 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 be821d642409..25dd9dc98826 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 013/340] 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 c2f5fbc0bf9c..d1768394c8f2 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 014/340] 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 d1768394c8f2..931881f85509 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 7660325844bc..3a510f925e3a 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 015/340] 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 931881f85509..b4d1fa443183 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 3a510f925e3a..54a222daf667 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 016/340] 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 000000000000..ac06eeae10d9 --- /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 000000000000..57a6e6103c21 --- /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 54a222daf667..dede99390992 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 017/340] 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 000000000000..aa2640d006c8 --- /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 605f5541f684..6247337f68d0 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 ac06eeae10d9..39fc18e2705c 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 dede99390992..4e325302ec77 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 018/340] 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 000000000000..fa3c1d3aa135 --- /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 6247337f68d0..98602f9cdb72 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 000000000000..aa8818ba0b76 --- /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 4e325302ec77..0fc4cff42729 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 019/340] 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 fa3c1d3aa135..4c97cf7a7b21 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 98602f9cdb72..0586532ff396 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 020/340] 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 4c97cf7a7b21..fa3c1d3aa135 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 0586532ff396..98602f9cdb72 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 021/340] 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 fa3c1d3aa135..000000000000 --- 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 98602f9cdb72..6247337f68d0 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 aa8818ba0b76..000000000000 --- 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 0fc4cff42729..4e325302ec77 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 022/340] 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 6247337f68d0..665fcfe08111 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 023/340] 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 665fcfe08111..468f5ea462b6 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 024/340] 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 000000000000..57dd9aa52611 --- /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 468f5ea462b6..c532867fd41e 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 025/340] 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 57dd9aa52611..9fdc56c35690 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 c532867fd41e..38e6292ea862 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 026/340] 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 9fdc56c35690..65db434981ec 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 38e6292ea862..30e88ff99c9f 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 027/340] 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 30e88ff99c9f..538ea054f05b 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 000000000000..6bc7635e2fd5 --- /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 028/340] 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 39fc18e2705c..fdccf4958c47 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 029/340] 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 538ea054f05b..7ccc09b4c763 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 6bc7635e2fd5..a05ec3502f5a 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 030/340] 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 7ccc09b4c763..d2dfdb34edc3 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 a05ec3502f5a..e7d5e1edb2f2 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 000000000000..b8569041e8dd --- /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 031/340] 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 e7d5e1edb2f2..fa07763740a7 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 000000000000..fdecedf3d5b4 --- /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 032/340] 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 fa07763740a7..2806ab08fb29 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 033/340] 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 fdccf4958c47..359cdf13c843 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 7077dcb0395e..bf0193dd3f85 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 4e325302ec77..714c3ff449a5 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 10:22:58 +0700 Subject: [PATCH 034/340] fix: 22929 forwardref console error --- src/components/AmountTextInput.js | 3 ++- src/components/Checkbox.js | 3 ++- src/components/CountryPicker/index.js | 3 ++- src/components/Picker/BasePicker.js | 3 ++- src/components/StatePicker/index.js | 3 ++- src/components/TextInputWithCurrencySymbol.js | 3 ++- src/components/TextLink.js | 3 ++- src/components/withAnimatedRef.js | 3 ++- src/components/withCurrentUserPersonalDetails.js | 3 ++- src/components/withNavigation.js | 3 ++- src/components/withToggleVisibilityView.js | 3 ++- src/pages/iou/steps/MoneyRequestAmountForm.js | 3 ++- 12 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js index 8472ef271be0..ec5330772cf3 100644 --- a/src/components/AmountTextInput.js +++ b/src/components/AmountTextInput.js @@ -9,7 +9,8 @@ const propTypes = { formattedAmount: PropTypes.string.isRequired, /** A ref to forward to amount text input */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Function to call when amount in text input is changed */ onChangeAmount: PropTypes.func.isRequired, diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js index 86b6e05d5ed7..97fec1b2618c 100644 --- a/src/components/Checkbox.js +++ b/src/components/Checkbox.js @@ -45,7 +45,8 @@ const propTypes = { caretSize: PropTypes.number, /** A ref to forward to the Pressable */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** An accessibility label for the checkbox */ accessibilityLabel: PropTypes.string.isRequired, diff --git a/src/components/CountryPicker/index.js b/src/components/CountryPicker/index.js index 6d1435dca796..417cd79eb4eb 100644 --- a/src/components/CountryPicker/index.js +++ b/src/components/CountryPicker/index.js @@ -19,7 +19,8 @@ const propTypes = { onInputChange: PropTypes.func, /** A ref to forward to MenuItemWithTopDescription */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; const defaultProps = { diff --git a/src/components/Picker/BasePicker.js b/src/components/Picker/BasePicker.js index 173b863edfcc..9fe5e3540194 100644 --- a/src/components/Picker/BasePicker.js +++ b/src/components/Picker/BasePicker.js @@ -13,7 +13,8 @@ import {ScrollContext} from '../ScrollViewWithContext'; const propTypes = { /** A forwarded ref */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** BasePicker label */ label: PropTypes.string, diff --git a/src/components/StatePicker/index.js b/src/components/StatePicker/index.js index c934790f54e5..2e41f85c32a2 100644 --- a/src/components/StatePicker/index.js +++ b/src/components/StatePicker/index.js @@ -19,7 +19,8 @@ const propTypes = { onInputChange: PropTypes.func, /** A ref to forward to MenuItemWithTopDescription */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Label to display on field */ label: PropTypes.string, diff --git a/src/components/TextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol.js index ef3fc3a1464a..e7538d4dac6a 100644 --- a/src/components/TextInputWithCurrencySymbol.js +++ b/src/components/TextInputWithCurrencySymbol.js @@ -6,7 +6,8 @@ import * as CurrencyUtils from '../libs/CurrencyUtils'; const propTypes = { /** A ref to forward to amount text input */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Formatted amount in local currency */ formattedAmount: PropTypes.string.isRequired, diff --git a/src/components/TextLink.js b/src/components/TextLink.js index a1b1cb4d1e8a..995b5c89db2d 100644 --- a/src/components/TextLink.js +++ b/src/components/TextLink.js @@ -24,7 +24,8 @@ const propTypes = { onMouseDown: PropTypes.func, /** A ref to forward to text */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; const defaultProps = { diff --git a/src/components/withAnimatedRef.js b/src/components/withAnimatedRef.js index 60cfd98e65e4..a84806d2cc46 100644 --- a/src/components/withAnimatedRef.js +++ b/src/components/withAnimatedRef.js @@ -17,7 +17,8 @@ export default function withAnimatedRef(WrappedComponent) { } WithAnimatedRef.displayName = `withAnimatedRef(${getComponentDisplayName(WrappedComponent)})`; WithAnimatedRef.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; WithAnimatedRef.defaultProps = { forwardedRef: undefined, diff --git a/src/components/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js index 75336c747210..dea89fb9a320 100644 --- a/src/components/withCurrentUserPersonalDetails.js +++ b/src/components/withCurrentUserPersonalDetails.js @@ -15,7 +15,8 @@ const withCurrentUserPersonalDetailsDefaultProps = { export default function (WrappedComponent) { const propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Personal details of all the users, including current user */ personalDetails: PropTypes.objectOf(personalDetailsPropType), diff --git a/src/components/withNavigation.js b/src/components/withNavigation.js index 4047cab72e1d..3ff96b64fef9 100644 --- a/src/components/withNavigation.js +++ b/src/components/withNavigation.js @@ -22,7 +22,8 @@ export default function withNavigation(WrappedComponent) { WithNavigation.displayName = `withNavigation(${getComponentDisplayName(WrappedComponent)})`; WithNavigation.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; WithNavigation.defaultProps = { forwardedRef: () => {}, diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js index 4537db2b7777..a7861c44b599 100644 --- a/src/components/withToggleVisibilityView.js +++ b/src/components/withToggleVisibilityView.js @@ -25,7 +25,8 @@ export default function (WrappedComponent) { WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; WithToggleVisibilityView.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Whether the content is visible. */ isVisible: PropTypes.bool, diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index 7178ed0e0158..173ef4574d9c 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -24,7 +24,8 @@ const propTypes = { isEditing: PropTypes.bool, /** Refs forwarded to the TextInputWithCurrencySymbol */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), /** Fired when back button pressed, navigates to currency selection page */ onCurrencyButtonPress: PropTypes.func.isRequired, From 1444433d51b585ebe36a87d80cd7b41e9b237e8a Mon Sep 17 00:00:00 2001 From: tienifr Date: Fri, 11 Aug 2023 11:06:23 +0700 Subject: [PATCH 035/340] update react native onyx version --- package-lock.json | 16 +++++++--------- package.json | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f23fe160a6b..75736f2c68ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "react-native-key-command": "^1.0.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.52", + "react-native-onyx": "1.0.59", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", @@ -42646,13 +42646,12 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.52", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.52.tgz", - "integrity": "sha512-lfIg+tSd+HZF00pr2oFoLY3iTdGYGC6Dd44/YktTsVKB/yIcUq61wBMOitX54Z54ac2/eHKFPicEr2xvBhE2VQ==", + "version": "1.0.59", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.59.tgz", + "integrity": "sha512-eDFRT3lGol651gjGmS7HMZVPJf/wrnLa3lQUWOeK5oD0D93I+e71brrHuUu0WoSiQVT6icp+0Wx3kEdds3+spw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", - "lodash": "^4.17.21", "underscore": "^1.13.1" }, "engines": { @@ -79121,13 +79120,12 @@ } }, "react-native-onyx": { - "version": "1.0.52", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.52.tgz", - "integrity": "sha512-lfIg+tSd+HZF00pr2oFoLY3iTdGYGC6Dd44/YktTsVKB/yIcUq61wBMOitX54Z54ac2/eHKFPicEr2xvBhE2VQ==", + "version": "1.0.59", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.59.tgz", + "integrity": "sha512-eDFRT3lGol651gjGmS7HMZVPJf/wrnLa3lQUWOeK5oD0D93I+e71brrHuUu0WoSiQVT6icp+0Wx3kEdds3+spw==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", - "lodash": "^4.17.21", "underscore": "^1.13.1" } }, diff --git a/package.json b/package.json index ac69af4c760b..4f45b9d18310 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "react-native-key-command": "^1.0.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.52", + "react-native-onyx": "1.0.59", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", From 3d4403e37dbdbb1e961ac96d3e5f0cc1dd4b8d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 11 Aug 2023 08:55:23 +0200 Subject: [PATCH 036/340] 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 d2dfdb34edc3..1b40cb3eda28 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 2806ab08fb29..e0e148f8c80e 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 037/340] 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 1b40cb3eda28..6843caa06add 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 038/340] 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 6843caa06add..946c6eb4d37b 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 039/340] 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 c403aa63c172..88fd250082c5 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 946c6eb4d37b..b4f3561981ac 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 040/340] 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 b4f3561981ac..e5a360960edd 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 a7609b7cf449..f1522be9bb98 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 041/340] 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 f1522be9bb98..4f34f32e1efc 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 042/340] 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 e5a360960edd..53daac2635dd 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 043/340] 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 26f63b92f37c..c7c07df423a0 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 a7fe3a19d288..81687a9d3dae 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 359cdf13c843..5291d29736a6 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 b7b890d8463afdebae080d3bf7d47031bb3db159 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 11 Aug 2023 12:01:12 +0200 Subject: [PATCH 044/340] add status details to personalDetailsSelector --- src/components/LHNOptionsList/OptionRowLHNData.js | 1 + src/libs/SidebarUtils.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 5b010593f1e7..21fade6eb942 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -137,6 +137,7 @@ const personalDetailsSelector = (personalDetails) => login: personalData.login, displayName: personalData.displayName, firstName: personalData.firstName, + status: personalData.status, avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID), }; return finalPersonalDetails; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 561d91a63a1f..665e10c2d917 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -263,6 +263,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, const subtitle = ReportUtils.getChatRoomSubtitle(report); const login = Str.removeSMSDomain(lodashGet(personalDetail, 'login', '')); + const status = lodashGet(personalDetail, 'status', ''); const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login; // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. @@ -352,6 +353,10 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); result.displayNamesWithTooltips = displayNamesWithTooltips; result.isLastMessageDeletedParentAction = report.isLastMessageDeletedParentAction; + + if (status) { + result.status = status; + } return result; } From 8e2e717c592534f7c6ded1d3e6997dcb6524e406 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 11 Aug 2023 12:31:18 +0200 Subject: [PATCH 045/340] add status next to user name --- src/components/LHNOptionsList/OptionRowLHN.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index a3fbb5e41378..26af48e5f72f 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import React, {useState, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; +import lodashGet from 'lodash/get'; import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -22,12 +23,17 @@ import * as ContextMenuActions from '../../pages/home/report/ContextMenu/Context import * as OptionsListUtils from '../../libs/OptionsListUtils'; import * as ReportUtils from '../../libs/ReportUtils'; import useLocalize from '../../hooks/useLocalize'; +import Permissions from '../../libs/Permissions'; +import Tooltip from '../Tooltip'; const propTypes = { /** Style for hovered state */ // eslint-disable-next-line react/forbid-prop-types hoverStyle: PropTypes.object, + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + /** The ID of the report that the option is for */ reportID: PropTypes.string.isRequired, @@ -54,6 +60,7 @@ const defaultProps = { style: null, optionItem: null, isFocused: false, + betas: [], }; function OptionRowLHN(props) { @@ -124,6 +131,10 @@ function OptionRowLHN(props) { ); }; + const emojiCode = lodashGet(optionItem, 'status.emojiCode', ''); + const statusContent = lodashGet(optionItem, 'status.text', ''); + const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && emojiCode && ReportUtils.isShowEmojiStatus(optionItem); + return ( + {isStatusVisible && ( + + {`${emojiCode}`} + + )} {optionItem.alternateText ? ( Date: Fri, 11 Aug 2023 13:36:09 +0200 Subject: [PATCH 046/340] add status to sidebar link --- src/components/AvatarWithIndicator.js | 27 ++++++++++++++--- src/libs/ReportUtils.js | 19 ++++++++++++ src/pages/home/sidebar/SidebarLinks.js | 42 ++++++++++++++++++++------ src/styles/styles.js | 19 ++++++++++++ 4 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index b59f67a45b7b..e8768778130d 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import Avatar from './Avatar'; import styles from '../styles/styles'; import Tooltip from './Tooltip'; +import Text from './Text'; import * as UserUtils from '../libs/UserUtils'; import Indicator from './Indicator'; @@ -13,18 +14,34 @@ const propTypes = { /** To show a tooltip on hover */ tooltipText: PropTypes.string, + + /** Persons emojy status */ + emojiStatus: PropTypes.string, }; const defaultProps = { tooltipText: '', + emojiStatus: '', }; -function AvatarWithIndicator(props) { +function AvatarWithIndicator({tooltipText, emojiStatus, source}) { return ( - - - - + + + {!!emojiStatus && ( + + + {emojiStatus} + + + )} + + + + ); diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index e8ef18a3ca27..39138266c8a2 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1162,6 +1162,24 @@ function isWaitingForIOUActionFromCurrentUser(report, allReportsDict = null) { return false; } +/** + * Should return true only for personal 1:1 report + * + * @param {Object} report (chatReport or iouReport) + * @returns {boolean} + */ +function isShowEmojiStatus(report) { + return ( + !report.isThread && + !report.isChatRoom && + !report.isExpenseRequest && + !report.isIOUReportOwner && + !report.isMoneyRequestReport && + !report.isPolicyExpenseChat && + !report.isTaskReport && + !report.isThread + ); +} function isWaitingForTaskCompleteFromAssignee(report) { return isTaskReport(report) && isTaskAssignee(report) && isOpenTaskReport(report); @@ -3024,4 +3042,5 @@ export { shouldDisableSettings, shouldDisableRename, hasSingleParticipant, + isShowEmojiStatus, }; diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 5747ed0e1d4a..24529f192605 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -4,6 +4,7 @@ import React from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; import styles from '../../../styles/styles'; import * as StyleUtils from '../../../styles/StyleUtils'; import ONYXKEYS from '../../../ONYXKEYS'; @@ -36,6 +37,7 @@ import KeyboardShortcut from '../../../libs/KeyboardShortcut'; import onyxSubscribe from '../../../libs/onyxSubscribe'; import personalDetailsPropType from '../../personalDetailsPropType'; import * as ReportActionContextMenu from '../report/ContextMenu/ReportActionContextMenu'; +import Permissions from '../../../libs/Permissions'; const basePropTypes = { /** Toggles the navigation menu open and closed */ @@ -59,6 +61,8 @@ const propTypes = { priorityMode: PropTypes.oneOf(_.values(CONST.PRIORITY_MODE)), + betas: PropTypes.arrayOf(PropTypes.string), + ...withLocalizePropTypes, }; @@ -67,6 +71,7 @@ const defaultProps = { avatar: '', }, priorityMode: CONST.PRIORITY_MODE.DEFAULT, + betas: [], }; class SidebarLinks extends React.PureComponent { @@ -74,7 +79,7 @@ class SidebarLinks extends React.PureComponent { super(props); this.showSearchPage = this.showSearchPage.bind(this); - this.showSettingsPage = this.showSettingsPage.bind(this); + this.onHandlePressAvatar = this.onHandlePressAvatar.bind(this); this.showReportPage = this.showReportPage.bind(this); if (this.props.isSmallScreenWidth) { @@ -124,22 +129,26 @@ class SidebarLinks extends React.PureComponent { } } - showSearchPage() { + onHandlePressAvatar() { if (this.props.isCreateMenuOpen) { - // Prevent opening Search page when click Search icon quickly after clicking FAB icon + // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon return; } - - Navigation.navigate(ROUTES.SEARCH); + const emojiCode = lodashGet(this.props.currentUserPersonalDetails, 'status.emojiCode', ''); + if (emojiCode && Permissions.canUseCustomStatus(this.props.betas)) { + Navigation.navigate(ROUTES.SETTINGS_STATUS); + } else { + Navigation.navigate(ROUTES.SETTINGS); + } } - showSettingsPage() { + showSearchPage() { if (this.props.isCreateMenuOpen) { - // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon + // Prevent opening Search page when click Search icon quickly after clicking FAB icon return; } - Navigation.navigate(ROUTES.SETTINGS); + Navigation.navigate(ROUTES.SEARCH); } /** @@ -161,6 +170,9 @@ class SidebarLinks extends React.PureComponent { } render() { + const statusEmojiCode = lodashGet(this.props.currentUserPersonalDetails, 'status.emojiCode', ''); + const emojiStatus = Permissions.canUseCustomStatus(this.props.betas) ? statusEmojiCode : ''; + return ( {Session.isAnonymousUser() ? ( @@ -207,6 +219,7 @@ class SidebarLinks extends React.PureComponent { )} @@ -230,5 +243,14 @@ class SidebarLinks extends React.PureComponent { SidebarLinks.propTypes = propTypes; SidebarLinks.defaultProps = defaultProps; -export default compose(withLocalize, withCurrentUserPersonalDetails, withWindowDimensions)(SidebarLinks); +export default compose( + withLocalize, + withCurrentUserPersonalDetails, + withWindowDimensions, + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + }), +)(SidebarLinks); export {basePropTypes}; diff --git a/src/styles/styles.js b/src/styles/styles.js index 2d5514d6df39..5fce4ef3b021 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3681,6 +3681,25 @@ const styles = { rotate90: { transform: [{rotate: '90deg'}], }, + + emojiStatusLHN: { + fontSize: 22, + }, + sidebarStatusAvatarContainer: { + height: 44, + width: 84, + backgroundColor: themeColors.componentBG, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderRadius: 42, + paddingHorizontal: 2, + }, + sidebarStatusAvatar: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, }; export default styles; From 2550706dce9754705e943375bf324f85b78b1fb7 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 11 Aug 2023 13:40:05 +0200 Subject: [PATCH 047/340] remove unused code --- src/components/LHNOptionsList/OptionRowLHN.js | 2 +- .../home/sidebar/SidebarScreen/BaseSidebarScreen.js | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 26af48e5f72f..47a6a5e47e29 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -134,7 +134,7 @@ function OptionRowLHN(props) { const emojiCode = lodashGet(optionItem, 'status.emojiCode', ''); const statusContent = lodashGet(optionItem, 'status.text', ''); const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && emojiCode && ReportUtils.isShowEmojiStatus(optionItem); - + return ( { - Navigation.navigate(ROUTES.SETTINGS); -}; - /** * Function called when a pinned chat is selected. */ @@ -50,7 +41,6 @@ function BaseSidebarScreen(props) { 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 048/340] 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 c7c07df423a0..189a482a3651 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 fdecedf3d5b4..785503eb5a07 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 329ef939c332be03999605b3707df642820fde77 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 11 Aug 2023 17:17:43 +0200 Subject: [PATCH 049/340] add time in status --- src/components/LHNOptionsList/OptionRowLHN.js | 6 +++- src/libs/DateUtils.js | 33 +++++++++++++++++++ .../Profile/CustomStatus/StatusPage.js | 4 +-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 47a6a5e47e29..1aa173477bbe 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -6,6 +6,7 @@ import lodashGet from 'lodash/get'; import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; +import DateUtils from '../../libs/DateUtils'; import Icon from '../Icon'; import * as Expensicons from '../Icon/Expensicons'; import MultipleAvatars from '../MultipleAvatars'; @@ -132,7 +133,10 @@ function OptionRowLHN(props) { }; const emojiCode = lodashGet(optionItem, 'status.emojiCode', ''); - const statusContent = lodashGet(optionItem, 'status.text', ''); + const statusText = lodashGet(optionItem, 'status.text', ''); + const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', ''); + const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); + const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && emojiCode && ReportUtils.isShowEmojiStatus(optionItem); return ( diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index 6be627dc643d..04bd32c5b98a 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -205,6 +205,38 @@ function getDateStringFromISOTimestamp(isoTimestamp) { return dateString; } +/** + * receive date like 2020-05-16 05:34:14 and format it to show in string like "Until 05:34 PM" + * + * @param {String} inputDate + * @returns {String} + */ +function getStatusUntilDate(inputDate) { + if (!inputDate) return ''; + + const input = moment(inputDate, 'YYYY-MM-DD HH:mm:ss'); + const now = moment(); + const endOfToday = moment().endOf('day').format('YYYY-MM-DD HH:mm:ss'); + + // If the date is equal to the end of today + if (input.isSame(endOfToday)) { + return 'Until tomorrow'; + } + + // If it's a time on the same date + if (input.isSame(now, 'day')) { + return `Until ${input.format('hh:mm A')}`; + } + + // If it's further in the future than tomorrow but within the same year + if (input.isAfter(now) && input.isSame(now, 'year')) { + return `Until ${input.format('MM-DD hh:mm A')}`; + } + + // If it's in another year + return `Until ${input.format('YYYY-MM-DD hh:mm A')}`; +} + /** * @namespace DateUtils */ @@ -220,6 +252,7 @@ const DateUtils = { getDBTime, subtractMillisecondsFromDateTime, getDateStringFromISOTimestamp, + getStatusUntilDate, }; export default DateUtils; diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js index 9767825820cd..061997e79044 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js @@ -38,8 +38,8 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { const hasDraftStatus = !!draftEmojiCode || !!draftText; const updateStatus = useCallback(() => { - const endOfDay = moment().endOf('day').toDate(); - User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji, clearAfter: endOfDay.toISOString()}); + const endOfDay = moment().endOf('day').format('YYYY-MM-DD HH:mm:ss'); + User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji, clearAfter: endOfDay}); User.clearDraftCustomStatus(); Navigation.goBack(ROUTES.SETTINGS); 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 050/340] 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 65db434981ec..e2adf235dd0e 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 1b276e3355e5b5f780793c5c23a78a4abaf4b52e Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 12 Aug 2023 13:08:49 +0800 Subject: [PATCH 051/340] allow emoji picker to accept id instead of report action when showing --- src/components/EmojiPicker/EmojiPicker.js | 16 ++++++++-------- .../EmojiPicker/EmojiPickerButton.js | 12 ++++-------- src/components/Reactions/AddReactionBubble.js | 2 +- .../Reactions/MiniQuickEmojiReactions.js | 2 +- src/libs/actions/EmojiPickerAction.js | 18 +++++++++--------- src/pages/home/report/ReportActionCompose.js | 1 + src/pages/home/report/ReportActionItem.js | 2 +- .../home/report/ReportActionItemMessageEdit.js | 4 ++-- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 2a3a7ba296f2..e3ad9d91b651 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -27,8 +27,8 @@ const EmojiPicker = forwardRef((props, ref) => { horizontal: 0, vertical: 0, }); - const [reportAction, setReportAction] = useState({}); const [emojiPopoverAnchorOrigin, setEmojiPopoverAnchorOrigin] = useState(DEFAULT_ANCHOR_ORIGIN); + const [activeID, setActiveID] = useState(); const emojiPopoverAnchor = useRef(null); const onModalHide = useRef(() => {}); const onEmojiSelected = useRef(() => {}); @@ -42,9 +42,9 @@ const EmojiPicker = forwardRef((props, ref) => { * @param {Element} emojiPopoverAnchorValue - Element to which Popover is anchored * @param {Object} [anchorOrigin=DEFAULT_ANCHOR_ORIGIN] - Anchor origin for Popover * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show - * @param {Object} reportActionValue - ReportAction for EmojiPicker + * @param {Object} id - Unique id for EmojiPicker */ - const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, reportActionValue) => { + const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, id) => { onModalHide.current = onModalHideValue; onEmojiSelected.current = onEmojiSelectedValue; emojiPopoverAnchor.current = emojiPopoverAnchorValue; @@ -60,7 +60,7 @@ const EmojiPicker = forwardRef((props, ref) => { setIsEmojiPickerVisible(true); setEmojiPopoverAnchorPosition(value); setEmojiPopoverAnchorOrigin(anchorOriginValue); - setReportAction(reportActionValue); + setActiveID(id); }); }; @@ -107,16 +107,16 @@ const EmojiPicker = forwardRef((props, ref) => { }; /** - * Whether Context Menu is active for the Report Action. + * Whether emoji picker is active for the given id. * - * @param {Number|String} actionID + * @param {Number|String} id * @return {Boolean} */ - const isActiveReportAction = (actionID) => Boolean(actionID) && reportAction.reportActionID === actionID; + const isActive = (id) => Boolean(id) && id === activeID; const resetEmojiPopoverAnchor = () => (emojiPopoverAnchor.current = null); - useImperativeHandle(ref, () => ({showEmojiPicker, isActiveReportAction, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor})); + useImperativeHandle(ref, () => ({showEmojiPicker, isActive, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor})); useEffect(() => { if (isEmojiPickerVisible) { diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index c78e9fdd285a..d86f741d7681 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -17,12 +17,8 @@ const propTypes = { /** Id to use for the emoji picker button */ nativeID: PropTypes.string, - /** - * ReportAction for EmojiPicker. - */ - reportAction: PropTypes.shape({ - reportActionID: PropTypes.string, - }), + /** Unique id for emoji picker */ + emojiPickerID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), ...withLocalizePropTypes, }; @@ -30,7 +26,7 @@ const propTypes = { const defaultProps = { isDisabled: false, nativeID: '', - reportAction: {}, + emojiPickerID: '', }; function EmojiPickerButton(props) { @@ -46,7 +42,7 @@ function EmojiPickerButton(props) { disabled={props.isDisabled} onPress={() => { if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.reportAction); + EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID); } else { EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); } diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js index de34ebf95242..922be96084d8 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.js @@ -67,7 +67,7 @@ function AddReactionBubble(props) { refParam || ref.current, anchorOrigin, props.onWillShowPicker, - props.reportAction, + props.reportAction.reportActionID, ); }; diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index 1ebb8a971827..82f83cb1e961 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -67,7 +67,7 @@ function MiniQuickEmojiReactions(props) { ref.current, undefined, () => {}, - props.reportAction, + props.reportAction.reportActionID, ); }; diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js index de462ac283f3..f8322508731e 100644 --- a/src/libs/actions/EmojiPickerAction.js +++ b/src/libs/actions/EmojiPickerAction.js @@ -3,21 +3,21 @@ import React from 'react'; const emojiPickerRef = React.createRef(); /** - * Show the ReportActionContextMenu modal popover. + * Show the EmojiPicker modal popover. * * @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides. * @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected. * @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored * @param {Object} [anchorOrigin] - Anchor origin for Popover * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show - * @param {Object} reportAction - ReportAction for EmojiPicker + * @param {Object} id - Unique id for EmojiPicker */ -function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, reportAction = {}) { +function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, id) { if (!emojiPickerRef.current) { return; } - emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, reportAction); + emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id); } /** @@ -33,16 +33,16 @@ function hideEmojiPicker(isNavigating) { } /** - * Whether Emoji Picker is active for the Report Action. + * Whether Emoji Picker is active for the given id. * - * @param {Number|String} actionID + * @param {Number|String} id * @return {Boolean} */ -function isActiveReportAction(actionID) { +function isActive(id) { if (!emojiPickerRef.current) { return; } - return emojiPickerRef.current.isActiveReportAction(actionID); + return emojiPickerRef.current.isActive(id); } function isEmojiPickerVisible() { @@ -59,4 +59,4 @@ function resetEmojiPopoverAnchor() { return emojiPickerRef.current.resetEmojiPopoverAnchor(); } -export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActiveReportAction, isEmojiPickerVisible, resetEmojiPopoverAnchor}; +export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index ef783a208ef8..d2bdaaccc953 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -1197,6 +1197,7 @@ class ReportActionCompose extends React.Component { this.focus(true); }} onEmojiSelected={this.replaceSelectionWithText} + emojiPickerID={this.props.report.reportID} /> )} { // Skip if this is not the focused message so the other edit composer stays focused. // In small screen devices, when EmojiPicker is shown, the current edit message will lose focus, we need to check this case as well. - if (!isFocusedRef.current && !EmojiPickerAction.isActiveReportAction(props.action.reportActionID)) { + if (!isFocusedRef.current && !EmojiPickerAction.isActive(props.action.reportActionID)) { return; } @@ -358,7 +358,7 @@ function ReportActionItemMessageEdit(props) { }} onEmojiSelected={addEmojiToTextBox} nativeID={emojiButtonID} - reportAction={props.action} + emojiPickerID={props.action.reportActionID} /> From 675becaf4beca39d6e6177d339d87fd18ad14487 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 12 Aug 2023 13:09:21 +0800 Subject: [PATCH 052/340] only hide the emoji picker when it's the active one --- src/pages/home/ReportScreen.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index c859bc6b8f05..a4ed7a476380 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -156,7 +156,7 @@ class ReportScreen extends React.Component { componentDidUpdate(prevProps) { // If composer should be hidden, hide emoji picker as well - if (ReportUtils.shouldHideComposer(this.props.report)) { + if (ReportUtils.shouldHideComposer(this.props.report) && EmojiPickerAction.isActive(this.props.report.reportID)) { EmojiPickerAction.hideEmojiPicker(true); } From d5f8711ab305df1ee7f19d570717e3bc067f0369 Mon Sep 17 00:00:00 2001 From: tienifr Date: Sat, 12 Aug 2023 13:34:01 +0700 Subject: [PATCH 053/340] fix: 24375 --- .../AttachmentView/AttachmentViewPdf/index.native.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js index dc329a9fd3fd..86bd3e08cf20 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js @@ -1,4 +1,4 @@ -import React, {memo, useCallback, useContext} from 'react'; +import React, {memo, useCallback, useContext, useEffect} from 'react'; import styles from '../../../../styles/styles'; import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes'; import PDFView from '../../../PDFView'; @@ -7,14 +7,21 @@ import AttachmentCarouselPagerContext from '../../AttachmentCarousel/Pager/Attac function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + useEffect(() => { + attachmentCarouselPagerContext.onPinchGestureChange(false); + }, []); + const onScaleChanged = useCallback( (scale) => { + console.log('onScaleChangeddddddd', scale); onScaleChangedProp(); // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in if (isUsedInCarousel) { const shouldPagerScroll = scale === 1; + attachmentCarouselPagerContext.onPinchGestureChange(scale !== 1); + if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) return; attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll; From 7141665c777d164061ebd8ab4638d2591ab0a5fd Mon Sep 17 00:00:00 2001 From: Mahesh Vagicherla Date: Sun, 13 Aug 2023 01:22:14 +0530 Subject: [PATCH 054/340] chore: update extensions allowed by ios app --- src/CONST.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/CONST.js b/src/CONST.js index 4c19965837d9..5d8e533fc543 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -787,8 +787,10 @@ const CONST = { }, FILE_TYPE_REGEX: { - IMAGE: /\.(jpg|jpeg|png|webp|avif|gif|tiff|wbmp|ico|jng|bmp|heic|svg|svg2)$/, - VIDEO: /\.(3gp|h261|h263|h264|m4s|jpgv|jpm|jpgm|mp4|mp4v|mpg4|mpeg|mpg|ogv|ogg|mov|qt|webm|flv|mkv|wmv|wav|avi|movie|f4v|avchd|mp2|mpe|mpv|m4v|swf)$/, + // Image MimeTypes allowed by iOS photos app. + IMAGE: /\.(jpg|jpeg|png|webp|gif|tiff|bmp|heic|heif)$/, + // Video MimeTypes allowed by iOS photos app. + VIDEO: /\.(mov|mp4)$/, }, IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, From 6720f6d9a5d8077fa4203873aed6ff131a273acd Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:14:20 +0200 Subject: [PATCH 055/340] split LoginForm into base and index.js files --- .../BaseLoginForm.js} | 48 +++++++++---------- src/pages/signin/LoginForm/index.js | 21 ++++++++ 2 files changed, 45 insertions(+), 24 deletions(-) rename src/pages/signin/{LoginForm.js => LoginForm/BaseLoginForm.js} (86%) create mode 100644 src/pages/signin/LoginForm/index.js diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js similarity index 86% rename from src/pages/signin/LoginForm.js rename to src/pages/signin/LoginForm/BaseLoginForm.js index 5f973f7113c1..c043aae00fe8 100644 --- a/src/pages/signin/LoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -5,30 +5,30 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import Str from 'expensify-common/lib/str'; import {parsePhoneNumber} from 'awesome-phonenumber'; -import styles from '../../styles/styles'; -import Text from '../../components/Text'; -import * as Session from '../../libs/actions/Session'; -import ONYXKEYS from '../../ONYXKEYS'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; -import compose from '../../libs/compose'; -import canFocusInputOnScreenFocus from '../../libs/canFocusInputOnScreenFocus'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import TextInput from '../../components/TextInput'; -import * as ValidationUtils from '../../libs/ValidationUtils'; -import * as LoginUtils from '../../libs/LoginUtils'; -import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../components/withToggleVisibilityView'; -import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; -import {withNetwork} from '../../components/OnyxProvider'; -import networkPropTypes from '../../components/networkPropTypes'; -import * as ErrorUtils from '../../libs/ErrorUtils'; -import DotIndicatorMessage from '../../components/DotIndicatorMessage'; -import * as CloseAccount from '../../libs/actions/CloseAccount'; -import CONST from '../../CONST'; -import isInputAutoFilled from '../../libs/isInputAutoFilled'; -import * as PolicyUtils from '../../libs/PolicyUtils'; -import Log from '../../libs/Log'; -import withNavigationFocus, {withNavigationFocusPropTypes} from '../../components/withNavigationFocus'; -import usePrevious from '../../hooks/usePrevious'; +import styles from '../../../styles/styles'; +import Text from '../../../components/Text'; +import * as Session from '../../../libs/actions/Session'; +import ONYXKEYS from '../../../ONYXKEYS'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; +import compose from '../../../libs/compose'; +import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import TextInput from '../../../components/TextInput'; +import * as ValidationUtils from '../../../libs/ValidationUtils'; +import * as LoginUtils from '../../../libs/LoginUtils'; +import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../../components/withToggleVisibilityView'; +import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton'; +import {withNetwork} from '../../../components/OnyxProvider'; +import networkPropTypes from '../../../components/networkPropTypes'; +import * as ErrorUtils from '../../../libs/ErrorUtils'; +import DotIndicatorMessage from '../../../components/DotIndicatorMessage'; +import * as CloseAccount from '../../../libs/actions/CloseAccount'; +import CONST from '../../../CONST'; +import isInputAutoFilled from '../../../libs/isInputAutoFilled'; +import * as PolicyUtils from '../../../libs/PolicyUtils'; +import Log from '../../../libs/Log'; +import withNavigationFocus, {withNavigationFocusPropTypes} from '../../../components/withNavigationFocus'; +import usePrevious from '../../../hooks/usePrevious'; const propTypes = { /** Should we dismiss the keyboard when transitioning away from the page? */ diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js new file mode 100644 index 000000000000..dc2edaebb0ba --- /dev/null +++ b/src/pages/signin/LoginForm/index.js @@ -0,0 +1,21 @@ +import React from 'react'; +import BaseLoginForm from './BaseLoginForm'; +import {propTypes, defaultProps} from './loginFormPropTypes'; + +const propTypes = {}; +const defaultProps = {}; + +function LoginForm(props) { + return ( + + ); +} + +LoginForm.displayName = 'LoginForm'; +LoginForm.propTypes = propTypes; +LoginForm.defaultProps = defaultProps; + +export default LoginForm; \ No newline at end of file From f08ae2016e3445db4d77723cdcd48386eb8ea5b3 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:14:52 +0200 Subject: [PATCH 056/340] added newline at file end --- src/pages/signin/LoginForm/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index dc2edaebb0ba..9051ceb0fd03 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -18,4 +18,4 @@ LoginForm.displayName = 'LoginForm'; LoginForm.propTypes = propTypes; LoginForm.defaultProps = defaultProps; -export default LoginForm; \ No newline at end of file +export default LoginForm; From 4b1de5c2cb63664e9f887ef171cdbeed37743005 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:17:43 +0200 Subject: [PATCH 057/340] exposed scrollPageToTop in the SignInPageLayout --- src/pages/signin/SignInPageLayout/index.js | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js index 12f223de33f7..a8cc4f5fcc49 100644 --- a/src/pages/signin/SignInPageLayout/index.js +++ b/src/pages/signin/SignInPageLayout/index.js @@ -1,4 +1,4 @@ -import React, {useRef, useEffect} from 'react'; +import React, {forwardRef, useRef, useEffect, useImperativeHandle} from 'react'; import {View, ScrollView} from 'react-native'; import {withSafeAreaInsets} from 'react-native-safe-area-context'; import PropTypes from 'prop-types'; @@ -35,10 +35,17 @@ const propTypes = { /** Whether to show welcome header on a particular page */ shouldShowWelcomeHeader: PropTypes.bool.isRequired, + /** A reference so we can expose scrollPageToTop */ + innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; +const defaultProps = { + innerRef: () => {}, +} + function SignInPageLayout(props) { const scrollViewRef = useRef(); const prevPreferredLocale = usePrevious(props.preferredLocale); @@ -60,6 +67,10 @@ function SignInPageLayout(props) { scrollViewRef.current.scrollTo({y: 0, animated}); }; + useImperativeHandle(props.innerRef, () => ({ + scrollPageToTop, + })); + useEffect(() => { if (prevPreferredLocale !== props.preferredLocale) { return; @@ -146,6 +157,15 @@ function SignInPageLayout(props) { } SignInPageLayout.propTypes = propTypes; +SignInPageLayout.defaultProps = defaultProps; SignInPageLayout.displayName = 'SignInPageLayout'; -export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)(SignInPageLayout); +export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)( + forwardRef((props, ref) => ( + + )), +); From 1f7fe9ab0c211a0684f71eca3f28e0f368edfee3 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:19:52 +0200 Subject: [PATCH 058/340] started passing scrollPageToTop to the LoginForm --- src/pages/signin/LoginForm/index.js | 5 ++++- src/pages/signin/SignInPage.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index 9051ceb0fd03..5b2aa4f81d9c 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -2,7 +2,10 @@ import React from 'react'; import BaseLoginForm from './BaseLoginForm'; import {propTypes, defaultProps} from './loginFormPropTypes'; -const propTypes = {}; +const propTypes = { + /** Function used to scroll to the top of the page */ + scrollPageToTop: PropTypes.func, +}; const defaultProps = {}; function LoginForm(props) { diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index f6eb4f9306f3..73f38b0ad1c7 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; @@ -81,6 +81,7 @@ function SignInPage({credentials, account}) { const {translate, formatPhoneNumber} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); + const signInPageLayoutRef = useRef(); useEffect(() => Performance.measureTTI(), []); useEffect(() => { @@ -143,12 +144,14 @@ function SignInPage({credentials, account}) { welcomeText={welcomeText} shouldShowWelcomeHeader={shouldShowWelcomeHeader || !isSmallScreenWidth} shouldShowWelcomeText={shouldShowWelcomeText} + ref={signInPageLayoutRef} > {/* LoginForm must use the isVisible prop. This keeps it mounted, but visually hidden so that password managers can access the values. Conditionally rendering this component will break this feature. */} {shouldShowValidateCodeForm && } {shouldShowUnlinkLoginForm && } From 5350167543b08fc2d325fbde9ed84c389fc509e9 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:25:13 +0200 Subject: [PATCH 059/340] exposed the input focus state of the BaseLoginForm with isInputFocused --- src/pages/signin/LoginForm/BaseLoginForm.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index c043aae00fe8..9c89dfd50f61 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -34,6 +34,9 @@ const propTypes = { /** Should we dismiss the keyboard when transitioning away from the page? */ blurOnSubmit: PropTypes.bool, + /** A reference so we can expose if the form input is focused */ + innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + /* Onyx Props */ /** The details about the account that the user is signing in with */ @@ -176,6 +179,12 @@ function LoginForm(props) { input.current.focus(); }, [props.blurOnSubmit, props.isVisible, prevIsVisible]); + useImperativeHandle(props.innerRef, () => ({ + isInputFocused() { + return input.current && input.current.isFocused(); + } + })); + const formErrorText = useMemo(() => (formError ? translate(formError) : ''), [formError, translate]); const serverErrorText = useMemo(() => ErrorUtils.getLatestErrorMessage(props.account), [props.account]); const hasError = !_.isEmpty(serverErrorText); @@ -249,4 +258,12 @@ export default compose( withLocalize, withToggleVisibilityView, withNetwork(), -)(LoginForm); +)( + forwardRef((props, ref) => ( + + )), +); From ba3e8d9a15ba91e113b24dd4fb7d073942b84fd7 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:26:59 +0200 Subject: [PATCH 060/340] added native scrolling logic for the LoginForm --- src/pages/signin/LoginForm/index.native.js | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/pages/signin/LoginForm/index.native.js diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js new file mode 100644 index 000000000000..ece99a0ba55f --- /dev/null +++ b/src/pages/signin/LoginForm/index.native.js @@ -0,0 +1,46 @@ +import React, {useEffect, useRef} from 'react'; +import BaseLoginForm from './BaseLoginForm'; +import AppStateMonitor from '../../../libs/AppStateMonitor'; + +const propTypes = { + /** Function used to scroll to the top of the page */ + scrollPageToTop: PropTypes.func, +}; +const defaultProps = {}; + +function LoginForm(props) { + const loginFormRef = useRef(); + const {scrollPageToTop} = props; + + useEffect(() => { + if (!scrollPageToTop) { + return; + } + + const unsubscribeToBecameActiveListener = AppStateMonitor.addBecameActiveListener(() => { + const isInputFocused = loginFormRef.current && loginFormRef.current.isInputFocused(); + if (!isInputFocused) { + return; + } + + scrollPageToTop(); + }); + + // Remove the subscription on cleanup + return unsubscribeToBecameActiveListener; + }, [scrollPageToTop]); + + return ( + (loginFormRef.current = ref)} + /> + ); +} + +LoginForm.displayName = 'LoginForm'; +LoginForm.propTypes = propTypes; +LoginForm.defaultProps = defaultProps; + +export default LoginForm; From 9faa879573f7e981fea807f5e96ce1690d7676a3 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:38:17 +0200 Subject: [PATCH 061/340] added PropTypes import --- src/pages/signin/LoginForm/index.js | 2 +- src/pages/signin/LoginForm/index.native.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index 5b2aa4f81d9c..e4eebd8cf8af 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -1,6 +1,6 @@ import React from 'react'; import BaseLoginForm from './BaseLoginForm'; -import {propTypes, defaultProps} from './loginFormPropTypes'; +import PropTypes from 'prop-types'; const propTypes = { /** Function used to scroll to the top of the page */ diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js index ece99a0ba55f..258b2362ec01 100644 --- a/src/pages/signin/LoginForm/index.native.js +++ b/src/pages/signin/LoginForm/index.native.js @@ -1,6 +1,7 @@ import React, {useEffect, useRef} from 'react'; import BaseLoginForm from './BaseLoginForm'; import AppStateMonitor from '../../../libs/AppStateMonitor'; +import PropTypes from 'prop-types'; const propTypes = { /** Function used to scroll to the top of the page */ From 5734483fc283c252cb314ae6a9ee368f1b01955a Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:46:02 +0200 Subject: [PATCH 062/340] fixed issues found by linter --- src/pages/signin/LoginForm/BaseLoginForm.js | 1 + src/pages/signin/LoginForm/index.js | 6 ++++-- src/pages/signin/LoginForm/index.native.js | 6 ++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 9c89dfd50f61..f227b2babe71 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -72,6 +72,7 @@ const defaultProps = { account: {}, closeAccount: {}, blurOnSubmit: false, + innerRef: () => {}, }; /** diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index e4eebd8cf8af..404cb6afa874 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -1,12 +1,14 @@ import React from 'react'; -import BaseLoginForm from './BaseLoginForm'; import PropTypes from 'prop-types'; +import BaseLoginForm from './BaseLoginForm'; const propTypes = { /** Function used to scroll to the top of the page */ scrollPageToTop: PropTypes.func, }; -const defaultProps = {}; +const defaultProps = { + scrollPageToTop: () => {}, +}; function LoginForm(props) { return ( diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js index 258b2362ec01..65cf9ca22fcd 100644 --- a/src/pages/signin/LoginForm/index.native.js +++ b/src/pages/signin/LoginForm/index.native.js @@ -1,13 +1,15 @@ import React, {useEffect, useRef} from 'react'; +import PropTypes from 'prop-types'; import BaseLoginForm from './BaseLoginForm'; import AppStateMonitor from '../../../libs/AppStateMonitor'; -import PropTypes from 'prop-types'; const propTypes = { /** Function used to scroll to the top of the page */ scrollPageToTop: PropTypes.func, }; -const defaultProps = {}; +const defaultProps = { + scrollPageToTop: () => {}, +}; function LoginForm(props) { const loginFormRef = useRef(); From 3548debf2c84b9099d816d834caa0ac76d1013bc Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 15:05:23 +0200 Subject: [PATCH 063/340] fixed prettier diff --- src/pages/signin/LoginForm/BaseLoginForm.js | 2 +- src/pages/signin/LoginForm/index.js | 2 +- src/pages/signin/LoginForm/index.native.js | 2 +- src/pages/signin/SignInPageLayout/index.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index f227b2babe71..240c09c6196f 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -183,7 +183,7 @@ function LoginForm(props) { useImperativeHandle(props.innerRef, () => ({ isInputFocused() { return input.current && input.current.isFocused(); - } + }, })); const formErrorText = useMemo(() => (formError ? translate(formError) : ''), [formError, translate]); diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index 404cb6afa874..8d284258d9fc 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import BaseLoginForm from './BaseLoginForm'; const propTypes = { - /** Function used to scroll to the top of the page */ + /** Function used to scroll to the top of the page */ scrollPageToTop: PropTypes.func, }; const defaultProps = { diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js index 65cf9ca22fcd..a4b32d55e137 100644 --- a/src/pages/signin/LoginForm/index.native.js +++ b/src/pages/signin/LoginForm/index.native.js @@ -4,7 +4,7 @@ import BaseLoginForm from './BaseLoginForm'; import AppStateMonitor from '../../../libs/AppStateMonitor'; const propTypes = { - /** Function used to scroll to the top of the page */ + /** Function used to scroll to the top of the page */ scrollPageToTop: PropTypes.func, }; const defaultProps = { diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js index a8cc4f5fcc49..ca1bbe4ff106 100644 --- a/src/pages/signin/SignInPageLayout/index.js +++ b/src/pages/signin/SignInPageLayout/index.js @@ -44,7 +44,7 @@ const propTypes = { const defaultProps = { innerRef: () => {}, -} +}; function SignInPageLayout(props) { const scrollViewRef = useRef(); From 770c9aa2075efd56aa7debc5d39b123162e255b0 Mon Sep 17 00:00:00 2001 From: Johannes Idarsson <138504087+joh42@users.noreply.github.com> Date: Sun, 13 Aug 2023 15:29:29 +0200 Subject: [PATCH 064/340] another prettier fix --- src/pages/signin/SignInPageLayout/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js index ca1bbe4ff106..8ea7b42596a2 100644 --- a/src/pages/signin/SignInPageLayout/index.js +++ b/src/pages/signin/SignInPageLayout/index.js @@ -160,7 +160,11 @@ SignInPageLayout.propTypes = propTypes; SignInPageLayout.defaultProps = defaultProps; SignInPageLayout.displayName = 'SignInPageLayout'; -export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)( +export default compose( + withWindowDimensions, + withSafeAreaInsets, + withLocalize, +)( forwardRef((props, ref) => ( Date: Sun, 13 Aug 2023 15:37:01 +0200 Subject: [PATCH 065/340] invisible prettier fix lol --- src/pages/signin/LoginForm/index.js | 52 ++++++------ src/pages/signin/LoginForm/index.native.js | 98 +++++++++++----------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js index 8d284258d9fc..b9262c3fb38a 100644 --- a/src/pages/signin/LoginForm/index.js +++ b/src/pages/signin/LoginForm/index.js @@ -1,26 +1,26 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import BaseLoginForm from './BaseLoginForm'; - -const propTypes = { - /** Function used to scroll to the top of the page */ - scrollPageToTop: PropTypes.func, -}; -const defaultProps = { - scrollPageToTop: () => {}, -}; - -function LoginForm(props) { - return ( - - ); -} - -LoginForm.displayName = 'LoginForm'; -LoginForm.propTypes = propTypes; -LoginForm.defaultProps = defaultProps; - -export default LoginForm; +import React from 'react'; +import PropTypes from 'prop-types'; +import BaseLoginForm from './BaseLoginForm'; + +const propTypes = { + /** Function used to scroll to the top of the page */ + scrollPageToTop: PropTypes.func, +}; +const defaultProps = { + scrollPageToTop: () => {}, +}; + +function LoginForm(props) { + return ( + + ); +} + +LoginForm.displayName = 'LoginForm'; +LoginForm.propTypes = propTypes; +LoginForm.defaultProps = defaultProps; + +export default LoginForm; diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js index a4b32d55e137..5c07d96f365f 100644 --- a/src/pages/signin/LoginForm/index.native.js +++ b/src/pages/signin/LoginForm/index.native.js @@ -1,49 +1,49 @@ -import React, {useEffect, useRef} from 'react'; -import PropTypes from 'prop-types'; -import BaseLoginForm from './BaseLoginForm'; -import AppStateMonitor from '../../../libs/AppStateMonitor'; - -const propTypes = { - /** Function used to scroll to the top of the page */ - scrollPageToTop: PropTypes.func, -}; -const defaultProps = { - scrollPageToTop: () => {}, -}; - -function LoginForm(props) { - const loginFormRef = useRef(); - const {scrollPageToTop} = props; - - useEffect(() => { - if (!scrollPageToTop) { - return; - } - - const unsubscribeToBecameActiveListener = AppStateMonitor.addBecameActiveListener(() => { - const isInputFocused = loginFormRef.current && loginFormRef.current.isInputFocused(); - if (!isInputFocused) { - return; - } - - scrollPageToTop(); - }); - - // Remove the subscription on cleanup - return unsubscribeToBecameActiveListener; - }, [scrollPageToTop]); - - return ( - (loginFormRef.current = ref)} - /> - ); -} - -LoginForm.displayName = 'LoginForm'; -LoginForm.propTypes = propTypes; -LoginForm.defaultProps = defaultProps; - -export default LoginForm; +import React, {useEffect, useRef} from 'react'; +import PropTypes from 'prop-types'; +import BaseLoginForm from './BaseLoginForm'; +import AppStateMonitor from '../../../libs/AppStateMonitor'; + +const propTypes = { + /** Function used to scroll to the top of the page */ + scrollPageToTop: PropTypes.func, +}; +const defaultProps = { + scrollPageToTop: () => {}, +}; + +function LoginForm(props) { + const loginFormRef = useRef(); + const {scrollPageToTop} = props; + + useEffect(() => { + if (!scrollPageToTop) { + return; + } + + const unsubscribeToBecameActiveListener = AppStateMonitor.addBecameActiveListener(() => { + const isInputFocused = loginFormRef.current && loginFormRef.current.isInputFocused(); + if (!isInputFocused) { + return; + } + + scrollPageToTop(); + }); + + // Remove the subscription on cleanup + return unsubscribeToBecameActiveListener; + }, [scrollPageToTop]); + + return ( + (loginFormRef.current = ref)} + /> + ); +} + +LoginForm.displayName = 'LoginForm'; +LoginForm.propTypes = propTypes; +LoginForm.defaultProps = defaultProps; + +export default LoginForm; From 78d8b207dcd611ef49007b9288cdb4e329d42486 Mon Sep 17 00:00:00 2001 From: Sam Hariri <137707942+samh-nl@users.noreply.github.com> Date: Mon, 14 Aug 2023 15:23:13 +0200 Subject: [PATCH 066/340] fix: use stripped amount for new selection --- src/pages/iou/steps/MoneyRequestAmountForm.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index 7178ed0e0158..b176efa70887 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -132,8 +132,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu return; } setCurrentAmount((prevAmount) => { - setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, newAmountWithoutSpaces.length)); - return MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, strippedAmount.length)); + return strippedAmount; }); }; From 1a84f307eaa7263c4837b01bafad40847495550e Mon Sep 17 00:00:00 2001 From: Sibtain Ali Date: Mon, 14 Aug 2023 19:03:03 +0500 Subject: [PATCH 067/340] fix: move auto complete menu to portals --- .../autoCompleteSuggestionsPropTypes.js | 9 +++- .../AutoCompleteSuggestions/index.js | 41 ++++++++++++++++--- src/components/EmojiSuggestions.js | 8 +++- src/components/MentionSuggestions.js | 6 +++ src/pages/home/report/ReportActionCompose.js | 4 ++ src/styles/StyleUtils.js | 12 ++++++ 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index 6ff330d839c6..d5146d308c17 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -27,8 +27,15 @@ const propTypes = { /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, + + /** Ref the container enclosing the menu. + * This is needed to render the menu in correct position inside a portal + */ + parentContainerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), }; -const defaultProps = {}; +const defaultProps = { + parentContainerRef: null +}; export {propTypes, defaultProps}; diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js index 9e1951d9a1d5..b0a39551c5b9 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.js @@ -1,7 +1,10 @@ import React from 'react'; +import {View} from 'react-native'; +import ReactDOM from 'react-dom'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import {propTypes} from './autoCompleteSuggestionsPropTypes'; +import * as StyleUtils from '../../styles/StyleUtils'; /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, @@ -12,6 +15,12 @@ import {propTypes} from './autoCompleteSuggestionsPropTypes'; function AutoCompleteSuggestions(props) { const containerRef = React.useRef(null); + const [containerState, setContainerState] = React.useState({ + width: 0, + height: 0, + x: 0, + y: 0, + }); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -26,12 +35,34 @@ function AutoCompleteSuggestions(props) { return () => (container.onpointerdown = null); }, []); + React.useEffect(() => { + if (!props.parentContainerRef || !props.parentContainerRef.current) { + return; + } + props.parentContainerRef.current.measureInWindow((x, y, width, height) => setContainerState({x, y, width, height})); + }, [props.parentContainerRef]); + + if (!containerState.width || !containerState.height) { + return ( + + ); + } + return ( - + ReactDOM.createPortal( + + + , + document.querySelector('body') + ) ); } diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index 76eb90f2295a..61ffd75fb38f 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -45,9 +45,14 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, + + /** Ref the container enclosing the menu. + * This is needed to render the menu in correct position inside a portal + */ + containerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), }; -const defaultProps = {highlightedEmojiIndex: 0}; +const defaultProps = {highlightedEmojiIndex: 0, containerRef: null}; /** * Create unique keys for each emoji item @@ -98,6 +103,7 @@ function EmojiSuggestions(props) { isSuggestionPickerLarge={props.isEmojiPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} + parentContainerRef={props.containerRef} /> ); } diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 1480d98d7899..5c0f59004ec2 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -42,10 +42,15 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, + /** Ref the container enclosing the menu. + * This is needed to render the menu in correct position inside a portal + */ + containerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), }; const defaultProps = { highlightedMentionIndex: 0, + containerRef: null, }; /** @@ -122,6 +127,7 @@ function MentionSuggestions(props) { isSuggestionPickerLarge={props.isMentionPickerLarge} shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} + parentContainerRef={props.containerRef} /> ); } diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index ef783a208ef8..8e4a388c7154 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -200,6 +200,7 @@ class ReportActionCompose extends React.Component { this.insertedEmojis = []; this.attachmentModalRef = React.createRef(); + this.containerRef = React.createRef(); // React Native will retain focus on an input for native devices but web/mWeb behave differently so we have some focus management // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager @@ -988,6 +989,7 @@ class ReportActionCompose extends React.Component { shouldShowReportRecipientLocalTime && !lodashGet(this.props.network, 'isOffline') && styles.chatItemComposeWithFirstRow, this.props.isComposerFullSize && styles.chatItemFullComposeRow, ]} + ref={this.containerRef} > )} @@ -1286,6 +1289,7 @@ class ReportActionCompose extends React.Component { isMentionPickerLarge={this.state.isAutoSuggestionPickerLarge} composerHeight={this.state.composerHeight} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + containerRef={this.containerRef} /> )} diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index fe910389c39c..f2ad67fcd147 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -1050,6 +1050,17 @@ function getAutoCompleteSuggestionItemStyle(highlightedEmojiIndex, rowHeight, ho ]; } +/** + * Gets the correct position for the base auto complete suggestion container + * + * @param {Object} parentContainerLayout + * @returns {Object} + */ + +function getBaseAutoCompleteSuggestionContainerStyle({x, y, width}) { + return {position: 'fixed', top: y, left: x, width}; +} + /** * Gets the correct position for auto complete suggestion container * @@ -1351,6 +1362,7 @@ export { getReportWelcomeBackgroundImageStyle, getReportWelcomeTopMarginStyle, getReportWelcomeContainerStyle, + getBaseAutoCompleteSuggestionContainerStyle, getAutoCompleteSuggestionItemStyle, getAutoCompleteSuggestionContainerStyle, getColoredBackgroundStyle, From 98360e939909deb02b9a571b7f35f2523c4dc25e Mon Sep 17 00:00:00 2001 From: Sibtain Ali Date: Mon, 14 Aug 2023 20:20:16 +0500 Subject: [PATCH 068/340] fix: invalid prop types --- .../autoCompleteSuggestionsPropTypes.js | 9 ++++++--- src/components/AutoCompleteSuggestions/index.js | 8 ++++---- src/components/AutoCompleteSuggestions/index.native.js | 2 +- src/components/EmojiSuggestions.js | 7 ++++--- src/components/MentionSuggestions.js | 10 +++++++--- src/pages/home/report/ReportActionCompose.js | 3 +-- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index d5146d308c17..0f334bfdaffe 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -28,14 +28,17 @@ const propTypes = { /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, - /** Ref the container enclosing the menu. + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal */ - parentContainerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), + // eslint-disable-next-line react/forbid-prop-types + parentContainerRef: PropTypes.shape({current: PropTypes.object}), }; const defaultProps = { - parentContainerRef: null + parentContainerRef: { + current: null + } }; export {propTypes, defaultProps}; diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js index b0a39551c5b9..e6c842dd0aae 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.js @@ -13,7 +13,7 @@ import * as StyleUtils from '../../styles/StyleUtils'; * On the native platform, tapping on auto-complete suggestions will not blur the main input. */ -function AutoCompleteSuggestions(props) { +function AutoCompleteSuggestions({parentContainerRef, ...props}) { const containerRef = React.useRef(null); const [containerState, setContainerState] = React.useState({ width: 0, @@ -36,11 +36,11 @@ function AutoCompleteSuggestions(props) { }, []); React.useEffect(() => { - if (!props.parentContainerRef || !props.parentContainerRef.current) { + if (!parentContainerRef || !parentContainerRef.current) { return; } - props.parentContainerRef.current.measureInWindow((x, y, width, height) => setContainerState({x, y, width, height})); - }, [props.parentContainerRef]); + parentContainerRef.current.measureInWindow((x, y, width, height) => setContainerState({x, y, width, height})); + }, [parentContainerRef]); if (!containerState.width || !containerState.height) { return ( diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.js index 22af774bd4fc..514cec6cd844 100644 --- a/src/components/AutoCompleteSuggestions/index.native.js +++ b/src/components/AutoCompleteSuggestions/index.native.js @@ -2,7 +2,7 @@ import React from 'react'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import {propTypes} from './autoCompleteSuggestionsPropTypes'; -function AutoCompleteSuggestions(props) { +function AutoCompleteSuggestions({parentContainerRef, ...props}) { // eslint-disable-next-line react/jsx-props-no-spreading return ; } diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index 61ffd75fb38f..b1e1c5db0d17 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -46,13 +46,14 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, - /** Ref the container enclosing the menu. + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal */ - containerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), + // eslint-disable-next-line react/forbid-prop-types + containerRef: PropTypes.shape({current: PropTypes.object}), }; -const defaultProps = {highlightedEmojiIndex: 0, containerRef: null}; +const defaultProps = {highlightedEmojiIndex: 0, containerRef: {current: null}}; /** * Create unique keys for each emoji item diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index 5c0f59004ec2..a4a0b95e812b 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -42,15 +42,19 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Ref the container enclosing the menu. + + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal */ - containerRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), + // eslint-disable-next-line react/forbid-prop-types + containerRef: PropTypes.shape({current: PropTypes.object}), }; const defaultProps = { highlightedMentionIndex: 0, - containerRef: null, + containerRef: { + current: null, + }, }; /** diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 8e4a388c7154..579a726d39d5 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -199,8 +199,7 @@ class ReportActionCompose extends React.Component { this.comment = props.comment; this.insertedEmojis = []; - this.attachmentModalRef = React.createRef(); - this.containerRef = React.createRef(); + this.containerRef = React.createRef(null); // React Native will retain focus on an input for native devices but web/mWeb behave differently so we have some focus management // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager From e3367013c2141e49f42803e11a85dd8cba055c02 Mon Sep 17 00:00:00 2001 From: Sibtain Ali Date: Mon, 14 Aug 2023 20:42:18 +0500 Subject: [PATCH 069/340] fix: lint errors --- .../autoCompleteSuggestionsPropTypes.js | 8 +++---- .../AutoCompleteSuggestions/index.js | 22 +++++++++---------- src/components/EmojiSuggestions.js | 6 ++--- src/components/MentionSuggestions.js | 4 ++-- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index 0f334bfdaffe..c27eda681996 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -28,17 +28,17 @@ const propTypes = { /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, - /** Ref of the container enclosing the menu. + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal - */ + */ // eslint-disable-next-line react/forbid-prop-types parentContainerRef: PropTypes.shape({current: PropTypes.object}), }; const defaultProps = { parentContainerRef: { - current: null - } + current: null, + }, }; export {propTypes, defaultProps}; diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js index e6c842dd0aae..412a4bf05408 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.js @@ -20,7 +20,7 @@ function AutoCompleteSuggestions({parentContainerRef, ...props}) { height: 0, x: 0, y: 0, - }); + }); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -52,17 +52,15 @@ function AutoCompleteSuggestions({parentContainerRef, ...props}) { ); } - return ( - ReactDOM.createPortal( - - - , - document.querySelector('body') - ) + return ReactDOM.createPortal( + + + , + document.querySelector('body'), ); } diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index b1e1c5db0d17..44ab4dd0fc5d 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -45,10 +45,10 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, - - /** Ref of the container enclosing the menu. + + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal - */ + */ // eslint-disable-next-line react/forbid-prop-types containerRef: PropTypes.shape({current: PropTypes.object}), }; diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index a4a0b95e812b..65b3b3cad001 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -43,9 +43,9 @@ const propTypes = { /** Show that we should include ReportRecipientLocalTime view height */ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Ref of the container enclosing the menu. + /** Ref of the container enclosing the menu. * This is needed to render the menu in correct position inside a portal - */ + */ // eslint-disable-next-line react/forbid-prop-types containerRef: PropTypes.shape({current: PropTypes.object}), }; From 9711a8e9e631430a4720dcf11392f4a0434d242b Mon Sep 17 00:00:00 2001 From: tienifr Date: Tue, 15 Aug 2023 00:29:35 +0700 Subject: [PATCH 070/340] pass onPress function to singleExecution --- src/components/Pressable/PressableWithFeedback.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index d31c42ebfa20..7eb0ee7286c9 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -73,8 +73,7 @@ const PressableWithFeedback = forwardRef((props, ref) => { if (props.onPressOut) props.onPressOut(); }} onPress={(e) => { - const onPress = props.onPress(e); - singleExecution(onPress); + singleExecution(() => props.onPress(e))(); }} > {(state) => (_.isFunction(props.children) ? props.children(state) : props.children)} From 9942d916a1b84a75ac6dedf393baba85a4804069 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 15 Aug 2023 11:24:11 +0200 Subject: [PATCH 071/340] split status and profile buttons --- src/pages/home/sidebar/SidebarLinks.js | 129 +++++++++++++++++-------- 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index d291d22cd3e6..1d17a353a7d5 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -32,6 +32,7 @@ import LogoComponent from '../../../../assets/images/expensify-wordmark.svg'; import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback'; import * as Session from '../../../libs/actions/Session'; import Button from '../../../components/Button'; +import Text from '../../../components/Text'; import * as UserUtils from '../../../libs/UserUtils'; import KeyboardShortcut from '../../../libs/KeyboardShortcut'; import onyxSubscribe from '../../../libs/onyxSubscribe'; @@ -92,7 +93,8 @@ class SidebarLinks extends React.PureComponent { super(props); this.showSearchPage = this.showSearchPage.bind(this); - this.onHandlePressAvatar = this.onHandlePressAvatar.bind(this); + this.showSettingsPage = this.showSettingsPage.bind(this); + this.showStatusPage = this.showStatusPage.bind(this); this.showReportPage = this.showReportPage.bind(this); if (this.props.isSmallScreenWidth) { @@ -142,19 +144,6 @@ class SidebarLinks extends React.PureComponent { } } - onHandlePressAvatar() { - if (this.props.isCreateMenuOpen) { - // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon - return; - } - const emojiCode = lodashGet(this.props.currentUserPersonalDetails, 'status.emojiCode', ''); - if (emojiCode && Permissions.canUseCustomStatus(this.props.betas)) { - Navigation.navigate(ROUTES.SETTINGS_STATUS); - } else { - Navigation.navigate(ROUTES.SETTINGS); - } - } - showSearchPage() { if (this.props.isCreateMenuOpen) { // Prevent opening Search page when click Search icon quickly after clicking FAB icon @@ -182,9 +171,94 @@ class SidebarLinks extends React.PureComponent { this.props.onLinkClick(); } - render() { + showSettingsPage() { + if (this.props.isCreateMenuOpen) { + // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon + return; + } + + Navigation.navigate(ROUTES.SETTINGS); + } + + showStatusPage() { + if (this.props.isCreateMenuOpen) { + // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon + return; + } + + Navigation.setShouldPopAllStateOnUP(); + Navigation.navigate(ROUTES.SETTINGS_STATUS); + } + + renderAvatarWithIndicator() { + return ( + + + + + + ); + } + + /** + * Render either a sign-in button or an avatar with optional status. + * @returns {React.Component} + */ + renderSignInOrAvatarWithOptionalStatus() { const statusEmojiCode = lodashGet(this.props.currentUserPersonalDetails, 'status.emojiCode', ''); const emojiStatus = Permissions.canUseCustomStatus(this.props.betas) ? statusEmojiCode : ''; + if (Session.isAnonymousUser()) { + return ( + + +