diff --git a/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch new file mode 100644 index 00000000000..84a233894f9 --- /dev/null +++ b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/react-native/React/Views/RCTModalHostViewManager.m b/node_modules/react-native/React/Views/RCTModalHostViewManager.m +index 4b9f9ad..b72984c 100644 +--- a/node_modules/react-native/React/Views/RCTModalHostViewManager.m ++++ b/node_modules/react-native/React/Views/RCTModalHostViewManager.m +@@ -79,6 +79,13 @@ RCT_EXPORT_MODULE() + if (self->_presentationBlock) { + self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else { ++ // In our App, If an input is blurred and a modal is opened, the rootView will become the firstResponder, which ++ // will cause system to retain a wrong keyboard state, and then the keyboard to flicker when the modal is closed. ++ // We first resign the rootView to avoid this problem. ++ UIWindow *window = RCTKeyWindow(); ++ if (window && window.rootViewController && [window.rootViewController.view isFirstResponder]) { ++ [window.rootViewController.view resignFirstResponder]; ++ } + [[modalHostView reactViewController] presentViewController:viewController + animated:animated + completion:completionBlock]; diff --git a/src/components/AttachmentPicker/index.js b/src/components/AttachmentPicker/index.js index e7653df2b4d..9ea94ae53d4 100644 --- a/src/components/AttachmentPicker/index.js +++ b/src/components/AttachmentPicker/index.js @@ -27,6 +27,8 @@ function getAcceptableFileTypes(type) { function AttachmentPicker(props) { const fileInput = useRef(); const onPicked = useRef(); + const onCanceled = useRef(() => {}); + return ( <> e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + if (!fileInput.current) { + return; + } + fileInput.current.addEventListener('cancel', () => onCanceled.current(), {once: true}); + }} accept={getAcceptableFileTypes(props.type)} /> {props.children({ - openPicker: ({onPicked: newOnPicked}) => { + openPicker: ({onPicked: newOnPicked, onCanceled: newOnCanceled = () => {}}) => { onPicked.current = newOnPicked; fileInput.current.click(); + onCanceled.current = newOnCanceled; }, })} diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index 8ba7ae33606..8b1bb54da92 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -95,6 +95,7 @@ function AttachmentPicker({type, children}) { const completeAttachmentSelection = useRef(); const onModalHide = useRef(); + const onCanceled = useRef(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -216,9 +217,11 @@ function AttachmentPicker({type, children}) { * Opens the attachment modal * * @param {function} onPickedHandler A callback that will be called with the selected attachment + * @param {function} onCanceledHandler A callback that will be called without a selected attachment */ - const open = (onPickedHandler) => { + const open = (onPickedHandler, onCanceledHandler = () => {}) => { completeAttachmentSelection.current = onPickedHandler; + onCanceled.current = onCanceledHandler; setIsVisible(true); }; @@ -239,6 +242,7 @@ function AttachmentPicker({type, children}) { const pickAttachment = useCallback( (attachments = []) => { if (attachments.length === 0) { + onCanceled.current(); return Promise.resolve(); } @@ -308,13 +312,16 @@ function AttachmentPicker({type, children}) { */ const renderChildren = () => children({ - openPicker: ({onPicked}) => open(onPicked), + openPicker: ({onPicked, onCanceled: newOnCanceled}) => open(onPicked, newOnCanceled), }); return ( <> { + close(); + onCanceled.current(); + }} isVisible={isVisible} anchorPosition={styles.createMenuPosition} onModalHide={onModalHide.current} diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 80ba1fc6034..79dd98d0e87 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -10,6 +10,7 @@ import {propTypes as modalPropTypes, defaultProps as modalDefaultProps} from './ import * as Modal from '../../libs/actions/Modal'; import getModalStyles from '../../styles/getModalStyles'; import variables from '../../styles/variables'; +import ComposerFocusManager from '../../libs/ComposerFocusManager'; const propTypes = { ...modalPropTypes, @@ -73,6 +74,9 @@ class BaseModal extends PureComponent { this.props.onModalHide(); } Modal.onModalDidClose(); + if (!this.props.fullscreen) { + ComposerFocusManager.setReadyToFocus(); + } } render() { @@ -109,6 +113,9 @@ class BaseModal extends PureComponent { // Note: Escape key on web/desktop will trigger onBackButtonPress callback // eslint-disable-next-line react/jsx-props-no-multi-spaces onBackButtonPress={this.props.onClose} + onModalWillShow={() => { + ComposerFocusManager.resetReadyToFocus(); + }} onModalShow={() => { if (this.props.shouldSetModalVisibility) { Modal.setModalVisibility(true); @@ -117,6 +124,7 @@ class BaseModal extends PureComponent { }} propagateSwipe={this.props.propagateSwipe} onModalHide={this.hideModal} + onDismiss={() => ComposerFocusManager.setReadyToFocus()} onSwipeComplete={this.props.onClose} swipeDirection={swipeDirection} isVisible={this.props.isVisible} diff --git a/src/components/Modal/index.android.js b/src/components/Modal/index.android.js index 09df74329b2..b5f11a02650 100644 --- a/src/components/Modal/index.android.js +++ b/src/components/Modal/index.android.js @@ -1,7 +1,17 @@ import React from 'react'; +import {AppState} from 'react-native'; import withWindowDimensions from '../withWindowDimensions'; import BaseModal from './BaseModal'; import {propTypes, defaultProps} from './modalPropTypes'; +import ComposerFocusManager from '../../libs/ComposerFocusManager'; + +AppState.addEventListener('focus', () => { + ComposerFocusManager.setReadyToFocus(); +}); + +AppState.addEventListener('blur', () => { + ComposerFocusManager.resetReadyToFocus(); +}); // Only want to use useNativeDriver on Android. It has strange flashes issue on IOS // https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating diff --git a/src/libs/ComposerFocusManager.js b/src/libs/ComposerFocusManager.js new file mode 100644 index 00000000000..569e165da96 --- /dev/null +++ b/src/libs/ComposerFocusManager.js @@ -0,0 +1,23 @@ +let isReadyToFocusPromise = Promise.resolve(); +let resolveIsReadyToFocus; + +function resetReadyToFocus() { + isReadyToFocusPromise = new Promise((resolve) => { + resolveIsReadyToFocus = resolve; + }); +} +function setReadyToFocus() { + if (!resolveIsReadyToFocus) { + return; + } + resolveIsReadyToFocus(); +} +function isReadyToFocus() { + return isReadyToFocusPromise; +} + +export default { + resetReadyToFocus, + setReadyToFocus, + isReadyToFocus, +}; diff --git a/src/libs/focusWithDelay.js b/src/libs/focusWithDelay.js new file mode 100644 index 00000000000..367cc2b92f9 --- /dev/null +++ b/src/libs/focusWithDelay.js @@ -0,0 +1,35 @@ +import {InteractionManager} from 'react-native'; +import ComposerFocusManager from './ComposerFocusManager'; + +/** + * Create a function that focuses a text input. + * @param {Object} textInput the text input to focus + * @returns {Function} a function that focuses the text input with a configurable delay + */ +function focusWithDelay(textInput) { + /** + * Focus the text input + * @param {Boolean} [shouldDelay=false] Impose delay before focusing the text input + */ + return (shouldDelay = false) => { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + if (!textInput) { + return; + } + if (!shouldDelay) { + textInput.focus(); + return; + } + ComposerFocusManager.isReadyToFocus().then(() => { + if (!textInput) { + return; + } + textInput.focus(); + }); + }); + }; +} + +export default focusWithDelay; diff --git a/src/libs/focusWithDelay/focusWithDelay.js b/src/libs/focusWithDelay/focusWithDelay.js deleted file mode 100644 index 143d5dd1243..00000000000 --- a/src/libs/focusWithDelay/focusWithDelay.js +++ /dev/null @@ -1,40 +0,0 @@ -import {InteractionManager} from 'react-native'; - -/** - * Creates a function that can be used to focus a text input - * @param {Boolean} disableDelay whether to force focus without a delay (on web and desktop) - * @returns {Function} a focusWithDelay function - */ -function focusWithDelay(disableDelay = false) { - /** - * Create a function that focuses a text input. - * @param {Object} textInput the text input to focus - * @returns {Function} a function that focuses the text input with a configurable delay - */ - return (textInput) => - /** - * Focus the text input - * @param {Boolean} [shouldDelay=false] Impose delay before focusing the text input - */ - (shouldDelay = false) => { - // There could be other animations running while we trigger manual focus. - // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!textInput) { - return; - } - - if (disableDelay || !shouldDelay) { - textInput.focus(); - } else { - // Keyboard is not opened after Emoji Picker is closed - // SetTimeout is used as a workaround - // https://github.com/react-native-modal/react-native-modal/issues/114 - // We carefully choose a delay. 100ms is found enough for keyboard to open. - setTimeout(() => textInput.focus(), 100); - } - }); - }; -} - -export default focusWithDelay; diff --git a/src/libs/focusWithDelay/index.js b/src/libs/focusWithDelay/index.js deleted file mode 100644 index faeb43147c5..00000000000 --- a/src/libs/focusWithDelay/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import focusWithDelay from './focusWithDelay'; - -/** - * We pass true to disable the delay on the web because it doesn't require - * using the workaround (explained in the focusWithDelay.js file). - */ -export default focusWithDelay(true); diff --git a/src/libs/focusWithDelay/index.native.js b/src/libs/focusWithDelay/index.native.js deleted file mode 100644 index 27fb19fe157..00000000000 --- a/src/libs/focusWithDelay/index.native.js +++ /dev/null @@ -1,6 +0,0 @@ -import focusWithDelay from './focusWithDelay'; - -/** - * We enable the delay on native to display the keyboard correctly - */ -export default focusWithDelay(false); diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 2363c8d5742..5b027abced4 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -372,6 +372,17 @@ function ReportActionCompose({ focusWithDelay(textInputRef.current)(shouldDelay); }, []); + const isNextModalWillOpenRef = useRef(false); + const isKeyboardVisibleWhenShowingModalRef = useRef(false); + + const restoreKeyboardState = useCallback(() => { + if (!isKeyboardVisibleWhenShowingModalRef.current) { + return; + } + focus(true); + isKeyboardVisibleWhenShowingModalRef.current = false; + }, [focus]); + /** * Update the value of the comment in Onyx * @@ -943,7 +954,8 @@ function ReportActionCompose({ shouldBlockEmojiCalc.current = false; shouldBlockMentionCalc.current = false; setIsAttachmentPreviewActive(false); - }, []); + restoreKeyboardState(); + }, [restoreKeyboardState]); useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); @@ -982,10 +994,13 @@ function ReportActionCompose({ const prevIsModalVisible = usePrevious(modal.isVisible); const prevIsFocused = usePrevious(isFocusedProp); useEffect(() => { + if (modal.isVisible && !prevIsModalVisible) { + isNextModalWillOpenRef.current = false; + } // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!(willBlurTextInputOnTapOutside && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { + if (!(willBlurTextInputOnTapOutside && !isNextModalWillOpenRef.current && !modal.isVisible && isFocusedProp && (prevIsModalVisible || !prevIsFocused))) { return; } @@ -1063,8 +1078,10 @@ function ReportActionCompose({ shouldBlockEmojiCalc.current = true; shouldBlockMentionCalc.current = true; } + isNextModalWillOpenRef.current = true; openPicker({ onPicked: displayFileInModal, + onCanceled: restoreKeyboardState, }); }; const menuItems = [ @@ -1133,6 +1150,10 @@ function ReportActionCompose({ ref={actionButtonRef} onPress={(e) => { e.preventDefault(); + if (!willBlurTextInputOnTapOutside) { + isKeyboardVisibleWhenShowingModalRef.current = textInputRef.current.isFocused(); + } + textInputRef.current.blur(); // Drop focus to avoid blue focus ring. actionButtonRef.current.blur(); @@ -1150,7 +1171,10 @@ function ReportActionCompose({ setMenuVisibility(false)} + onClose={() => { + setMenuVisibility(false); + restoreKeyboardState(); + }} onItemSelected={(item, index) => { setMenuVisibility(false); @@ -1185,9 +1209,12 @@ function ReportActionCompose({ style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={maxComposerLines} onFocus={() => setIsFocused(true)} - onBlur={() => { + onBlur={(e) => { setIsFocused(false); resetSuggestions(); + if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } }} onClick={() => { shouldBlockEmojiCalc.current = false;