diff --git a/src/CONST.ts b/src/CONST.ts index 5625381d286..48e06e1462e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -715,6 +715,15 @@ const CONST = { RIGHT: 'right', }, POPOVER_MENU_PADDING: 8, + BUSINESS_TYPE: { + DEFAULT: 'default', + ATTACHMENT: 'attachment', + }, + RESTORE_FOCUS_TYPE: { + DEFAULT: 'default', + DELETE: 'delete', + PRESERVE: 'preserve', + }, }, TIMING: { CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action', diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index 59928b80c4b..9bd6426686b 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -103,6 +103,7 @@ const getDataForUpload = (fileData) => { function AttachmentPicker({type, children, shouldHideCameraOption}) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); + const [restoreFocusType, setRestoreFocusType] = useState(); const completeAttachmentSelection = useRef(); const onModalHide = useRef(); @@ -305,12 +306,14 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { return ( <> { close(); onCanceled.current(); }} isVisible={isVisible} anchorPosition={styles.createMenuPosition} + onModalShow={() => setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT)} onModalHide={onModalHide.current} > @@ -319,7 +322,10 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { key={item.textTranslationKey} icon={item.icon} title={translate(item.textTranslationKey)} - onPress={() => selectItem(item)} + onPress={() => { + setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE); + selectItem(item); + }} focused={focusedIndex === menuIndex} /> ))} diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index c7b020a5c6d..db2e7e38480 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -4,7 +4,6 @@ import type {TextInput} from 'react-native'; import {StyleSheet} from 'react-native'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; -import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,7 +30,6 @@ function Composer( ref: ForwardedRef, ) { const textInput = useRef(null); - const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -81,12 +79,6 @@ function Composer( /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} readOnly={isDisabled} - onBlur={(e) => { - if (!isFocused) { - shouldResetFocus.current = true; // detect the input is blurred when the page is hidden - } - props?.onBlur?.(e); - }} /> ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 50a79021437..7765465e85a 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -372,17 +372,7 @@ function Composer( numberOfLines={numberOfLines} disabled={isDisabled} onKeyPress={handleKeyPress} - onFocus={(e) => { - ReportActionComposeFocusManager.onComposerFocus(() => { - if (!textInput.current) { - return; - } - - textInput.current.focus(); - }); - - props.onFocus?.(e); - }} + onFocus={(e) => props.onFocus?.(e)} /> {shouldCalculateCaretPosition && renderElementForCaretPosition} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index f25fc978e3e..5cd0b60ce3f 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -1,6 +1,7 @@ import type {ReactNode} from 'react'; import React from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -64,6 +65,9 @@ type ConfirmModalProps = { /** Whether to stack the buttons */ shouldStackButtons?: boolean; + + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; }; function ConfirmModal({ @@ -86,6 +90,7 @@ function ConfirmModal({ shouldStackButtons = true, isVisible, onConfirm, + restoreFocusType, }: ConfirmModalProps) { const {isSmallScreenWidth} = useWindowDimensions(); @@ -97,6 +102,7 @@ function ConfirmModal({ shouldSetModalVisibility={shouldSetModalVisibility} onModalHide={onModalHide} type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM} + restoreFocusType={restoreFocusType} > ComposerFocusManager.getId(), []); + const {businessType} = useContext(ModalBusinessTypeContext); + const saveFocusState = () => { + ComposerFocusManager.saveFocusState(modalId, businessType, shouldClearFocusWithType); + ComposerFocusManager.resetReadyToFocus(modalId); + }; + /** * Hides modal * @param callHideCallback - Should we call the onModalHide callback @@ -67,11 +78,9 @@ function BaseModal( onModalHide(); } Modal.onModalDidClose(); - if (!fullscreen) { - ComposerFocusManager.setReadyToFocus(); - } + ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, businessType, restoreFocusType); }, - [shouldSetModalVisibility, onModalHide, fullscreen], + [shouldSetModalVisibility, onModalHide, businessType, restoreFocusType, modalId], ); useEffect(() => { @@ -119,7 +128,7 @@ function BaseModal( }; const handleDismissModal = () => { - ComposerFocusManager.setReadyToFocus(); + ComposerFocusManager.setReadyToFocus(modalId); }; const { @@ -181,7 +190,7 @@ function BaseModal( onModalShow={handleShowModal} propagateSwipe={propagateSwipe} onModalHide={hideModal} - onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()} + onModalWillShow={saveFocusState} onDismiss={handleDismissModal} onSwipeComplete={() => onClose?.()} swipeDirection={swipeDirection} @@ -205,12 +214,14 @@ function BaseModal( avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} > - - {children} - + + + {children} + + ); } diff --git a/src/components/Modal/ModalBusinessTypeProvider/index.tsx b/src/components/Modal/ModalBusinessTypeProvider/index.tsx new file mode 100644 index 00000000000..ddeea87bbbd --- /dev/null +++ b/src/components/Modal/ModalBusinessTypeProvider/index.tsx @@ -0,0 +1,20 @@ +import React, {useMemo} from 'react'; +import CONST from '@src/CONST'; +import type {ModalBusinessTypeContextProps, ModalBusinessTypeContextValue} from './types'; + +const ModalBusinessTypeContext = React.createContext({ + businessType: CONST.MODAL.BUSINESS_TYPE.DEFAULT, +}); + +function ModalBusinessTypeProvider({businessType, children}: ModalBusinessTypeContextProps) { + const contextValue = useMemo( + () => ({ + businessType, + }), + [businessType], + ); + return {children}; +} + +export default ModalBusinessTypeProvider; +export {ModalBusinessTypeContext}; diff --git a/src/components/Modal/ModalBusinessTypeProvider/types.ts b/src/components/Modal/ModalBusinessTypeProvider/types.ts new file mode 100644 index 00000000000..5ca3015ca08 --- /dev/null +++ b/src/components/Modal/ModalBusinessTypeProvider/types.ts @@ -0,0 +1,21 @@ +import type {ReactNode} from 'react'; +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type ModalBusinessTypeContextProps = { + /** + * So far, modern browsers only support the file cancel event in some newer versions + * (i.e., Chrome: 113+ / Firefox: 91+ / Safari 16.4+), and there is no standard feature detection method available. + * Therefore, we introduce this prop to isolate the impact of the file upload modal on the focus stack. + */ + businessType: ValueOf; + + /** Children to render */ + children: ReactNode; +}; + +type ModalBusinessTypeContextValue = { + businessType: ValueOf; +}; + +export type {ModalBusinessTypeContextProps, ModalBusinessTypeContextValue}; diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx new file mode 100644 index 00000000000..5c8e0d2ece6 --- /dev/null +++ b/src/components/Modal/ModalContent.tsx @@ -0,0 +1,19 @@ +import type {ReactNode} from 'react'; +import React from 'react'; + +type ModalContentProps = { + /** Modal contents */ + children: ReactNode; + + /** called after modal content is dismissed */ + onDismiss: () => void; +}; + +function ModalContent({children, onDismiss = () => {}}: ModalContentProps) { + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(() => () => onDismiss?.(), []); + return children; +} +ModalContent.displayName = 'ModalContent'; + +export default ModalContent; diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx index 86a1fd27218..7cb2c608375 100644 --- a/src/components/Modal/index.android.tsx +++ b/src/components/Modal/index.android.tsx @@ -1,17 +1,7 @@ import React from 'react'; -import {AppState} from 'react-native'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; -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 function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js index 84e610b694e..e8e5a9d141c 100644 --- a/src/components/Modal/modalPropTypes.js +++ b/src/components/Modal/modalPropTypes.js @@ -66,6 +66,9 @@ const propTypes = { * */ hideModalContentWhileAnimating: PropTypes.bool, + /** how to re-focus after the modal is dismissed */ + restoreFocusType: PropTypes.oneOf(_.values(CONST.MODAL.RESTORE_FOCUS_TYPE)), + ...windowDimensionsPropTypes, }; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index a0cdb737d44..6e410f5ac20 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -62,6 +62,12 @@ type BaseModalProps = Partial & { /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; + + /** Whether the modal should clear the focus record for the current business type. */ + shouldClearFocusWithType?: boolean; + + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; }; export default BaseModalProps; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index ff7d0fdfb8e..4e8b52881c8 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -3,6 +3,7 @@ import type {RefObject} from 'react'; import React, {useRef} from 'react'; import {View} from 'react-native'; import type {ModalProps} from 'react-native-modal'; +import type {ValueOf} from 'type-fest'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -50,6 +51,15 @@ type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; + /** Callback method fired when the modal is shown */ + onModalShow?: () => void; + + /** Whether the modal should clear the focus record for the current business type. */ + shouldClearFocusWithType?: boolean; + + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; + /** State that determines whether to display the modal or not */ isVisible: boolean; @@ -91,6 +101,9 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + shouldClearFocusWithType, + restoreFocusType, + onModalShow = () => {}, headerText, fromSidebarMediumScreen, anchorAlignment = { @@ -142,6 +155,9 @@ function PopoverMenu({ anchorAlignment={anchorAlignment} onClose={onClose} isVisible={isVisible} + shouldClearFocusWithType={shouldClearFocusWithType} + restoreFocusType={restoreFocusType} + onModalShow={onModalShow} onModalHide={onModalHide} animationIn={animationIn} animationOut={animationOut} diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index 58d022ef9d6..a7405f24afc 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -1,13 +1,17 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useContext, useEffect, useMemo} from 'react'; +import React, {forwardRef, useContext, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; +import {ModalBusinessTypeContext} from '@components/Modal/ModalBusinessTypeProvider'; +import ModalContent from '@components/Modal/ModalContent'; import {PopoverContext} from '@components/PopoverProvider'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import * as Modal from '@userActions/Modal'; +import CONST from '@src/CONST'; import viewRef from '@src/types/utils/viewRef'; import type PopoverWithoutOverlayProps from './types'; @@ -22,6 +26,8 @@ function PopoverWithoutOverlay( isVisible, onClose, onModalHide = () => {}, + shouldClearFocusWithType, + restoreFocusType, children, }: PopoverWithoutOverlayProps, ref: ForwardedRef, @@ -30,7 +36,9 @@ function PopoverWithoutOverlay( const StyleUtils = useStyleUtils(); const {onOpen, close} = useContext(PopoverContext); const {windowWidth, windowHeight} = useWindowDimensions(); + const modalId = useMemo(() => ComposerFocusManager.getId(), []); const insets = useSafeAreaInsets(); + const {businessType} = useContext(ModalBusinessTypeContext); const {modalStyle, modalContainerStyle, shouldAddTopSafeAreaMargin, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaPadding, shouldAddBottomSafeAreaPadding} = StyleUtils.getModalStyles( 'popover', @@ -54,6 +62,8 @@ function PopoverWithoutOverlay( anchorRef, }); removeOnClose = Modal.setCloseModal(onClose); + ComposerFocusManager.saveFocusState(modalId, businessType, shouldClearFocusWithType, withoutOverlayRef.current); + ComposerFocusManager.resetReadyToFocus(modalId); } else { onModalHide(); close(anchorRef); @@ -113,6 +123,17 @@ function PopoverWithoutOverlay( ], ); + const restoreFocusTypeRef = useRef(); + restoreFocusTypeRef.current = restoreFocusType; + const handleDismissContent = () => { + ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, businessType, restoreFocusTypeRef.current); + + // On the web platform, because there is no overlay, modal can be closed and opened instantly and randomly, + // this will cause the input box to gain and lose focus instantly while the subsequent modal is opened. + // The RESTORE_FOCUS_TYPE cannot address this randomness case, so we have to delay the refocusing here. + setTimeout(() => ComposerFocusManager.setReadyToFocus(modalId), CONST.ANIMATION_IN_TIMING); + }; + if (!isVisible) { return null; } @@ -122,16 +143,18 @@ function PopoverWithoutOverlay( style={[modalStyle, {zIndex: 1}]} ref={viewRef(withoutOverlayRef)} > - - {children} - + + + {children} + + ); } diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx index 1df56093d6a..d4a5b7272f6 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx @@ -7,6 +7,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import HapticFeedback from '@libs/HapticFeedback'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import CONST from '@src/CONST'; @@ -97,6 +98,8 @@ function GenericPressable( const onPressHandler = useCallback( (event?: GestureResponderEvent | KeyboardEvent) => { + ComposerFocusManager.clearFocusedInput(); + if (isDisabled) { return; } diff --git a/src/components/Pressable/GenericPressable/index.tsx b/src/components/Pressable/GenericPressable/index.tsx index 3d630137915..4d1a052184b 100644 --- a/src/components/Pressable/GenericPressable/index.tsx +++ b/src/components/Pressable/GenericPressable/index.tsx @@ -1,9 +1,22 @@ import React, {forwardRef} from 'react'; -import type {Role} from 'react-native'; +import type {NativePointerEvent, NativeSyntheticEvent, Role} from 'react-native'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import GenericPressable from './BaseGenericPressable'; import type {PressableRef} from './types'; import type PressableProps from './types'; +function saveFocusedInput(e: NativeSyntheticEvent) { + const target = e.target as unknown as EventTarget; + if (!target) { + return; + } + // If an input is clicked, it usually doesn't show a modal, so there's no need to save the focused input. + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { + return; + } + ComposerFocusManager.saveFocusedInput(); +} + function WebGenericPressable({focusable = true, ...props}: PressableProps, ref: PressableRef) { const accessible = props.accessible ?? props.accessible === undefined ? true : props.accessible; @@ -11,6 +24,7 @@ function WebGenericPressable({focusable = true, ...props}: PressableProps, ref: ) { const theme = useTheme(); + const inputRef = React.useRef(null); + + React.useEffect(() => () => ComposerFocusManager.releaseInput(inputRef.current), []); + return ( { + if (refHandle) { + (inputRef.current as Component>) = refHandle; + } if (typeof ref !== 'function') { return; } diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index b66bbe92599..03219c7ca2a 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -1,25 +1,270 @@ -let isReadyToFocusPromise = Promise.resolve(); -let resolveIsReadyToFocus: (value: void | PromiseLike) => void; +import type {View} from 'react-native'; +import {TextInput} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import isWindowReadyToFocus from './isWindowReadyToFocus'; -function resetReadyToFocus() { - isReadyToFocusPromise = new Promise((resolve) => { - resolveIsReadyToFocus = resolve; +type ModalId = number | undefined; + +type InputElement = (TextInput & HTMLElement) | null; + +type BusinessType = ValueOf | undefined; + +type RestoreFocusType = ValueOf | undefined; + +type ModalContainer = View | HTMLElement | undefined | null; + +type FocusMapValue = { + input: InputElement; + businessType?: BusinessType; +}; + +type PromiseMapValue = { + ready: Promise; + resolve: () => void; +}; + +let focusedInput: InputElement = null; +let uniqueModalId = 1; +const focusMap = new Map(); +const activeModals: ModalId[] = []; +const promiseMap = new Map(); + +/** + * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible. + */ +function getActiveInput() { + return (TextInput.State.currentlyFocusedInput ? TextInput.State.currentlyFocusedInput() : TextInput.State.currentlyFocusedField()) as InputElement; +} + +/** + * On web platform, if the modal is displayed by a click, the blur event is fired before the modal appears, + * so we need to cache the focused input in the pointerdown handler, which is fired before the blur event. + */ +function saveFocusedInput() { + focusedInput = getActiveInput(); +} + +/** + * If a click does not display the modal, we also should clear the cached value to avoid potential issues. + */ +function clearFocusedInput() { + if (!focusedInput) { + return; + } + + // we have to use timeout because of measureLayout + setTimeout(() => (focusedInput = null), CONST.ANIMATION_IN_TIMING); +} + +/** + * When a TextInput is unmounted, we also should release the reference here to avoid potential issues. + * + */ +function releaseInput(input: InputElement) { + if (!input) { + return; + } + if (input === focusedInput) { + focusedInput = null; + } + [...focusMap].forEach(([key, value]) => { + if (value.input !== input) { + return; + } + focusMap.delete(key); + }); +} + +function getId() { + return uniqueModalId++; +} + +/** + * Save the focus state when opening the modal. + */ +function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BUSINESS_TYPE.DEFAULT, shouldClearFocusWithType = false, container: ModalContainer = undefined) { + const activeInput = getActiveInput(); + + // For popoverWithoutOverlay, react calls autofocus before useEffect. + const input = focusedInput ?? activeInput; + focusedInput = null; + if (activeModals.indexOf(id) < 0) { + activeModals.push(id); + } + + if (shouldClearFocusWithType) { + [...focusMap].forEach(([key, value]) => { + if (value.businessType !== businessType) { + return; + } + focusMap.delete(key); + }); + } + + if (container && 'contains' in container && container.contains(input)) { + return; + } + focusMap.set(id, {input, businessType}); + if (!input) { + return; + } + input.blur(); +} + +/** + * On web platform, if we intentionally click on another input box, there is no need to restore focus. + * Additionally, if we are closing the RHP, we can ignore the focused input. + */ +function focus(input: InputElement, shouldIgnoreFocused = false) { + if (!input) { + return; + } + if (shouldIgnoreFocused) { + isWindowReadyToFocus().then(() => input.focus()); + return; + } + const activeInput = getActiveInput(); + if (activeInput) { + return; + } + isWindowReadyToFocus().then(() => input.focus()); +} + +/** + * Restore the focus state after the modal is dismissed. + */ +function restoreFocusState( + id: ModalId, + shouldIgnoreFocused = false, + businessType: BusinessType = CONST.MODAL.BUSINESS_TYPE.DEFAULT, + restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, +) { + if (!id) { + return; + } + + // The stack is empty + if (activeModals.length < 1) { + return; + } + const index = activeModals.indexOf(id); + + // This id has been removed from the stack. + if (index < 0) { + return; + } + activeModals.splice(index, 1); + if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE) { + return; + } + + const {input} = focusMap.get(id) ?? {}; + focusMap.delete(id); + if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE) { + return; + } + + // This modal is not the topmost one, do not restore it. + if (activeModals.length > index) { + if (input) { + const lastId = activeModals.slice(-1)[0]; + focusMap.set(lastId, {...focusMap.get(lastId), input}); + } + return; + } + if (input) { + focus(input, shouldIgnoreFocused); + return; + } + + // Try to find the topmost one and restore it + const stack = [...focusMap].filter(([, v]) => v.input && v.businessType === businessType); + if (stack.length < 1) { + return; + } + const [lastId, {input: lastInput}] = stack.slice(-1)[0]; + + // The previous modal is still active + if (activeModals.indexOf(lastId) >= 0) { + return; + } + focus(lastInput, shouldIgnoreFocused); + focusMap.delete(lastId); +} + +function resetReadyToFocus(id: ModalId) { + const promise: PromiseMapValue = { + ready: Promise.resolve(), + resolve: () => {}, + }; + promise.ready = new Promise((resolve) => { + promise.resolve = resolve; }); + promiseMap.set(id, promise); +} + +function getKey(id: ModalId) { + if (id) { + return id; + } + if (promiseMap.size < 1) { + return 0; + } + return [...promiseMap.keys()].slice(-1)[0]; } -function setReadyToFocus() { - if (!resolveIsReadyToFocus) { +function setReadyToFocus(id: ModalId) { + const key = getKey(id); + const promise = promiseMap.get(key); + if (!promise) { return; } - resolveIsReadyToFocus(); + promise.resolve?.(); + promiseMap.delete(key); } -function isReadyToFocus(): Promise { - return isReadyToFocusPromise; +function isReadyToFocus(id: ModalId = undefined) { + const key = getKey(id); + const promise = promiseMap.get(key); + if (!promise) { + return Promise.resolve(); + } + return promise.ready; +} + +function tryRestoreFocusAfterClosedCompletely(id: ModalId, businessType: BusinessType, restoreType: RestoreFocusType) { + isReadyToFocus(id)?.then(() => restoreFocusState(id, false, businessType, restoreType)); +} + +/** + * So far, this will only be called in file canceled event handler. + */ +function tryRestoreFocusByExternal(businessType: BusinessType) { + const stack = [...focusMap].filter(([, value]) => value.businessType === businessType && value.input); + if (stack.length < 1) { + return; + } + const [key, {input}] = stack.slice(-1)[0]; + focusMap.delete(key); + if (!input) { + return; + } + focus(input); } +export type {InputElement}; + export default { + getId, + saveFocusedInput, + clearFocusedInput, + releaseInput, + saveFocusState, + restoreFocusState, resetReadyToFocus, setReadyToFocus, isReadyToFocus, + tryRestoreFocusAfterClosedCompletely, + tryRestoreFocusByExternal, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index e9fcb57df1d..aca2f105240 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -5,6 +5,7 @@ import Onyx, {withOnyx} from 'react-native-onyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; @@ -131,6 +132,8 @@ function handleNetworkReconnect() { } } +const modalId = ComposerFocusManager.getId(); + const RootStack = createCustomStackNavigator(); // We want to delay the re-rendering for components(e.g. ReportActionCompose) // that depends on modal visibility until Modal is completely closed and its focused @@ -139,9 +142,12 @@ const RootStack = createCustomStackNavigator(); const modalScreenListeners = { focus: () => { + ComposerFocusManager.saveFocusState(modalId); Modal.setModalVisibility(true); }, beforeRemove: () => { + ComposerFocusManager.restoreFocusState(modalId, true); + // Clear search input (WorkspaceInvitePage) when modal is closed SearchInputManager.searchInput = ''; Modal.setModalVisibility(false); @@ -299,7 +305,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, isUsingMemoryOnlyKeys = f presentation: 'transparentModal', }} getComponent={loadReportAttachments} - listeners={modalScreenListeners} /> (screens: cardStyle: styles.navigationScreenCardStyle, headerShown: false, cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, + keyboardHandlingEnabled: false, }), [styles], ); diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts index 31b8d49e74c..42c68a8c978 100644 --- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts @@ -38,6 +38,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr // Excess space should be on the left so we need to position from right. right: 0, }, + keyboardHandlingEnabled: false, }, leftModalNavigator: { ...commonScreenOptions, diff --git a/src/libs/isWindowReadyToFocus/index.android.ts b/src/libs/isWindowReadyToFocus/index.android.ts new file mode 100644 index 00000000000..b9cca1b5a29 --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.android.ts @@ -0,0 +1,27 @@ +import {AppState} from 'react-native'; + +let isWindowReadyPromise = Promise.resolve(); +let resolveWindowReadyToFocus: () => void; + +AppState.addEventListener('focus', () => { + if (!resolveWindowReadyToFocus) { + return; + } + resolveWindowReadyToFocus(); +}); + +AppState.addEventListener('blur', () => { + isWindowReadyPromise = new Promise((resolve) => { + resolveWindowReadyToFocus = resolve; + }); +}); + +/** + * If we want to show the soft keyboard reliably, we need to ensure that the input's window gains focus first. + * Fortunately, we only need to manage the focus of the app window now, + * so we can achieve this by listening to the 'focus' event of the AppState. + * See {@link https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility#ShowReliably} + */ +const isWindowReadyToFocus = () => isWindowReadyPromise; + +export default isWindowReadyToFocus; diff --git a/src/libs/isWindowReadyToFocus/index.ts b/src/libs/isWindowReadyToFocus/index.ts new file mode 100644 index 00000000000..666ab096cf0 --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.ts @@ -0,0 +1,5 @@ +const isWindowReadyToFocus = () => ({ + then: (callback: () => void) => callback?.(), +}); + +export default isWindowReadyToFocus; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 213d94f51f8..65cab49d41c 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -86,6 +86,9 @@ type BaseReportActionContextMenuProps = BaseReportActionContextMenuOnyxProps & { /** List of disabled actions */ disabledActions?: ContextMenuAction[]; + + /** Callback to fire when a menu item is selected */ + onItemSelected?: (action: ContextMenuAction) => void; }; type MenuItemRefs = Record; @@ -108,6 +111,7 @@ function BaseReportActionContextMenu({ reportActions, checkIfContextMenuActive, disabledActions = [], + onItemSelected = () => {}, }: BaseReportActionContextMenuProps) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -228,6 +232,7 @@ function BaseReportActionContextMenu({ openContextMenu: () => setShouldKeepOpen(true), interceptAnonymousUser, openOverflowMenu, + onPressAddReaction: () => onItemSelected(contextAction), }; if ('renderContent' in contextAction) { @@ -252,7 +257,17 @@ function BaseReportActionContextMenu({ successText={contextAction.successTextTranslateKey ? translate(contextAction.successTextTranslateKey) : undefined} isMini={isMini} key={contextAction.textTranslateKey} - onPress={(event) => interceptAnonymousUser(() => contextAction.onPress?.(closePopup, {...payload, event}), contextAction.isAnonymousAction)} + onPress={(event) => { + onItemSelected(contextAction); + interceptAnonymousUser( + () => + contextAction.onPress?.(closePopup, { + ...payload, + event, + }), + contextAction.isAnonymousAction, + ); + }} description={contextAction.getDescription?.(selection) ?? ''} isAnonymousAction={contextAction.isAnonymousAction} isFocused={focusedIndex === index} diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 6e0034b9193..ebc59eee638 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -5,6 +5,7 @@ import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import * as Expensicons from '@components/Icon/Expensicons'; +import type BaseModalProps from '@components/Modal/types'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; @@ -76,6 +77,7 @@ type ContextMenuActionPayload = { interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; openOverflowMenu: (event: GestureResponderEvent | MouseEvent) => void; event?: GestureResponderEvent | MouseEvent | KeyboardEvent; + onPressAddReaction?: () => void; }; type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; @@ -100,15 +102,17 @@ type ContextMenuActionWithIcon = { type ContextMenuAction = (ContextMenuActionWithContent | ContextMenuActionWithIcon) & { isAnonymousAction: boolean; shouldShow: ShouldShow; + restoreType?: BaseModalProps['restoreFocusType']; }; // A list of all the context actions in this menu. const ContextMenuActions: ContextMenuAction[] = [ { isAnonymousAction: false, + restoreType: CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE, shouldShow: (type, reportAction): reportAction is ReportAction => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !ReportActionsUtils.isMessageDeleted(reportAction), - renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu}) => { + renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu, onPressAddReaction}) => { const isMini = !closePopover; const closeContextMenu = (onHideCallback?: () => void) => { @@ -143,7 +147,10 @@ const ContextMenuActions: ContextMenuAction[] = [ return ( { + onPressAddReaction?.(); + closeContextMenu(onHideCallback); + }} onEmojiSelected={toggleEmojiAndCloseMenu} reportActionID={reportAction?.reportActionID} reportAction={reportAction} @@ -187,6 +194,7 @@ const ContextMenuActions: ContextMenuAction[] = [ } return !ReportUtils.shouldDisableThread(reportAction, reportID); }, + restoreType: CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE, onPress: (closePopover, {reportAction, reportID}) => { if (closePopover) { hideContextMenu(false, () => { @@ -206,6 +214,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: Expensicons.Pencil, shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, + restoreType: CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE, onPress: (closePopover, {reportID, reportAction, draftMessage}) => { if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { hideContextMenu(false); @@ -442,6 +451,7 @@ const ContextMenuActions: ContextMenuAction[] = [ !isArchivedRoom && !isChronosReport && !ReportActionsUtils.isMessageDeleted(reportAction), + restoreType: CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE, onPress: (closePopover, {reportID, reportAction}) => { if (closePopover) { // Hide popover, then call showDeleteConfirmModal diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index b28374fae04..8b95de0c5e3 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -63,6 +63,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef([]); + const [restoreFocusType, setRestoreFocusType] = useState(undefined); const contentRef = useRef(null); const anchorRef = useRef(null); @@ -210,6 +211,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef { + setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT); onPopoverShow.current(); // After we have called the action, reset it. @@ -308,6 +310,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef setRestoreFocusType(action.restoreType ?? CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT)} originalReportID={originalReportIDRef.current} disabledActions={disabledActions} /> diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 444dd939142..59cff4d0f48 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -18,6 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; @@ -73,18 +74,9 @@ const propTypes = { /** Called when opening the attachment picker */ onTriggerAttachmentPicker: PropTypes.func.isRequired, - /** Called when cancelling the attachment picker */ - onCanceledAttachmentPicker: PropTypes.func.isRequired, - /** Called when the menu with the items is closed after it was open */ onMenuClosed: PropTypes.func.isRequired, - /** Called when the add action button is pressed */ - onAddActionPressed: PropTypes.func.isRequired, - - /** Called when the menu item is selected */ - onItemSelected: PropTypes.func.isRequired, - /** A ref for the add action button */ actionButtonRef: PropTypes.shape({ // eslint-disable-next-line react/forbid-prop-types @@ -122,10 +114,7 @@ function AttachmentPickerWithMenuItems({ setMenuVisibility, isMenuVisible, onTriggerAttachmentPicker, - onCanceledAttachmentPicker, onMenuClosed, - onAddActionPressed, - onItemSelected, actionButtonRef, isFocused, raiseIsScrollLikelyLayoutTriggered, @@ -134,6 +123,7 @@ function AttachmentPickerWithMenuItems({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); + const [restoreFocusType, setRestoreFocusType] = useState(undefined); /** * Returns the list of IOU Options @@ -211,7 +201,7 @@ function AttachmentPickerWithMenuItems({ onTriggerAttachmentPicker(); openPicker({ onPicked: displayFileInModal, - onCanceled: onCanceledAttachmentPicker, + onCanceled: () => ComposerFocusManager.tryRestoreFocusByExternal(CONST.MODAL.BUSINESS_TYPE.ATTACHMENT), }); }; const menuItems = [ @@ -283,7 +273,6 @@ function AttachmentPickerWithMenuItems({ if (!isFocused) { return; } - onAddActionPressed(); // Drop focus to avoid blue focus ring. actionButtonRef.current.blur(); @@ -302,17 +291,23 @@ function AttachmentPickerWithMenuItems({ setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT)} onClose={onPopoverMenuClose} onItemSelected={(item, index) => { setMenuVisibility(false); - onItemSelected(); + if (index !== menuItems.length - 1) { + return; + } + setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE); // 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 && Browser.isSafari()) { + if (Browser.isSafari()) { triggerAttachmentPicker(); } }} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 90c2ba0b42c..ddc4995118d 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -7,7 +7,6 @@ import _ from 'underscore'; import Composer from '@components/Composer'; import withKeyboardState from '@components/withKeyboardState'; import useLocalize from '@hooks/useLocalize'; -import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,7 +18,6 @@ import * as ComposerUtils from '@libs/ComposerUtils'; import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import * as EmojiUtils from '@libs/EmojiUtils'; -import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import getPlatform from '@libs/getPlatform'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; @@ -27,7 +25,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as SuggestionUtils from '@libs/SuggestionUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; -import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; @@ -50,8 +47,6 @@ 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(); @@ -102,8 +97,6 @@ function ComposerWithSuggestions({ suggestionsRef, animatedRef, forwardedRef, - isNextModalWillOpenRef, - editFocused, // For testing children, }) { @@ -445,24 +438,14 @@ function ComposerWithSuggestions({ /** * Focus the composer text input - * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose */ - const focus = useCallback((shouldDelay = false) => { - focusComposerWithDelay(textInputRef.current)(shouldDelay); + const focus = useCallback(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); }, []); - const setUpComposeFocusManager = useCallback(() => { - // This callback is used in the contextMenuActions to manage giving focus back to the compose input. - ReportActionComposeFocusManager.onComposerFocus(() => { - if (!willBlurTextInputOnTapOutside || !isFocused) { - return; - } - - focus(false); - }, true); - }, [focus, isFocused]); - /** * Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise. * @returns {Boolean} @@ -505,12 +488,9 @@ function ComposerWithSuggestions({ const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListener(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { KeyDownListener.addKeyDownPressListener(focusComposerOnKeyPress); - setUpComposeFocusManager(); }); KeyDownListener.addKeyDownPressListener(focusComposerOnKeyPress); - setUpComposeFocusManager(); - return () => { ReportActionComposeFocusManager.clear(true); @@ -518,28 +498,7 @@ function ComposerWithSuggestions({ unsubscribeNavigationBlur(); unsubscribeNavigationFocus(); }; - }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]); - - const prevIsModalVisible = usePrevious(modal.isVisible); - const prevIsFocused = usePrevious(isFocused); - useEffect(() => { - if (modal.isVisible && !prevIsModalVisible) { - // eslint-disable-next-line no-param-reassign - isNextModalWillOpenRef.current = false; - } - // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. - // We avoid doing this on native platforms since the software keyboard popping - // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { - return; - } - - if (editFocused) { - InputFocus.inputFocusChange(false); - return; - } - focus(true); - }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef, shouldAutoFocus]); + }, [focusComposerOnKeyPress, navigation]); useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit @@ -679,9 +638,6 @@ export default compose( key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, selector: EmojiUtils.getPreferredSkinToneIndex, }, - editFocused: { - key: ONYXKEYS.INPUT_FOCUSED, - }, parentReportActions: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, canEvict: false, diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js index e2aa1d86af0..c2e9feecaeb 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js @@ -103,9 +103,6 @@ const propTypes = { /** Ref for the composer */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - /** Ref for the isNextModalWillOpen */ - isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, - /** A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not */ isScrollLikelyLayoutTriggered: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c52b8ec6760..8510eafd6dd 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -9,6 +9,7 @@ import _ from 'underscore'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; +import ModalBusinessTypeProvider from '@components/Modal/ModalBusinessTypeProvider'; import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails, withNetwork} from '@components/OnyxProvider'; @@ -24,7 +25,6 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; -import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import ReportDropUI from '@pages/home/report/ReportDropUI'; @@ -94,8 +94,6 @@ const defaultProps = { // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - function ReportActionCompose({ blockedFromConcierge, currentUserPersonalDetails, @@ -202,22 +200,7 @@ function ReportActionCompose({ return translate('reportActionCompose.writeSomething'); }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); - const focus = () => { - if (composerRef === null || composerRef.current === null) { - return; - } - composerRef.current.focus(true); - }; - - const isKeyboardVisibleWhenShowingModalRef = useRef(false); - const isNextModalWillOpenRef = useRef(false); - const restoreKeyboardState = useCallback(() => { - if (!isKeyboardVisibleWhenShowingModalRef.current || isNextModalWillOpenRef.current) { - return; - } - focus(); - isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); + const restoreKeyboardState = useCallback(() => {}, []); const containerRef = useRef(null); const measureContainer = useCallback( @@ -232,17 +215,6 @@ function ReportActionCompose({ [isComposerFullSize], ); - const onAddActionPressed = useCallback(() => { - if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused(); - } - composerRef.current.blur(); - }, []); - - const onItemSelected = useCallback(() => { - isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { if (!suggestionsRef.current) { return; @@ -268,8 +240,7 @@ function ReportActionCompose({ const onAttachmentPreviewClose = useCallback(() => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); - restoreKeyboardState(); - }, [updateShouldShowSuggestionMenuToFalse, restoreKeyboardState]); + }, [updateShouldShowSuggestionMenuToFalse]); /** * Add a new comment to this chat @@ -292,19 +263,13 @@ function ReportActionCompose({ [onSubmit], ); - const onTriggerAttachmentPicker = useCallback(() => { - isNextModalWillOpenRef.current = true; - isKeyboardVisibleWhenShowingModalRef.current = true; - }, []); + const onTriggerAttachmentPicker = useCallback(() => {}, []); - const onBlur = useCallback((e) => { + const onBlur = useCallback(() => { setIsFocused(false); if (suggestionsRef.current) { suggestionsRef.current.resetSuggestions(); } - if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } }, []); const onFocus = useCallback(() => { @@ -383,81 +348,79 @@ function ReportActionCompose({ hasExceededMaxCommentLength && styles.borderColorDanger, ]} > - setIsAttachmentPreviewActive(true)} - onModalHide={onAttachmentPreviewClose} - > - {({displayFileInModal}) => ( - <> - { - isNextModalWillOpenRef.current = false; - restoreKeyboardState(); - }} - onMenuClosed={restoreKeyboardState} - onAddActionPressed={onAddActionPressed} - onItemSelected={onItemSelected} - actionButtonRef={actionButtonRef} - /> - - { - if (isAttachmentPreviewActive) { - return; - } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); - }} - /> - - )} - + + setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} + > + {({displayFileInModal}) => ( + <> + { + restoreKeyboardState(); + }} + onMenuClosed={restoreKeyboardState} + actionButtonRef={actionButtonRef} + /> + + { + if (isAttachmentPreviewActive) { + return; + } + const data = lodashGet(e, ['dataTransfer', 'items', 0]); + displayFileInModal(data); + }} + /> + + )} + + + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( composerRef.current.replaceSelectionWithText(...args)} emojiPickerID={report.reportID} shiftVertical={emojiShiftVertical} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 5934c4c333c..e66ad236a49 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -24,7 +24,6 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import * as ComposerUtils from '@libs/ComposerUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; -import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import onyxSubscribe from '@libs/onyxSubscribe'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -368,11 +367,6 @@ function ReportActionItemMessageEdit( [deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft], ); - /** - * Focus the composer text input - */ - const focus = focusComposerWithDelay(textInputRef.current); - useEffect(() => { validateCommentMaxLength(draft); }, [draft, validateCommentMaxLength]); @@ -456,7 +450,6 @@ function ReportActionItemMessageEdit( focus(true)} onEmojiSelected={addEmojiToTextBox} id={emojiButtonID} emojiPickerID={action.reportActionID} diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index 42ca51cabe0..7a804dc9efb 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -1,7 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback} from 'react'; import AttachmentModal from '@components/AttachmentModal'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; @@ -46,11 +45,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) { defaultOpen report={report} source={source} - onModalHide={() => { - Navigation.dismissModal(); - // This enables Composer refocus when the attachments modal is closed by the browser navigation - ComposerFocusManager.setReadyToFocus(); - }} + onModalHide={() => Navigation.dismissModal()} onCarouselAttachmentChange={onCarouselAttachmentChange} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.js b/src/pages/iou/request/step/IOURequestStepWaypoint.js index 1087018eeed..9c1b706defc 100644 --- a/src/pages/iou/request/step/IOURequestStepWaypoint.js +++ b/src/pages/iou/request/step/IOURequestStepWaypoint.js @@ -100,6 +100,8 @@ function IOURequestStepWaypoint({ const waypointCount = _.size(allWaypoints); const filledWaypointCount = _.size(_.filter(allWaypoints, (waypoint) => !_.isEmpty(waypoint))); + const [restoreFocusType, setRestoreFocusType] = useState(); + const waypointDescriptionKey = useMemo(() => { switch (parsedWaypointIndex) { case 0: @@ -162,6 +164,7 @@ function IOURequestStepWaypoint({ }; const deleteStopAndHideModal = () => { + setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE); Transaction.removeWaypoint(transaction, pageIndex, true); setIsDeleteStopModalOpen(false); Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType)); @@ -208,7 +211,10 @@ function IOURequestStepWaypoint({ { icon: Expensicons.Trashcan, text: translate('distance.deleteWaypoint'), - onSelected: () => setIsDeleteStopModalOpen(true), + onSelected: () => { + setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT); + setIsDeleteStopModalOpen(true); + }, }, ]} /> @@ -221,6 +227,7 @@ function IOURequestStepWaypoint({ confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger + restoreFocusType={restoreFocusType} />