diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 000000000000..8995a0443b85 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,37 @@ +import {useEffect, useRef} from 'react'; +import lodashDebounce from 'lodash/debounce'; + +/** + * Create and return a debounced function. + * + * Every time the identity of any of the arguments changes, the debounce operation will restart (canceling any ongoing debounce). + * This is especially important in the case of func. To prevent that, pass stable references. + * + * @param {Function} func The function to debounce. + * @param {Number} wait The number of milliseconds to delay. + * @param {Object} options The options object. + * @param {Boolean} options.leading Specify invoking on the leading edge of the timeout. + * @param {Number} options.maxWait The maximum time func is allowed to be delayed before it’s invoked. + * @param {Boolean} options.trailing Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns a function to call the debounced function. + */ +export default function useDebounce(func, wait, options) { + const debouncedFnRef = useRef(); + const {leading, maxWait, trailing = true} = options || {}; + + useEffect(() => { + const debouncedFn = lodashDebounce(func, wait, {leading, maxWait, trailing}); + + debouncedFnRef.current = debouncedFn; + + return debouncedFn.cancel; + }, [func, wait, leading, maxWait, trailing]); + + return (...args) => { + const debouncedFn = debouncedFnRef.current; + + if (debouncedFn) { + debouncedFn(...args); + } + }; +} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index ccf7a0a51518..630c983cd889 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -33,6 +33,7 @@ import compose from '../../../../libs/compose'; import withKeyboardState from '../../../../components/withKeyboardState'; import {propTypes, defaultProps} from './composerWithSuggestionsProps'; import focusWithDelay from '../../../../libs/focusWithDelay'; +import useDebounce from '../../../../hooks/useDebounce'; const {RNTextInputReset} = NativeModules; @@ -126,6 +127,9 @@ function ComposerWithSuggestions({ const textInputRef = useRef(null); const insertedEmojisRef = useRef([]); + // A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not + const isScrollLikelyLayoutTriggered = useRef(false); + /** * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis * API is not called too often. @@ -135,6 +139,23 @@ function ComposerWithSuggestions({ insertedEmojisRef.current = []; }, []); + /** + * Reset isScrollLikelyLayoutTriggered to false. + * + * The function is debounced with a handpicked wait time to address 2 issues: + * 1. There is a slight delay between onChangeText and onScroll + * 2. Layout change will trigger onScroll multiple times + */ + const debouncedLowerIsScrollLikelyLayoutTriggered = useDebounce( + useCallback(() => (isScrollLikelyLayoutTriggered.current = false), []), + 500, + ); + + const raiseIsScrollLikelyLayoutTriggered = useCallback(() => { + isScrollLikelyLayoutTriggered.current = true; + debouncedLowerIsScrollLikelyLayoutTriggered(); + }, [debouncedLowerIsScrollLikelyLayoutTriggered]); + const onInsertedEmoji = useCallback( (emojiObject) => { insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; @@ -175,6 +196,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { + raiseIsScrollLikelyLayoutTriggered(); const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { @@ -217,7 +239,7 @@ function ComposerWithSuggestions({ debouncedBroadcastUserIsTyping(reportID); } }, - [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef], + [debouncedUpdateFrequentlyUsedEmojis, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered], ); /** @@ -324,8 +346,8 @@ function ComposerWithSuggestions({ [suggestionsRef], ); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (!suggestionsRef.current) { + const hideSuggestionMenu = useCallback(() => { + if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { return; } suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); @@ -504,7 +526,7 @@ function ComposerWithSuggestions({ } setComposerHeight(composerLayoutHeight); }} - onScroll={updateShouldShowSuggestionMenuToFalse} + onScroll={hideSuggestionMenu} />