diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 0cddb32f5aeb..a02767d24c87 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; @@ -9,6 +9,7 @@ import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useKeyboardState from '@hooks/useKeyboardState'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -37,6 +38,7 @@ function Composer( selection, value, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -49,7 +51,11 @@ function Composer( const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [contextMenuHidden, setContextMenuHidden] = useState(true); + const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); + const keyboardState = useKeyboardState(); + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; useEffect(() => { if (autoFocus === !!autoFocusInputRef.current) { @@ -58,6 +64,13 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!showSoftInputOnFocus || !isKeyboardShown) { + return; + } + setContextMenuHidden(false); + }, [showSoftInputOnFocus, isKeyboardShown]); + useEffect(() => { if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { return; @@ -158,6 +171,8 @@ function Composer( props?.onBlur?.(e); }} onClear={onClear} + showSoftInputOnFocus={showSoftInputOnFocus} + contextMenuHidden={contextMenuHidden} /> ); } diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 5af76a2406b5..9171132964f6 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -5,7 +5,7 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; -import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, StyleSheet} from 'react-native'; import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -50,6 +50,7 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -74,6 +75,11 @@ function Composer( }); const [hasMultipleLines, setHasMultipleLines] = useState(false); const [isRendered, setIsRendered] = useState(false); + + // On mobile safari, the cursor will move from right to left with inputMode set to none during report transition + // To avoid that we should hide the cursor util the transition is finished + const [shouldTransparentCursor, setShouldTransparentCursor] = useState(!showSoftInputOnFocus && Browser.isMobileSafari()); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const [prevHeight, setPrevHeight] = useState(); @@ -260,6 +266,15 @@ function Composer( setIsRendered(true); }, []); + useEffect(() => { + if (!shouldTransparentCursor) { + return; + } + InteractionManager.runAfterInteractions(() => { + setShouldTransparentCursor(false); + }); + }, [shouldTransparentCursor]); + const clear = useCallback(() => { if (!textInput.current) { return; @@ -347,11 +362,12 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo]} + style={[inputStyleMemo, shouldTransparentCursor ? {caretColor: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} + inputMode={showSoftInputOnFocus ? 'text' : 'none'} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 3df5508f1dd7..6ea3bdb2f824 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -68,6 +68,9 @@ type ComposerProps = Omit & { /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ isGroupPolicyReport?: boolean; + + /** Whether to show the keyboard on focus */ + showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts index e6123733b0e8..134364ddbad6 100644 --- a/src/libs/actions/EmojiPickerAction.ts +++ b/src/libs/actions/EmojiPickerAction.ts @@ -79,8 +79,8 @@ function hideEmojiPicker(isNavigating?: boolean) { /** * Whether Emoji Picker is active for the given id. */ -function isActive(id: string): boolean { - if (!emojiPickerRef.current) { +function isActive(id?: string): boolean { + if (!emojiPickerRef.current || !id) { return false; } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index ed055f2908fb..fe0aa699d900 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -254,7 +254,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]); const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const indexOfLinkedMessage = useMemo( (): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)), @@ -282,6 +281,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`]; const isTopMostReportId = currentReportID === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); + const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); useEffect(() => { if (!report?.reportID || shouldHideReport) { @@ -759,7 +759,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro ) : null} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 7b0d6663facf..b56109b64c40 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -40,7 +40,6 @@ import getPlatform from '@libs/getPlatform'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; @@ -126,27 +125,26 @@ type ComposerWithSuggestionsProps = Partial & { /** The ref to the next modal will open */ isNextModalWillOpenRef: MutableRefObject; - /** Wheater chat is empty */ - isEmptyChat?: boolean; - /** The last report action */ lastReportAction?: OnyxEntry; /** Whether to include chronos */ includeChronos?: boolean; - /** The parent report action ID */ - parentReportActionID?: string; - - /** The parent report ID */ - // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC - parentReportID: string | undefined; - /** Whether report is from group policy */ isGroupPolicyReport: boolean; /** policy ID of the report */ - policyID: string; + policyID?: string; + + /** Whether to show the keyboard on focus */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; + + /** Whether the main composer was hidden */ + didHideComposerInput?: boolean; }; type SwitchToCurrentReportProps = { @@ -187,10 +185,6 @@ const debouncedBroadcastUserIsTyping = lodashDebounce( 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(); - /** * This component holds the value and selection state. * If a component really needs access to these state values it should be put here. @@ -201,11 +195,8 @@ function ComposerWithSuggestions( { // Props: Report reportID, - parentReportID, includeChronos, - isEmptyChat, lastReportAction, - parentReportActionID, isGroupPolicyReport, policyID, @@ -236,6 +227,9 @@ function ComposerWithSuggestions( // For testing children, + showSoftInputOnFocus, + setShowSoftInputOnFocus, + didHideComposerInput, }: ComposerWithSuggestionsProps, ref: ForwardedRef, ) { @@ -257,14 +251,12 @@ function ComposerWithSuggestions( } return draftComment; }); + const commentRef = useRef(value); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [modal] = useOnyx(ONYXKEYS.MODAL); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: EmojiUtils.getPreferredSkinToneIndex}); const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID || '-1'}`, {canEvict: false, initWithStoredValues: false}); const lastTextRef = useRef(value); useEffect(() => { @@ -274,13 +266,7 @@ function ComposerWithSuggestions( const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const parentReportAction = useMemo(() => parentReportActions?.[parentReportActionID ?? '-1'], [parentReportActionID, parentReportActions]); - const shouldAutoFocus = - !modal?.isVisible && - Modal.areAllModalsHidden() && - isFocused && - (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportUtils.isTaskReport(report))) && - shouldShowComposeInput; + const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused && !didHideComposerInput; const valueRef = useRef(value); valueRef.current = value; @@ -643,7 +629,15 @@ function ComposerWithSuggestions( // 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))) { + if ( + !( + (willBlurTextInputOnTapOutside || (shouldAutoFocus && canFocusInputOnScreenFocus())) && + !isNextModalWillOpenRef.current && + !modal?.isVisible && + isFocused && + (!!prevIsModalVisible || !prevIsFocused) + ) + ) { return; } @@ -775,6 +769,19 @@ function ComposerWithSuggestions( onScroll={hideSuggestionMenu} shouldContainScroll={Browser.isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} + showSoftInputOnFocus={showSoftInputOnFocus} + onTouchStart={() => { + if (showSoftInputOnFocus) { + return; + } + if (Browser.isMobileSafari()) { + setTimeout(() => { + setShowSoftInputOnFocus(true); + }, CONST.ANIMATED_TRANSITION); + return; + } + setShowSoftInputOnFocus(true); + }} /> diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 893d2b3060d9..3659a638fca9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -67,7 +67,7 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; -type ReportActionComposeProps = Pick & { +type ReportActionComposeProps = Pick & { /** A method to call when the form is submitted */ onSubmit: (newComment: string) => void; @@ -91,6 +91,15 @@ type ReportActionComposeProps = Pick void; + + /** Whether the main composer was hidden */ + didHideComposerInput?: boolean; }; // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will @@ -110,11 +119,13 @@ function ReportActionCompose({ report, reportID, isReportReadyForDisplay = true, - isEmptyChat, lastReportAction, shouldShowEducationalTooltip, + showSoftInputOnFocus, onComposerFocus, onComposerBlur, + setShowSoftInputOnFocus, + didHideComposerInput, }: ReportActionComposeProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -323,7 +334,7 @@ function ReportActionCompose({ // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { - if (!EmojiPickerActions.isActive(report?.reportID ?? '-1')) { + if (!EmojiPickerActions.isActive(report?.reportID)) { return; } EmojiPickerActions.hideEmojiPicker(); @@ -514,12 +525,9 @@ function ReportActionCompose({ isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} - policyID={report?.policyID ?? '-1'} - parentReportID={report?.parentReportID} - parentReportActionID={report?.parentReportActionID} + policyID={report?.policyID} includeChronos={ReportUtils.chatIncludesChronos(report)} isGroupPolicyReport={isGroupPolicyReport} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} @@ -534,7 +542,10 @@ function ReportActionCompose({ onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} onValueChange={onValueChange} + didHideComposerInput={didHideComposerInput} /> { diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 9771e357b15f..2e8d72b5bc6a 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -1,6 +1,6 @@ import {Str} from 'expensify-common'; import lodashIsEqual from 'lodash/isEqual'; -import React, {memo, useCallback} from 'react'; +import React, {memo, useCallback, useEffect, useState} from 'react'; import {Keyboard, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -48,9 +48,6 @@ type ReportFooterProps = { /** Whether to show educational tooltip in workspace chat for first-time user */ workspaceTooltip: OnyxEntry; - /** Whether the chat is empty */ - isEmptyChat?: boolean; - /** The pending action when we are adding a chat */ pendingAction?: PendingAction; @@ -65,6 +62,12 @@ type ReportFooterProps = { /** A method to call when the input is blur */ onComposerBlur: () => void; + + /** Whether to show the keyboard on focus */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; }; function ReportFooter({ @@ -73,12 +76,13 @@ function ReportFooter({ report = {reportID: '-1'}, reportMetadata, policy, - isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, + showSoftInputOnFocus, workspaceTooltip, onComposerBlur, onComposerFocus, + setShowSoftInputOnFocus, }: ReportFooterProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -103,7 +107,7 @@ function ReportFooter({ } }, }); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; const isArchivedRoom = ReportUtils.isArchivedRoom(report, reportNameValuePairs); @@ -172,6 +176,15 @@ function ReportFooter({ [report.reportID, handleCreateTask], ); + const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); + + useEffect(() => { + if (didHideComposerInput || shouldShowComposeInput) { + return; + } + setDidHideComposerInput(true); + }, [shouldShowComposeInput, didHideComposerInput]); + return ( <> {!!shouldHideComposer && ( @@ -213,12 +226,14 @@ function ReportFooter({ onComposerBlur={onComposerBlur} reportID={report.reportID} report={report} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} isReportReadyForDisplay={isReportReadyForDisplay} shouldShowEducationalTooltip={didScreenTransitionEnd && shouldShowEducationalTooltip} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} + didHideComposerInput={didHideComposerInput} /> @@ -235,9 +250,9 @@ export default memo( lodashIsEqual(prevProps.report, nextProps.report) && prevProps.pendingAction === nextProps.pendingAction && prevProps.isComposerFullSize === nextProps.isComposerFullSize && - prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && + prevProps.showSoftInputOnFocus === nextProps.showSoftInputOnFocus && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) && diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 845727c75c97..1827e23ffe4b 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -96,6 +96,8 @@ function ReportActionComposeWrapper() { disabled={false} report={LHNTestUtils.getFakeReport()} isComposerFullSize + showSoftInputOnFocus={false} + setShowSoftInputOnFocus={() => {}} /> );