From 1457b8b34ab9667d813da0da1a933e917bff57bd Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Thu, 1 Feb 2024 23:27:24 +0800 Subject: [PATCH 01/13] fix emoji picker keyboard issue --- src/CONST.ts | 9 + src/components/EmojiPicker/EmojiPicker.js | 1 + src/components/Modal/BaseModal.tsx | 35 ++- src/components/Modal/ModalContent.tsx | 19 ++ src/components/Modal/index.android.tsx | 10 - src/components/Modal/types.ts | 9 + src/libs/ComposerFocusManager.ts | 273 +++++++++++++++++- src/libs/focusComposerWithDelay.ts | 15 +- .../isWindowReadyToFocus/index.android.ts | 27 ++ src/libs/isWindowReadyToFocus/index.ts | 3 + 10 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 src/components/Modal/ModalContent.tsx create mode 100644 src/libs/isWindowReadyToFocus/index.android.ts create mode 100644 src/libs/isWindowReadyToFocus/index.ts diff --git a/src/CONST.ts b/src/CONST.ts index 1ccdfd9a82a8..4c5523790a91 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -718,6 +718,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/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index eaf89b7f64ea..10d040301e94 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -187,6 +187,7 @@ const EmojiPicker = forwardRef((props, ref) => { outerStyle={StyleUtils.getOuterModalStyle(windowHeight, props.viewportOffsetTop)} innerContainerStyle={styles.popoverInnerContainer} avoidKeyboard + shouldEnableNewFocusManagement > , ) { @@ -55,6 +58,14 @@ function BaseModal( const isVisibleRef = useRef(isVisible); const wasVisible = usePrevious(isVisible); + const modalId = useMemo(() => ComposerFocusManager.getId(), []); + const saveFocusState = () => { + if (shouldEnableNewFocusManagement) { + ComposerFocusManager.saveFocusState(modalId); + } + ComposerFocusManager.resetReadyToFocus(modalId); + }; + /** * Hides modal * @param callHideCallback - Should we call the onModalHide callback @@ -69,11 +80,9 @@ function BaseModal( onModalHide(); } Modal.onModalDidClose(); - if (!fullscreen) { - ComposerFocusManager.setReadyToFocus(); - } + ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, restoreFocusType); }, - [shouldSetModalVisibility, onModalHide, fullscreen], + [shouldSetModalVisibility, onModalHide, restoreFocusType, modalId], ); useEffect(() => { @@ -121,7 +130,7 @@ function BaseModal( }; const handleDismissModal = () => { - ComposerFocusManager.setReadyToFocus(); + ComposerFocusManager.setReadyToFocus(modalId); }; const { @@ -183,7 +192,7 @@ function BaseModal( onModalShow={handleShowModal} propagateSwipe={propagateSwipe} onModalHide={hideModal} - onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()} + onModalWillShow={saveFocusState} onDismiss={handleDismissModal} onSwipeComplete={() => onClose?.()} swipeDirection={swipeDirection} @@ -207,12 +216,14 @@ function BaseModal( avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} > - - {children} - + + + {children} + + ); } diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx new file mode 100644 index 000000000000..5c8e0d2ece6b --- /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 86a1fd272185..7cb2c6083752 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/types.ts b/src/components/Modal/types.ts index a0cdb737d448..43a2c281415a 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -62,6 +62,15 @@ type BaseModalProps = Partial & { /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; + + /** + * Whether the modal should enable the new focus manager. + * We are attempting to migrate to a new refocus manager, adding this property for gradual migration. + * */ + shouldEnableNewFocusManagement?: boolean; + + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; }; export default BaseModalProps; diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index b66bbe92599e..88e701a2e569 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -1,25 +1,278 @@ -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; + +/** + * 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. + * We will introduce this prop to isolate the impact of the file upload modal on the focus stack. + */ +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, + restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, + businessType: BusinessType = CONST.MODAL.BUSINESS_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); +} + +/** + * Backward compatibility, for cases without an ID, it's fine to just take the topmost one. + */ +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) { + const key = getKey(id); + const promise = promiseMap.get(key); + if (!promise) { + return Promise.resolve(); + } + return promise.ready; +} + +function tryRestoreFocusAfterClosedCompletely(id: ModalId, restoreType: RestoreFocusType, businessType?: BusinessType) { + isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, businessType)); +} + +/** + * 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/focusComposerWithDelay.ts b/src/libs/focusComposerWithDelay.ts index 6a2f85f7d311..a61c45325b3b 100644 --- a/src/libs/focusComposerWithDelay.ts +++ b/src/libs/focusComposerWithDelay.ts @@ -1,6 +1,7 @@ import type {TextInput} from 'react-native'; import * as EmojiPickerAction from './actions/EmojiPickerAction'; import ComposerFocusManager from './ComposerFocusManager'; +import isWindowReadyToFocus from './isWindowReadyToFocus'; type FocusComposerWithDelay = (shouldDelay?: boolean) => void; /** @@ -22,12 +23,14 @@ function focusComposerWithDelay(textInput: TextInput | null): FocusComposerWithD textInput.focus(); return; } - ComposerFocusManager.isReadyToFocus().then(() => { - if (!textInput) { - return; - } - textInput.focus(); - }); + ComposerFocusManager.isReadyToFocus() + .then(isWindowReadyToFocus) + .then(() => { + if (!textInput) { + return; + } + textInput.focus(); + }); }; } diff --git a/src/libs/isWindowReadyToFocus/index.android.ts b/src/libs/isWindowReadyToFocus/index.android.ts new file mode 100644 index 000000000000..b9cca1b5a294 --- /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 000000000000..7ae3930c0c1d --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.ts @@ -0,0 +1,3 @@ +const isWindowReadyToFocus = () => Promise.resolve(); + +export default isWindowReadyToFocus; From 70e763c4652f0c29454b2658e3e096bea23b2ccf Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 2 Feb 2024 12:30:39 +0800 Subject: [PATCH 02/13] fix lint error --- src/components/Modal/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 43a2c281415a..6692f2751e40 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -62,7 +62,7 @@ type BaseModalProps = Partial & { /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; - + /** * Whether the modal should enable the new focus manager. * We are attempting to migrate to a new refocus manager, adding this property for gradual migration. From 6c134cc58c1be0032fa9e853c8fcdc8cd810a322 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Wed, 21 Feb 2024 00:26:00 +0800 Subject: [PATCH 03/13] polish code --- src/libs/ComposerFocusManager.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 88e701a2e569..4bc3edf91569 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -73,7 +73,7 @@ function releaseInput(input: InputElement) { if (input === focusedInput) { focusedInput = null; } - [...focusMap].forEach(([key, value]) => { + focusMap.forEach((value, key) => { if (value.input !== input) { return; } @@ -99,7 +99,7 @@ function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BU } if (shouldClearFocusWithType) { - [...focusMap].forEach(([key, value]) => { + focusMap.forEach((value, key) => { if (value.businessType !== businessType) { return; } @@ -145,12 +145,7 @@ function restoreFocusState( restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, businessType: BusinessType = CONST.MODAL.BUSINESS_TYPE.DEFAULT, ) { - if (!id) { - return; - } - - // The stack is empty - if (activeModals.length < 1) { + if (!id || !activeModals.length) { return; } const index = activeModals.indexOf(id); @@ -219,7 +214,7 @@ function getKey(id: ModalId) { if (promiseMap.size < 1) { return 0; } - return [...promiseMap.keys()].slice(-1)[0]; + return [...promiseMap.keys()].at(-1); } function setReadyToFocus(id?: ModalId) { From 024bd2b69e6a1497a1e5f43d748397e76d9aca13 Mon Sep 17 00:00:00 2001 From: wentao Date: Fri, 23 Feb 2024 13:43:53 +0800 Subject: [PATCH 04/13] Update src/libs/ComposerFocusManager.ts Co-authored-by: Getabalew Tesfaye <75031127+getusha@users.noreply.github.com> --- src/libs/ComposerFocusManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 4bc3edf91569..7ee823475e93 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -36,7 +36,8 @@ const activeModals: ModalId[] = []; const promiseMap = new Map(); /** - * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible. + * Returns the ref of the currently focused text field, if one exists + * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible by using `currentlyFocusedField` instead. */ function getActiveInput() { return (TextInput.State.currentlyFocusedInput ? TextInput.State.currentlyFocusedInput() : TextInput.State.currentlyFocusedField()) as InputElement; From e85ca868c0463eced93d11ebed7c20d77daf971e Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 23 Feb 2024 16:55:11 +0800 Subject: [PATCH 05/13] small improvements --- src/components/Modal/BaseModal.tsx | 2 +- src/components/Modal/ModalContent.tsx | 6 +++++- src/libs/ComposerFocusManager.ts | 14 ++++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index a2478d29253b..e9fa8ce46224 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -80,7 +80,7 @@ function BaseModal( onModalHide(); } Modal.onModalDidClose(); - ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, restoreFocusType); + ComposerFocusManager.refocusAfterModalFullyClosed(modalId, restoreFocusType); }, [shouldSetModalVisibility, onModalHide, restoreFocusType, modalId], ); diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx index 5c8e0d2ece6b..49d3b049220f 100644 --- a/src/components/Modal/ModalContent.tsx +++ b/src/components/Modal/ModalContent.tsx @@ -5,7 +5,11 @@ type ModalContentProps = { /** Modal contents */ children: ReactNode; - /** called after modal content is dismissed */ + /** + * Callback method fired after modal content is unmounted. + * isVisible is not enough to cover all modal close cases, + * such as closing the attachment modal through the browser's back button. + * */ onDismiss: () => void; }; diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 7ee823475e93..a158f752d292 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -17,7 +17,7 @@ type BusinessType = ValueOf | undefined; type RestoreFocusType = ValueOf | undefined; -type ModalContainer = View | HTMLElement | undefined | null; +type ModalContainer = View & HTMLElement | undefined | null; type FocusMapValue = { input: InputElement; @@ -36,7 +36,7 @@ const activeModals: ModalId[] = []; const promiseMap = new Map(); /** - * Returns the ref of the currently focused text field, if one exists + * Returns the ref of the currently focused text field, if one exists. * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible by using `currentlyFocusedField` instead. */ function getActiveInput() { @@ -59,7 +59,9 @@ function clearFocusedInput() { return; } - // we have to use timeout because of measureLayout + // For the PopoverWithMeasuredContent component, Modal is only mounted after onLayout event is triggered, + // this event is placed within a setTimeout in react-native-web, + // so we can safely clear the cached value only after this event. setTimeout(() => (focusedInput = null), CONST.ANIMATION_IN_TIMING); } @@ -108,7 +110,7 @@ function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BU }); } - if (container && 'contains' in container && container.contains(input)) { + if (container?.contains(input)) { return; } focusMap.set(id, {input, businessType}); @@ -237,7 +239,7 @@ function isReadyToFocus(id?: ModalId) { return promise.ready; } -function tryRestoreFocusAfterClosedCompletely(id: ModalId, restoreType: RestoreFocusType, businessType?: BusinessType) { +function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, businessType?: BusinessType) { isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, businessType)); } @@ -269,6 +271,6 @@ export default { resetReadyToFocus, setReadyToFocus, isReadyToFocus, - tryRestoreFocusAfterClosedCompletely, + refocusAfterModalFullyClosed, tryRestoreFocusByExternal, }; From 3754145825af252edcf5a44fa1f9e17e467577a0 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 23 Feb 2024 17:17:48 +0800 Subject: [PATCH 06/13] optional improvement --- src/libs/ComposerFocusManager.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index a158f752d292..84a54495aab9 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -114,10 +114,7 @@ function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BU return; } focusMap.set(id, {input, businessType}); - if (!input) { - return; - } - input.blur(); + input?.blur(); } /** @@ -253,9 +250,6 @@ function tryRestoreFocusByExternal(businessType: BusinessType) { } const [key, {input}] = stack.slice(-1)[0]; focusMap.delete(key); - if (!input) { - return; - } focus(input); } From 251e467f19da7df069e69e11360fadd2b7b18f38 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 23 Feb 2024 17:32:48 +0800 Subject: [PATCH 07/13] function improvement --- src/libs/ComposerFocusManager.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 84a54495aab9..bfbf46887390 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -205,12 +205,9 @@ function resetReadyToFocus(id: ModalId) { } /** - * Backward compatibility, for cases without an ID, it's fine to just take the topmost one. + * Backward compatibility, for cases without an ModalId param, it's fine to just take the topmost one. */ -function getKey(id: ModalId) { - if (id) { - return id; - } +function getTopmostModalId() { if (promiseMap.size < 1) { return 0; } @@ -218,7 +215,7 @@ function getKey(id: ModalId) { } function setReadyToFocus(id?: ModalId) { - const key = getKey(id); + const key = id ?? getTopmostModalId(); const promise = promiseMap.get(key); if (!promise) { return; @@ -228,7 +225,7 @@ function setReadyToFocus(id?: ModalId) { } function isReadyToFocus(id?: ModalId) { - const key = getKey(id); + const key = id ?? getTopmostModalId(); const promise = promiseMap.get(key); if (!promise) { return Promise.resolve(); From fd7367769343b784a9370a6962e3f9c213329e34 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 23 Feb 2024 18:01:50 +0800 Subject: [PATCH 08/13] upload field improvement --- src/CONST.ts | 4 ---- src/libs/ComposerFocusManager.ts | 32 +++++++++++++++----------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 95e3312838d2..21c359b96e13 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -720,10 +720,6 @@ const CONST = { RIGHT: 'right', }, POPOVER_MENU_PADDING: 8, - BUSINESS_TYPE: { - DEFAULT: 'default', - ATTACHMENT: 'attachment', - }, RESTORE_FOCUS_TYPE: { DEFAULT: 'default', DELETE: 'delete', diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index bfbf46887390..a4100e5172f5 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -8,20 +8,18 @@ type ModalId = number | undefined; type InputElement = (TextInput & HTMLElement) | null; -/** - * 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. - * We will introduce this prop to isolate the impact of the file upload modal on the focus stack. - */ -type BusinessType = ValueOf | undefined; - type RestoreFocusType = ValueOf | undefined; type ModalContainer = View & HTMLElement | undefined | null; +/** + * 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. + * We will introduce the isInUploadingContext field to isolate the impact of the upload modal on the other modals. + */ type FocusMapValue = { input: InputElement; - businessType?: BusinessType; + isInUploadingContext?: boolean; }; type PromiseMapValue = { @@ -91,7 +89,7 @@ function getId() { /** * Save the focus state when opening the modal. */ -function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BUSINESS_TYPE.DEFAULT, shouldClearFocusWithType = false, container: ModalContainer = undefined) { +function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFocusWithType = false, container: ModalContainer = undefined) { const activeInput = getActiveInput(); // For popoverWithoutOverlay, react calls autofocus before useEffect. @@ -103,7 +101,7 @@ function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BU if (shouldClearFocusWithType) { focusMap.forEach((value, key) => { - if (value.businessType !== businessType) { + if (value.isInUploadingContext !== isInUploadingContext) { return; } focusMap.delete(key); @@ -113,7 +111,7 @@ function saveFocusState(id: ModalId, businessType: BusinessType = CONST.MODAL.BU if (container?.contains(input)) { return; } - focusMap.set(id, {input, businessType}); + focusMap.set(id, {input, isInUploadingContext}); input?.blur(); } @@ -143,7 +141,7 @@ function restoreFocusState( id: ModalId, shouldIgnoreFocused = false, restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, - businessType: BusinessType = CONST.MODAL.BUSINESS_TYPE.DEFAULT, + isInUploadingContext = false, ) { if (!id || !activeModals.length) { return; @@ -179,7 +177,7 @@ function restoreFocusState( } // Try to find the topmost one and restore it - const stack = [...focusMap].filter(([, v]) => v.input && v.businessType === businessType); + const stack = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext); if (stack.length < 1) { return; } @@ -233,15 +231,15 @@ function isReadyToFocus(id?: ModalId) { return promise.ready; } -function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, businessType?: BusinessType) { - isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, businessType)); +function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, isInUploadingContext?: boolean) { + isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, isInUploadingContext)); } /** * 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); +function tryRestoreFocusByExternal(isInUploadingContext = false) { + const stack = [...focusMap].filter(([, value]) => value.isInUploadingContext === isInUploadingContext && value.input); if (stack.length < 1) { return; } From bc482fb313a8f21343f0578029eeda86c0a51fcd Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 23 Feb 2024 18:15:14 +0800 Subject: [PATCH 09/13] fix lint error --- src/libs/ComposerFocusManager.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index a4100e5172f5..19e9ea66e01e 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -10,7 +10,7 @@ type InputElement = (TextInput & HTMLElement) | null; type RestoreFocusType = ValueOf | undefined; -type ModalContainer = View & HTMLElement | undefined | null; +type ModalContainer = (View & HTMLElement) | undefined | null; /** * So far, modern browsers only support the file cancel event in some newer versions @@ -137,12 +137,7 @@ function focus(input: InputElement, shouldIgnoreFocused = false) { /** * Restore the focus state after the modal is dismissed. */ -function restoreFocusState( - id: ModalId, - shouldIgnoreFocused = false, - restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, - isInUploadingContext = false, -) { +function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, isInUploadingContext = false) { if (!id || !activeModals.length) { return; } From 628c44d4a2139b5510a1cd2d997c843063b787eb Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Thu, 29 Feb 2024 22:00:59 +0800 Subject: [PATCH 10/13] fix switch bug --- src/components/EmojiPicker/EmojiPicker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 6a963db22b8b..3ab0f398fa9d 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -192,6 +192,7 @@ const EmojiPicker = forwardRef((props, ref) => { innerContainerStyle={styles.popoverInnerContainer} avoidKeyboard shouldEnableNewFocusManagement + restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} > Date: Fri, 8 Mar 2024 22:21:07 +0800 Subject: [PATCH 11/13] code style improvement --- src/libs/ComposerFocusManager.ts | 58 ++++++++++++-------------------- 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 19e9ea66e01e..04243454d541 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -120,18 +120,26 @@ function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFo * Additionally, if we are closing the RHP, we can ignore the focused input. */ function focus(input: InputElement, shouldIgnoreFocused = false) { - if (!input) { + const activeInput = getActiveInput(); + if (!input || activeInput && !shouldIgnoreFocused) { return; } - if (shouldIgnoreFocused) { - isWindowReadyToFocus().then(() => input.focus()); + isWindowReadyToFocus().then(() => input.focus()); +} + +function tryRestoreTopmostFocus(shouldIgnoreFocused: boolean, isInUploadingContext = false) { + const topmost = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext).at(-1); + if (topmost === undefined) { return; } - const activeInput = getActiveInput(); - if (activeInput) { + const [modalId, {input}] = topmost; + + // This modal is still active + if (activeModals.indexOf(modalId) >= 0) { return; } - isWindowReadyToFocus().then(() => input.focus()); + focus(input, shouldIgnoreFocused); + focusMap.delete(modalId); } /** @@ -141,13 +149,13 @@ function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocu if (!id || !activeModals.length) { return; } - const index = activeModals.indexOf(id); + const activeModalIndex = activeModals.indexOf(id); // This id has been removed from the stack. - if (index < 0) { + if (activeModalIndex < 0) { return; } - activeModals.splice(index, 1); + activeModals.splice(activeModalIndex, 1); if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE) { return; } @@ -159,9 +167,9 @@ function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocu } // This modal is not the topmost one, do not restore it. - if (activeModals.length > index) { + if (activeModals.length > activeModalIndex) { if (input) { - const lastId = activeModals.slice(-1)[0]; + const lastId = activeModals.at(-1); focusMap.set(lastId, {...focusMap.get(lastId), input}); } return; @@ -172,18 +180,7 @@ function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocu } // Try to find the topmost one and restore it - const stack = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext); - 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); + tryRestoreTopmostFocus(shouldIgnoreFocused, isInUploadingContext); } function resetReadyToFocus(id: ModalId) { @@ -230,19 +227,6 @@ function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, isInUploadingContext)); } -/** - * So far, this will only be called in file canceled event handler. - */ -function tryRestoreFocusByExternal(isInUploadingContext = false) { - const stack = [...focusMap].filter(([, value]) => value.isInUploadingContext === isInUploadingContext && value.input); - if (stack.length < 1) { - return; - } - const [key, {input}] = stack.slice(-1)[0]; - focusMap.delete(key); - focus(input); -} - export type {InputElement}; export default { @@ -256,5 +240,5 @@ export default { setReadyToFocus, isReadyToFocus, refocusAfterModalFullyClosed, - tryRestoreFocusByExternal, + tryRestoreTopmostFocus, }; From 6fe725d3b4ac8fd8be2045b12497aec7ed208f67 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Fri, 8 Mar 2024 22:31:13 +0800 Subject: [PATCH 12/13] fix lint error --- src/libs/ComposerFocusManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index 04243454d541..d793c202d243 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -121,7 +121,7 @@ function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFo */ function focus(input: InputElement, shouldIgnoreFocused = false) { const activeInput = getActiveInput(); - if (!input || activeInput && !shouldIgnoreFocused) { + if (!input || (activeInput && !shouldIgnoreFocused)) { return; } isWindowReadyToFocus().then(() => input.focus()); From d7d5a878e3c050a1fa638da8bf25bf759fdf6f22 Mon Sep 17 00:00:00 2001 From: ntdiary <2471314@gmail.com> Date: Mon, 1 Apr 2024 23:58:40 +0800 Subject: [PATCH 13/13] fix selection bug --- src/libs/focusComposerWithDelay/index.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 6e3f6d1828e9..cbd81b884d12 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -27,17 +27,15 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD } return; } - ComposerFocusManager.isReadyToFocus() - .then(isWindowReadyToFocus) - .then(() => { - if (!textInput) { - return; - } - textInput.focus(); - if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); - } - }); + Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => { + if (!textInput) { + return; + } + textInput.focus(); + if (forcedSelectionRange) { + setTextInputSelection(textInput, forcedSelectionRange); + } + }); }; }