diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index e542ed56bdd3..ffceccc84c8d 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -1,14 +1,13 @@ 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, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; 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'; @@ -39,7 +38,6 @@ function Composer( selection, value, isGroupPolicyReport = false, - showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -52,11 +50,7 @@ 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) { @@ -116,13 +110,6 @@ function Composer( const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); - useEffect(() => { - if (!showSoftInputOnFocus || !isKeyboardShown) { - return; - } - setContextMenuHidden(false); - }, [showSoftInputOnFocus, isKeyboardShown]); - return ( ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 26eb0f960c61..b5514518666a 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -50,7 +50,6 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, - showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -350,7 +349,6 @@ function Composer( 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 7f54c7486e8d..ef497dd52e47 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -74,9 +74,6 @@ 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 the soft keyboard is open */ - showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 8afeb0cf2307..b45b3bcea4a4 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -238,6 +238,7 @@ 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)), @@ -809,6 +810,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro policy={policy} pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} + isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} workspaceTooltip={workspaceTooltip} /> diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 12b145a78e87..e63bd952b4ab 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; -import type {ForwardedRef, MutableRefObject, RefObject} from 'react'; +import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react'; import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type { LayoutChangeEvent, @@ -14,7 +14,7 @@ import type { import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; @@ -29,6 +29,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -39,6 +40,7 @@ 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'; @@ -63,85 +65,113 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsProps = Partial & { - /** Report ID */ - reportID: string; +type ComposerWithSuggestionsOnyxProps = { + /** The parent report actions for the report */ + parentReportActions: OnyxEntry; - /** Callback to focus composer */ - onFocus: () => void; + /** The modal state */ + modal: OnyxEntry; - /** Callback to blur composer */ - onBlur: (event: NativeSyntheticEvent) => void; + /** The preferred skin tone of the user */ + preferredSkinTone: number; - /** Callback when layout of composer changes */ - onLayout?: (event: LayoutChangeEvent) => void; + /** Whether the input is focused */ + editFocused: OnyxEntry; +}; - /** Callback to update the value of the composer */ - onValueChange: (value: string) => void; +type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & + Partial & { + /** Report ID */ + reportID: string; - /** Callback when the composer got cleared on the UI thread */ - onCleared?: (text: string) => void; + /** Callback to focus composer */ + onFocus: () => void; - /** Whether the composer is full size */ - isComposerFullSize: boolean; + /** Callback to blur composer */ + onBlur: (event: NativeSyntheticEvent) => void; - /** Whether the menu is visible */ - isMenuVisible: boolean; + /** Callback when layout of composer changes */ + onLayout?: (event: LayoutChangeEvent) => void; - /** The placeholder for the input */ - inputPlaceholder: string; + /** Callback to update the value of the composer */ + onValueChange: (value: string) => void; - /** Function to display a file in a modal */ - displayFileInModal: (file: FileObject) => void; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; - /** Whether the user is blocked from concierge */ - isBlockedFromConcierge: boolean; + /** Whether the composer is full size */ + isComposerFullSize: boolean; - /** Whether the input is disabled */ - disabled: boolean; + /** Whether the menu is visible */ + isMenuVisible: boolean; - /** Whether the full composer is available */ - isFullComposerAvailable: boolean; + /** The placeholder for the input */ + inputPlaceholder: string; - /** Function to set whether the full composer is available */ - setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; + /** Function to display a file in a modal */ + displayFileInModal: (file: FileObject) => void; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; + /** Whether the user is blocked from concierge */ + isBlockedFromConcierge: boolean; - /** Function to handle sending a message */ - handleSendMessage: () => void; + /** Whether the input is disabled */ + disabled: boolean; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; + /** Whether the full composer is available */ + isFullComposerAvailable: boolean; - /** Function to measure the parent container */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + /** Function to set whether the full composer is available */ + setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; + /** Function to set whether the comment is empty */ + setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; + /** Function to handle sending a message */ + handleSendMessage: () => void; - /** The ref to the suggestions */ - suggestionsRef: React.RefObject; + /** Whether the compose input should show */ + shouldShowComposeInput: OnyxEntry; - /** The ref to the next modal will open */ - isNextModalWillOpenRef: MutableRefObject; + /** Function to measure the parent container */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** The last report action */ - lastReportAction?: OnyxEntry; + /** Whether the scroll is likely to trigger a layout */ + isScrollLikelyLayoutTriggered: RefObject; - /** Whether to include chronos */ - includeChronos?: boolean; + /** Function to raise the scroll is likely layout triggered */ + raiseIsScrollLikelyLayoutTriggered: () => void; - /** Whether report is from group policy */ - isGroupPolicyReport: boolean; + /** The ref to the suggestions */ + suggestionsRef: React.RefObject; - /** policy ID of the report */ - policyID: string; -}; + /** The ref to the next modal will open */ + isNextModalWillOpenRef: MutableRefObject; + + /** Whether the edit is focused */ + editFocused: boolean; + + /** 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; + }; type SwitchToCurrentReportProps = { preexistingReportID: string; @@ -181,6 +211,10 @@ 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. @@ -189,10 +223,17 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); */ function ComposerWithSuggestions( { + // Onyx + modal, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + parentReportActions, + // Props: Report reportID, includeChronos, + isEmptyChat, lastReportAction, + parentReportActionID, isGroupPolicyReport, policyID, @@ -222,6 +263,7 @@ function ComposerWithSuggestions( // Refs suggestionsRef, isNextModalWillOpenRef, + editFocused, // For testing children, @@ -246,15 +288,6 @@ function ComposerWithSuggestions( } return draftComment; }); - - const [modal] = useOnyx(ONYXKEYS.MODAL); - const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, { - selector: EmojiUtils.getPreferredSkinToneIndex, - initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, - }); - - const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); - const commentRef = useRef(value); const lastTextRef = useRef(value); @@ -265,7 +298,13 @@ function ComposerWithSuggestions( const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused; + const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1']; + const shouldAutoFocus = + !modal?.isVisible && + Modal.areAllModalsHidden() && + isFocused && + (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && + shouldShowComposeInput; const valueRef = useRef(value); valueRef.current = value; @@ -274,8 +313,6 @@ function ComposerWithSuggestions( const [composerHeight, setComposerHeight] = useState(0); - const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); - const textInputRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); @@ -763,19 +800,6 @@ 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); - }} /> @@ -813,6 +837,22 @@ ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions); -export default memo(ComposerWithSuggestionsWithRef); +export default withOnyx, ComposerWithSuggestionsOnyxProps>({ + modal: { + key: ONYXKEYS.MODAL, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, + editFocused: { + key: ONYXKEYS.INPUT_FOCUSED, + }, + parentReportActions: { + key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + canEvict: false, + initWithStoredValues: false, + }, +})(memo(ComposerWithSuggestionsWithRef)); export type {ComposerWithSuggestionsProps, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 1f2c7b7798b5..762021163833 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -28,6 +28,7 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {getDraftComment} from '@libs/DraftCommentUtils'; @@ -63,7 +64,7 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; -type ReportActionComposeProps = Pick & { +type ReportActionComposeProps = Pick & { /** A method to call when the form is submitted */ onSubmit: (newComment: string) => void; @@ -89,6 +90,10 @@ type ReportActionComposeProps = Pick { const initialModalState = getModalState(); - return shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [shouldHideEducationalTooltip, setShouldHideEducationalTooltip] = useState(false); @@ -460,8 +466,11 @@ function ReportActionCompose({ raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} policyID={report?.policyID ?? '-1'} + parentReportID={report?.parentReportID} + parentReportActionID={report?.parentReportActionID} includeChronos={ReportUtils.chatIncludesChronos(report)} isGroupPolicyReport={isGroupPolicyReport} + isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 90746efa3b68..7c4ec786b633 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -48,6 +48,9 @@ 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; @@ -70,6 +73,7 @@ function ReportFooter({ report = {reportID: '-1'}, reportMetadata, policy, + isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, workspaceTooltip, @@ -220,6 +224,7 @@ function ReportFooter({ onComposerBlur={onComposerBlur} reportID={report.reportID} report={report} + isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} @@ -241,6 +246,7 @@ 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.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow &&