Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix keyboard flashing while clicking "Add attachment" #23994

Merged
merged 17 commits into from
Aug 23, 2023
Merged
15 changes: 15 additions & 0 deletions patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/node_modules/react-native/React/Views/RCTModalHostViewManager.m b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
index 4b9f9ad..4992874 100644
--- a/node_modules/react-native/React/Views/RCTModalHostViewManager.m
+++ b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
@@ -79,6 +79,10 @@ RCT_EXPORT_MODULE()
if (self->_presentationBlock) {
self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock);
} else {
+ UIWindow *window = RCTKeyWindow();
+ if (window && window.rootViewController && [window.rootViewController.view isFirstResponder]) {
+ [window.rootViewController.view resignFirstResponder];
+ }
ntdiary marked this conversation as resolved.
Show resolved Hide resolved
[[modalHostView reactViewController] presentViewController:viewController
animated:animated
completion:completionBlock];
13 changes: 11 additions & 2 deletions src/components/AttachmentPicker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ function getAcceptableFileTypes(type) {
function AttachmentPicker(props) {
const fileInput = useRef();
const onPicked = useRef();
const onCanceled = useRef(() => {});

return (
<>
<input
Expand All @@ -46,13 +48,20 @@ function AttachmentPicker(props) {
}}
// We are stopping the event propagation because triggering the `click()` on the hidden input
// causes the event to unexpectedly bubble up to anything wrapping this component e.g. Pressable
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
if (!fileInput.current) {
return;
}
fileInput.current.addEventListener('cancel', () => onCanceled.current(), {once: true});
}}
accept={getAcceptableFileTypes(props.type)}
/>
{props.children({
openPicker: ({onPicked: newOnPicked}) => {
openPicker: ({onPicked: newOnPicked, onCanceled: newOnCanceled = () => {}}) => {
onPicked.current = newOnPicked;
fileInput.current.click();
onCanceled.current = newOnCanceled;
},
})}
</>
Expand Down
12 changes: 10 additions & 2 deletions src/components/AttachmentPicker/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class AttachmentPicker extends Component {
});
}

this.cancel = () => {};
this.close = this.close.bind(this);
this.pickAttachment = this.pickAttachment.bind(this);
this.removeKeyboardListener = this.removeKeyboardListener.bind(this);
Expand Down Expand Up @@ -181,6 +182,7 @@ class AttachmentPicker extends Component {
*/
pickAttachment(attachments = []) {
if (attachments.length === 0) {
this.cancel();
return;
}

Expand Down Expand Up @@ -342,15 +344,21 @@ class AttachmentPicker extends Component {
*/
renderChildren() {
return this.props.children({
openPicker: ({onPicked}) => this.open(onPicked),
openPicker: ({onPicked, onCanceled = () => {}}) => {
this.open(onPicked);
this.cancel = onCanceled;
},
});
}

render() {
return (
<>
<Popover
onClose={this.close}
onClose={() => {
this.close();
this.cancel();
}}
isVisible={this.state.isVisible}
anchorPosition={styles.createMenuPosition}
onModalHide={this.onModalHide}
Expand Down
8 changes: 8 additions & 0 deletions src/components/Modal/BaseModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {propTypes as modalPropTypes, defaultProps as modalDefaultProps} from './
import * as Modal from '../../libs/actions/Modal';
import getModalStyles from '../../styles/getModalStyles';
import variables from '../../styles/variables';
import ComposerFocusManager from '../../libs/ComposerFocusManager';

const propTypes = {
...modalPropTypes,
Expand Down Expand Up @@ -73,6 +74,9 @@ class BaseModal extends PureComponent {
this.props.onModalHide();
}
Modal.onModalDidClose();
if (!this.props.fullscreen) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this condition for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure that the pending promise can also be settled when coverScreen is false, because onDismiss will not be emitted in this case. (It only exists in the RN Modal component.)
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @ntdiary, I know this thread is kinda old, but would appreciate your input:
Why was it necessary to put the ComposerFocusManager.setReadyToFocus() logic into both onDismiss and onModalHide (which calls hideModal)?
Was there any drawback to just calling it in hideModal regardless of the fullscreen condition? This would mean removing the ReactNativeModal.onDismiss prop below.
I'm asking because we've recently discovered another case when the onDismiss is not called, this time for a full-screen Attachments modal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paultsimura, when using the Modal component, the invocation of onModalHide happens earlier than the destruction of the focus trap, which will cause the composer not gaining focus correctly after the modal is dismissed. :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ntdiary. Do you have any particular Modal scenario in mind that might break?
I tried with only the hideModal: emoji picker, attachments modal, the side drawer – everything focuses the composer correctly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paultsimura, I'm not exactly sure about the new code in the main branch, maybe you can verify the emoji picker in mobile chrome. Additionally, I'm refactoring the modal's refocusing behavior (#29199), so there might be a chance to review the related code again tomorrow. :)

Copy link
Contributor

@paultsimura paultsimura Nov 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, would your change cover the modal being closed on clicking the browser's "Back" button as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I added a new ModalContent, I feel it should fix that issue.

ComposerFocusManager.setReadyToFocus();
}
}

render() {
Expand Down Expand Up @@ -109,6 +113,9 @@ class BaseModal extends PureComponent {
// Note: Escape key on web/desktop will trigger onBackButtonPress callback
// eslint-disable-next-line react/jsx-props-no-multi-spaces
onBackButtonPress={this.props.onClose}
onModalWillShow={() => {
ComposerFocusManager.resetReadyToFocus();
}}
onModalShow={() => {
if (this.props.shouldSetModalVisibility) {
Modal.setModalVisibility(true);
Expand All @@ -117,6 +124,7 @@ class BaseModal extends PureComponent {
}}
propagateSwipe={this.props.propagateSwipe}
onModalHide={this.hideModal}
onDismiss={() => ComposerFocusManager.setReadyToFocus()}
onSwipeComplete={this.props.onClose}
swipeDirection={swipeDirection}
isVisible={this.props.isVisible}
Expand Down
10 changes: 10 additions & 0 deletions src/components/Modal/index.android.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import React from 'react';
import {AppState} from 'react-native';
import withWindowDimensions from '../withWindowDimensions';
import BaseModal from './BaseModal';
import {propTypes, defaultProps} from './modalPropTypes';
import ComposerFocusManager from '../../libs/ComposerFocusManager';

AppState.addEventListener('focus', () => {
ComposerFocusManager.setReadyToFocus();
});

AppState.addEventListener('blur', () => {
ComposerFocusManager.resetReadyToFocus();
});

// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
Expand Down
23 changes: 23 additions & 0 deletions src/libs/ComposerFocusManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
let isReadyToFocusPromise = Promise.resolve();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this logic on web at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the Modal component in react-native-web has focus trap. This code, together with InteractionManager.runAfterInteractions, can also ensure that the focus is called safely.

let resolveIsReadyToFocus;

function resetReadyToFocus() {
isReadyToFocusPromise = new Promise((resolve) => {
resolveIsReadyToFocus = resolve;
});
}
function setReadyToFocus() {
if (!resolveIsReadyToFocus) {
return;
}
resolveIsReadyToFocus();
}
function isReadyToFocus() {
return isReadyToFocusPromise;
}

export default {
resetReadyToFocus,
setReadyToFocus,
isReadyToFocus,
};
57 changes: 51 additions & 6 deletions src/pages/home/report/ReportActionCompose.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import {View, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native';
import {View, InteractionManager, LayoutAnimation, NativeModules, findNodeHandle} from 'react-native';
import {runOnJS} from 'react-native-reanimated';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import _ from 'underscore';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
import focusWithDelay from '../../../libs/focusWithDelay';
import styles from '../../../styles/styles';
import themeColors from '../../../styles/themes/default';
import Composer from '../../../components/Composer';
Expand Down Expand Up @@ -60,6 +59,7 @@ import * as KeyDownListener from '../../../libs/KeyboardShortcut/KeyDownPressLis
import * as EmojiPickerActions from '../../../libs/actions/EmojiPickerAction';
import withAnimatedRef from '../../../components/withAnimatedRef';
import updatePropsPaperWorklet from '../../../libs/updatePropsPaperWorklet';
import ComposerFocusManager from '../../../libs/ComposerFocusManager';

const propTypes = {
/** Beta features list */
Expand Down Expand Up @@ -178,7 +178,7 @@ class ReportActionCompose extends React.Component {
this.submitForm = this.submitForm.bind(this);
this.setIsFocused = this.setIsFocused.bind(this);
this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this);
this.focus = focusWithDelay(this.textInput).bind(this);
this.focus = this.focus.bind(this);
this.replaceSelectionWithText = this.replaceSelectionWithText.bind(this);
this.focusComposerOnKeyPress = this.focusComposerOnKeyPress.bind(this);
this.checkComposerVisibility = this.checkComposerVisibility.bind(this);
Expand Down Expand Up @@ -223,6 +223,8 @@ class ReportActionCompose extends React.Component {
this.unsubscribeNavigationBlur = () => null;
this.unsubscribeNavigationFocus = () => null;

this.shouldFocusAfterClosingModal = true;

this.state = {
isFocused: this.shouldFocusInputOnScreenFocus && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible && this.props.shouldShowComposeInput,
isFullComposerAvailable: props.isComposerFullSize,
Expand Down Expand Up @@ -271,10 +273,19 @@ class ReportActionCompose extends React.Component {
}

componentDidUpdate(prevProps) {
if (this.props.modal.isVisible && !prevProps.modal.isVisible) {
this.shouldFocusAfterClosingModal = true;
}
// 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 (this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && this.props.isFocused && (prevProps.modal.isVisible || !prevProps.isFocused)) {
if (
this.willBlurTextInputOnTapOutside &&
this.shouldFocusAfterClosingModal &&
!this.props.modal.isVisible &&
this.props.isFocused &&
(prevProps.modal.isVisible || !prevProps.isFocused)
) {
this.focus();
}

Expand Down Expand Up @@ -382,7 +393,6 @@ class ReportActionCompose extends React.Component {
if (_.isFunction(this.props.animatedRef)) {
this.props.animatedRef(el);
}
this.focus = focusWithDelay(this.textInput).bind(this);
}

/**
Expand Down Expand Up @@ -743,6 +753,32 @@ class ReportActionCompose extends React.Component {
this.replaceSelectionWithText(e.key, false);
}

/**
* Focus the composer text input
* @param {Boolean} [shouldelay=false] Impose delay before focusing the composer
* @memberof ReportActionCompose
*/
focus(shouldelay = false) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to libs/focusWithDelay replacing setTimeout approach. And this will fix edit composer as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually libs/focusWithDelay has an small issue, mobile web also needs delay (especially mobile Safari). If moving the above code to focusWithDelay, I plan to remove the disableDelay variable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, go ahead

// There could be other animations running while we trigger manual focus.
// This prevents focus from making those animations janky.
InteractionManager.runAfterInteractions(() => {
if (!this.textInput) {
return;
}

if (!shouldelay) {
this.textInput.focus();
return;
}
ComposerFocusManager.isReadyToFocus().then(() => {
if (!this.textInput) {
return;
}
this.textInput.focus();
});
});
}

/**
* Save our report comment in Onyx. We debounce this method in the constructor so that it's not called too often
* to update Onyx and re-render this component.
Expand Down Expand Up @@ -1012,6 +1048,7 @@ class ReportActionCompose extends React.Component {
this.shouldBlockEmojiCalc = false;
this.shouldBlockMentionCalc = false;
this.setState({isAttachmentPreviewActive: false});
this.focus(true);
}}
>
{({displayFileInModal}) => (
Expand All @@ -1025,8 +1062,12 @@ class ReportActionCompose extends React.Component {
this.shouldBlockEmojiCalc = true;
this.shouldBlockMentionCalc = true;
}
this.shouldFocusAfterClosingModal = false;
openPicker({
onPicked: displayFileInModal,
onCanceled: () => {
this.focus(true);
},
});
};
const menuItems = [
Expand Down Expand Up @@ -1095,6 +1136,7 @@ class ReportActionCompose extends React.Component {
ref={this.actionButtonRef}
onPress={(e) => {
e.preventDefault();
this.textInput.blur();

// Drop focus to avoid blue focus ring.
this.actionButtonRef.current.blur();
Expand All @@ -1112,7 +1154,10 @@ class ReportActionCompose extends React.Component {
<PopoverMenu
animationInTiming={CONST.ANIMATION_IN_TIMING}
isVisible={this.state.isMenuVisible}
onClose={() => this.setMenuVisibility(false)}
onClose={() => {
this.setMenuVisibility(false);
this.focus(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep original focus state before opening popover.
We should not change current focus behavior. Please compare this branch build with main or staging.
Only the difference here should be to fix keyboard flash. Other behaviors should keep the same on all platforms.

Screen.Recording.2023-08-16.at.6.03.06.PM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand what you mean😄. The current focus behavior is based on the previous discussion and PR , it's not an accidental change. If we ultimately decide to preserve state, the the conditions for determining focus in the solution will certainly also be different 🙂.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trjExpensify can you please confirm this is the new expected behavior we landed?
I see that composer is always focused upon modal close no matter keyboard was shown or not before opening modal

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just FYI, for now the focus behavior of attachment modal and emoji modal is consistent. Also we have discussed composer's refocusing behavior in many places, e.g., issue #9252 is also interested in preserving state, issue #15992 is looking for a more overall design. (actually, I personally also prefer preserving state, and have implemented it before, however as I mentioned in the proposal, it will affect many places, so handling it in other issues may be more appropriate 🙂).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should be consistent and give focus back to the composer input here when the action menu closes, allowing the software keyboard to be shown at the same time.

To clarify what I was talking about here in the linked comment referenced is the scenario in which we were addressing in the issue.

  1. The keyboard is open because you focused the composer
  2. You tap + > add attachment
  3. Tap off the action menu
  4. The action menu closes and the keyboard reopens

I agree that should be what we're shooting for here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that should be what we're shooting for here.

@trjExpensify, hi, does this mean that if the keyboard was not shown before opening the attachment modal, the keyboard also should not be shown after closing the modal? If so, I will modify the focus condition later. : )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. I originally thought the focus behavior in the PR #15337 was as expected 😂.
Just modified. Please feel free to let me know if there are any differences from expectations. : )
cc @situchan

}}
onItemSelected={(item, index) => {
this.setMenuVisibility(false);

Expand Down