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: Composer not focused on click while editor's emoji modal is open #28238

Merged
merged 35 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0f02054
fix: composer not focused on click
tienifr Sep 26, 2023
86d30a4
add focus check
tienifr Sep 26, 2023
27f6a05
reuse existing functions
tienifr Sep 26, 2023
24d1fa9
clear FocusManager on unmount
tienifr Sep 26, 2023
d262d65
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Sep 29, 2023
f9a54af
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 2, 2023
83f7745
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 4, 2023
801066c
focus main composer
tienifr Oct 4, 2023
0472812
re-focus the right composer on emoji modal hide
tienifr Oct 4, 2023
e7c2fb5
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 4, 2023
b2dede8
refocus composer on context menu close
tienifr Oct 4, 2023
c6b17d5
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 4, 2023
3f4e9d7
remove willBlurOnTapOutside check to re-focus the correct composer
tienifr Oct 4, 2023
fb99586
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 4, 2023
6bcd089
clear focus callback on unmount
tienifr Oct 6, 2023
b1025c1
add comment
tienifr Oct 6, 2023
3439d49
add comment for removing callback
tienifr Oct 6, 2023
f100c71
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 11, 2023
f51ed18
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Oct 11, 2023
fdfec45
Merge branch 'main' into fix/25892
tienifr Oct 12, 2023
97338fc
Merge branch 'main' into fix/25892
tienifr Dec 8, 2023
759b4a5
implement callback priority approach
tienifr Dec 8, 2023
a20eace
modify comment
tienifr Dec 8, 2023
bb9f370
Merge branch main into fix/25892
tienifr Dec 20, 2023
66ce543
fix lint
tienifr Dec 20, 2023
0ae8452
Merge branch 'main' of https://github.com/tienifr/App into fix/25892
tienifr Aug 1, 2024
f1b4e20
reapply changes
tienifr Aug 1, 2024
fe16fc0
fix lint
tienifr Aug 1, 2024
f6b25c8
focus to edit composer if press its emoji picker
tienifr Aug 2, 2024
21d8617
Merge branch 'main' into fix/25892
tienifr Aug 9, 2024
df83fc8
Merge branch 'main' into fix/25892
tienifr Aug 19, 2024
599fe46
resolve conflicts
tienifr Aug 21, 2024
031ab6c
Merge branch 'main' into fix/25892
tienifr Aug 30, 2024
dc73745
lint fix
tienifr Aug 30, 2024
b41e23c
Merge branch 'main' into fix/25892
tienifr Sep 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions src/components/Composer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import Text from '../Text';
import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition';
import CONST from '../../CONST';
import withNavigation from '../withNavigation';
import ReportActionComposeFocusManager from '../../libs/ReportActionComposeFocusManager';

const propTypes = {
/** Maximum number of lines in the text input */
Expand Down Expand Up @@ -80,9 +79,6 @@ const propTypes = {
/** Function to check whether composer is covered up or not */
checkComposerVisibility: PropTypes.func,

/** Whether this is the report action compose */
isReportActionCompose: PropTypes.bool,

/** Whether the sull composer is open */
isComposerFullSize: PropTypes.bool,

Expand Down Expand Up @@ -113,7 +109,6 @@ const defaultProps = {
setIsFullComposerAvailable: () => {},
shouldCalculateCaretPosition: false,
checkComposerVisibility: () => false,
isReportActionCompose: false,
isComposerFullSize: false,
};

Expand Down Expand Up @@ -164,7 +159,6 @@ function Composer({
setIsFullComposerAvailable,
checkComposerVisibility,
selection: selectionProp,
isReportActionCompose,
isComposerFullSize,
...props
}) {
Expand Down Expand Up @@ -388,9 +382,6 @@ function Composer({
}

return () => {
if (!isReportActionCompose) {
ReportActionComposeFocusManager.clear();
}
unsubscribeFocus();
unsubscribeBlur();
document.removeEventListener('paste', handlePaste);
Expand Down Expand Up @@ -468,18 +459,6 @@ function Composer({
numberOfLines={numberOfLines}
disabled={isDisabled}
onKeyPress={handleKeyPress}
onFocus={(e) => {
ReportActionComposeFocusManager.onComposerFocus(() => {
if (!textInput.current) {
return;
}

textInput.current.focus();
});
if (props.onFocus) {
props.onFocus(e);
}
}}
/>
{shouldCalculateCaretPosition && renderElementForCaretPosition}
</>
Expand Down
7 changes: 6 additions & 1 deletion src/components/EmojiPicker/EmojiPickerButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ const propTypes = {
/** Unique id for emoji picker */
emojiPickerID: PropTypes.string,

/** A callback function when the button is pressed */
onPress: PropTypes.func,

...withLocalizePropTypes,
};

const defaultProps = {
isDisabled: false,
nativeID: '',
emojiPickerID: '',
onPress: () => {},
};

function EmojiPickerButton(props) {
Expand All @@ -40,12 +44,13 @@ function EmojiPickerButton(props) {
ref={emojiPopoverAnchor}
style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]}
disabled={props.isDisabled}
onPress={() => {
onPress={(e) => {
if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) {
EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID);
} else {
EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker();
}
props.onPress(e);
}}
nativeID={props.nativeID}
accessibilityLabel={props.translate('reportActionCompose.emoji')}
Expand Down
14 changes: 8 additions & 6 deletions src/libs/ReportActionComposeFocusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,18 @@ function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
* Request focus on the ReportActionComposer
*/
function focus() {
if (typeof focusCallback !== 'function') {
if (typeof mainComposerFocusCallback !== 'function') {
return;
}
if (typeof focusCallback !== 'function' && typeof mainComposerFocusCallback !== 'function') {
return;
}

mainComposerFocusCallback();
if (typeof focusCallback === 'function') {
focusCallback();
return;
}

focusCallback();
if (typeof mainComposerFocusCallback === 'function') {
mainComposerFocusCallback();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,16 +384,19 @@ function ComposerWithSuggestions({
focusWithDelay(textInputRef.current)(shouldDelay);
}, []);

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;
}
const setUpComposeFocusManager = useCallback(
(isMainComposer = true) => {
// 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]);
focus(false);
}, isMainComposer);
},
[focus, isFocused],
);

/**
* Check if the composer is visible. Returns true if the composer is not covered up by emoji picker or menu. False otherwise.
Expand Down Expand Up @@ -517,7 +520,10 @@ function ComposerWithSuggestions({
onKeyPress={triggerHotkeyActions}
style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]}
maxLines={maxComposerLines}
onFocus={onFocus}
onFocus={() => {
setUpComposeFocusManager(false);
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain this?

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 a note: Explained here #28238 (comment).

Copy link
Member

Choose a reason for hiding this comment

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

Can you please explain this? I didn't understand that comment. Why are we setting isMainComposer false even though it is the main composer?

Please add a comment 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.

This comment explains from the code perspective.

Conceptually, the ReportActionComposeFocusManager is not all about general and main composer but it's actually about the order of callback execution. The general focus callback would take priority, once there's any focus callback left, the main composer callback would never be trigered (i.e. main composer never gets the focus). If we did not re-subscribe main composer as general callback, it would never be triggered.

Copy link
Member

@parasharrajat parasharrajat Oct 13, 2023

Choose a reason for hiding this comment

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

What is general focus or main focus callback?

If we did not re-subscribe main composer as general callback,

What does it mean? 😃

Copy link
Contributor Author

@tienifr tienifr Oct 16, 2023

Choose a reason for hiding this comment

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

In ReportActionFocusManager, we've two callbacks: mainComposerFocusCallback for main composer (ReportActionCompose) and focusCallback for general (edit) composers.

  1. mainComposerFocusCallback would only be trigered if there's no focusCallback
  2. The focusCallback would be only be cleared if edit composer unmounted

As a result, mainComposerFocusCallback would never be trigered if there's edit composer, because focusCallback was there and took the priority. So we need to overwrite the focusCallback to point to main composer when it gets focused.

I know the isMainComposer param might be confusing and this is admittedly a confusing logic. But if you looked at the code changes for onFocus prop between Composer and ComposerWithSuggestion, you would get it. Or even better, we should not refactor it to avoid confusions for others :D

Copy link
Member

@parasharrajat parasharrajat Oct 16, 2023

Choose a reason for hiding this comment

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

This is a mess. I think we should refactor this to make it clear in each step what we are doing. These functions naming and the pattern are confusing not the code.

Copy link
Member

Choose a reason for hiding this comment

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

we should find a way to set the order to the focus callbacks. Logic should say that now this callback should be called first if both general and main focus callbacks are set.

onFocus();
}}
onBlur={onBlur}
onClick={setShouldBlockSuggestionCalcToFalse}
onPasteFile={displayFileInModal}
Expand Down
33 changes: 26 additions & 7 deletions src/pages/home/report/ReportActionItemMessageEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ function ReportActionItemMessageEdit(props) {
[props.action.reportActionID],
);

/**
* Focus the composer text input
* @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
* @memberof ReportActionCompose
*/
const focus = useCallback((shouldDelay = false) => {
focusWithDelay(textInputRef.current)(shouldDelay);
}, []);
Copy link
Member

Choose a reason for hiding this comment

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

Why did you recreate this if it was already present below? If you want to use useCallback, we can do so

Suggested change
/**
* Focus the composer text input
* @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
* @memberof ReportActionCompose
*/
const focus = useCallback((shouldDelay = false) => {
focusWithDelay(textInputRef.current)(shouldDelay);
}, []);
const focus = useCallback(focusWithDelay(textInputRef.current), []);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was referenced from ComposerWithSuggestions which allows the shouldDelay param:

const focus = useCallback((shouldDelay = false) => {
focusWithDelay(textInputRef.current)(shouldDelay);
}, []);

Anw, the former implementation without useCallback is still fine if you want to stick with it.

Copy link
Member

Choose a reason for hiding this comment

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

Actually, focusWithDelay is a curry function that returns a callback that accepts shouldDelay by default. IMO, it was done in this way to be explicit about parameters. If the suggested code works, let's do that instead.

const focus = useCallback(focusWithDelay(textInputRef.current), []);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This would cause lint error: ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. I think the implementation referenced from ComposerWithSuggestions is better.


const setUpComposeFocusManager = useCallback(() => {
ReportActionComposeFocusManager.onComposerFocus(() => {
focus(true);
}, false);
}, [focus]);

useEffect(
() => () => {
ReportActionComposeFocusManager.clear();
},
[],
);
tienifr marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
// For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus
// and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style),
Expand Down Expand Up @@ -305,11 +327,6 @@ function ReportActionItemMessageEdit(props) {
[deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft],
);

/**
* Focus the composer text input
*/
const focus = focusWithDelay(textInputRef.current);

return (
<>
<View style={[styles.chatItemMessage, styles.flexRow]}>
Expand Down Expand Up @@ -365,6 +382,7 @@ function ReportActionItemMessageEdit(props) {
setIsFocused(true);
reportScrollManager.scrollToIndex({animated: true, index: props.index}, true);
setShouldShowComposeInputKeyboardAware(false);
setUpComposeFocusManager();
Copy link
Member

Choose a reason for hiding this comment

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

This will cause recursion.

Copy link
Member

@parasharrajat parasharrajat Oct 6, 2023

Choose a reason for hiding this comment

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

In other words, We will resubscribe to focusHandler(ReportActionComposeFocusManager) every time, it gets focused where focus might be triggered from the same focushandler(ReportActionComposeFocusManager).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The purpose of this is to let the last focused composer to re-gain focus:

We need to make sure the ReportActionComposeFocusManager can handle both:

  • general composer (which includes the edit composer): If this is the last composer that was focused on, we want to refocus on this
  • main composer: If there's no last composer that was focused on before, this should be the fallback composer that we need to focus

This also explains why I did the same with ComposerWithSuggestions above.

Copy link
Contributor Author

@tienifr tienifr Oct 6, 2023

Choose a reason for hiding this comment

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

I don't think this could cause recursion because focusCallback is overwritten.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From code perspective, previously the onComposerFocus callback was overwritten onFocus in Composer component. Thus if we removed the callback there, we need to do the same with parent composers.

Copy link
Contributor Author

@tienifr tienifr Oct 6, 2023

Choose a reason for hiding this comment

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

Speaking of this, I realized that we should not subscribe the focus callback when composer mounts but when it's focused instead. Subscribe on mount is redundant.

Copy link
Member

Choose a reason for hiding this comment

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

Please add a comment here on why calling this function is necessary.


// Clear active report action when another action gets focused
if (!EmojiPickerAction.isActive(props.action.reportActionID)) {
Expand All @@ -390,12 +408,13 @@ function ReportActionItemMessageEdit(props) {
<EmojiPickerButton
isDisabled={props.shouldDisableEmojiPicker}
onModalHide={() => {
setIsFocused(true);
focus(true);
ReportActionComposeFocusManager.focus();
}}
onEmojiSelected={addEmojiToTextBox}
nativeID={emojiButtonID}
emojiPickerID={props.action.reportActionID}
// Let the pressed composer re-gain focus on modal hide
tienifr marked this conversation as resolved.
Show resolved Hide resolved
onPress={setUpComposeFocusManager}
/>
</View>

Expand Down
Loading