diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 17c05c300f51..a6fd488bcf53 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -902,6 +902,7 @@ "../../src/pages/EnablePayments/shared/IdologyQuestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/EnablePayments/Wallet/PersonalInfo/substeps/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/EnablePayments/Wallet/utils/getSubstepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/home/ForYouSection/ConciergePromptBox.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "no-restricted-imports" 1 "../../src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx index 6c6fb37e4fbe..9a90271fe6f3 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx @@ -9,18 +9,21 @@ import CONST from '@src/CONST'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; -import type {PointerEvent} from 'react-native'; +import type {PointerEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; type TransparentOverlayProps = { onPress: () => void; + + /** Additional styles for the overlay */ + style?: StyleProp; }; type OnPressHandler = PressableProps['onPress']; -function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { +function TransparentOverlay({onPress: onPressProp, style}: TransparentOverlayProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const dropZone = useRef(null); @@ -54,7 +57,7 @@ function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { return ( 7) { - // Calculate the rate of change between reference points - const paddingRate = (referencePaddingMax - referencePaddingMin) / (referenceHeightMax - referenceHeightMin); - - // Interpolate the padding value based on the current screen height - const padding = referencePaddingMin + (height - referenceHeightMin) * paddingRate; - - // Clamp the padding value between -60 and -5 to prevent extreme values - return Math.round(Math.max(-60, Math.min(-5, padding))); - } - - return basePadding; -} - -export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts deleted file mode 100644 index b82e2bc0a66b..000000000000 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts +++ /dev/null @@ -1,6 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { - return 30; -} - -export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts new file mode 100644 index 000000000000..d7c7b47a0021 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts @@ -0,0 +1,5 @@ +function getBottomSuggestionPadding(isMenuAbove: boolean): number { + return isMenuAbove ? 30 : 0; +} + +export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts index a159938366a1..0a543ccbd775 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { +function getBottomSuggestionPadding(isMenuAbove: boolean): number { return 6; } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 44fd2c4d2d62..946d4168bbe6 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -1,27 +1,42 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; +import useKeyboardState from '@hooks/useKeyboardState'; import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensionsForAutoCompleteSuggestions from '@hooks/useWindowDimensionsForAutoCompleteSuggestions'; + +import variables from '@styles/variables'; import {Portal} from '@gorhom/portal'; -import React, {useMemo} from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; import type {AutoCompleteSuggestionsPortalProps} from './types'; import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; +const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; + function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, resetSuggestions = () => {}, - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); - const bottomPadding = getBottomSuggestionPadding(bottom, isInLandscapeMode); - const styles = useMemo(() => StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: bottom + bottomPadding}), [StyleUtils, left, width, bottom, bottomPadding]); + const {height: windowHeight} = useWindowDimensionsForAutoCompleteSuggestions(); + const {keyboardHeight} = useKeyboardState(); + const hostFrameRef = useRef(null); + + const [hostBottomInset, setHostBottomInset] = useState(null); + + const measureHostFrame = () => { + hostFrameRef.current?.measureInWindow((x, y, frameWidth, height) => setHostBottomInset(windowHeight - y - height)); + }; + + useEffect(measureHostFrame, [windowHeight, keyboardHeight]); if (!width) { return null; @@ -29,13 +44,32 @@ function AutoCompleteSuggestionsPortal({ return ( - - - - width={width} - {...props} - /> - + {/* Zero-cost probe filling the portal host, so the host's position in the window is known before positioning the suggestions. */} + + {hostBottomInset !== null && ( + <> + + + + width={width} + {...props} + /> + + + )} ); } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index 53f45af0b59b..84fa67621141 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -2,6 +2,8 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/Bas import useStyleUtils from '@hooks/useStyleUtils'; +import variables from '@styles/variables'; + import type {ReactElement} from 'react'; import React from 'react'; @@ -13,6 +15,8 @@ import type {AutoCompleteSuggestionsPortalProps} from './types'; import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; +const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; + /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, * we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback). @@ -25,9 +29,7 @@ function AutoCompleteSuggestionsPortal({ width = 0, bottom = 0, resetSuggestions = () => {}, - // isInLandscapeMode is only used on native platforms to adjust the bottom padding - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps): ReactElement | null | false { const StyleUtils = useStyleUtils(); @@ -46,8 +48,13 @@ function AutoCompleteSuggestionsPortal({ bodyElement && ReactDOM.createPortal( <> - - {componentToRender} + + + {componentToRender} + , bodyElement, ) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index 62fcc5afd77a..0fc47283933e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -7,7 +7,9 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps { if (isInLandscapeMode) { @@ -53,6 +54,7 @@ const initialContainerState = { width: 0, left: 0, bottom: 0, + isMenuAbove: false, cursorCoordinates: {x: 0, y: 0}, }; @@ -112,7 +114,9 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu : xCoordinatesOfCursor; const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); - let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + // Read in the same tick as the composer measurement above, so both describe the same layout state. + const viewportBottom = getSuggestionsViewportBottom(windowHeight, keyboardHeight); + let bottomValue = viewportBottom - (cursorCoordinates.y - scrollValue + y); const widthValue = shouldUseNarrowLayout ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({ @@ -137,8 +141,10 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu const newLeftOffset = getLeftOffset(x, insets, bigScreenLeftOffset, shouldUseNarrowLayout, width, windowWidth, isInLandscapeMode); // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; - if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode) { - isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const shouldRenderMenuAbove = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const hasRunOutOfSpaceAbove = isSuggestionMenuAboveRef.current && !shouldRenderMenuAbove; + if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode || hasRunOutOfSpaceAbove) { + isSuggestionMenuAboveRef.current = shouldRenderMenuAbove; leftValue.current = newLeftOffset; isInitialRender.current = false; prevLeftValue.current = newLeftOffset; @@ -146,6 +152,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } let measuredHeight = 0; + const isMenuAbove = isSuggestionMenuAboveRef.current && (isEnoughSpaceToRenderMenuAboveForBig || isEnoughSpaceToRenderMenuAboveForSmall); if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); @@ -155,7 +162,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); - bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; + bottomValue = viewportBottom - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } setSuggestionHeight(measuredHeight); @@ -163,6 +170,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu left: leftValue.current, bottom: bottomValue, width: widthValue, + isMenuAbove, cursorCoordinates, }); }); @@ -192,7 +200,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu width={containerState.width} bottom={containerState.bottom} measuredHeightOfSuggestionRows={suggestionHeight} - isInLandscapeMode={isInLandscapeMode} + isMenuAbove={containerState.isMenuAbove} /> ); } diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 41049c349e18..1f2adeba6312 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,6 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; -import type {ComposerRef} from '@components/Composer/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -28,6 +28,8 @@ import getButtonState from '@libs/getButtonState'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; +import useComposerSuggestions from '@pages/inbox/report/ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; @@ -43,6 +45,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {NativeMethods, TextInputKeyPressEvent} from 'react-native'; +import {useIsFocused} from '@react-navigation/core'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useAnimatedRef} from 'react-native-reanimated'; @@ -79,12 +82,13 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {calculatePopoverPosition} = usePopoverPosition(); const [draft] = useOnyx(ONYXKEYS.CONCIERGE_PROMPT_DRAFT); const [value, setValue] = useState(draft ?? ''); + const isScreenFocused = useIsFocused(); const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID: conciergeTargetReportID}); // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. - const [selection, setSelection] = useState({start: value.length, end: value.length}); + const [selection, setSelection] = useState({start: value.length, end: value.length}); const [lastSyncedDraft, setLastSyncedDraft] = useState(draft); const {saveDraft: debouncedSaveDraft, cancelSaveDraft} = useDebouncedSaveDraft( @@ -112,14 +116,29 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const actionButtonRef = useRef(null); const animatedRef = useAnimatedRef(); + const containerRef = useRef(null); + // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. const composerRef = useRef(null); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + const setComposerRef = (element: ComposerRef) => { animatedRef(element); composerRef.current = element; }; + // Shared by typing and by inserting a mention, so a mention takes the same validation and draft-save path as typed text. + const updateComment = (text: string) => { + setValue(text); + debouncedCommentMaxLengthValidation(text); + debouncedSaveDraft(text); + }; + const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); @@ -182,6 +201,11 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo if (canSkipTriggerHotkeys(shouldUseNarrowLayout, isKeyboardShown)) { return; } + + if (suggestionsRef.current?.triggerHotkeyActions(event as unknown as KeyboardEvent)) { + return; + } + const {nativeEvent} = event; const hasShiftModifier = 'shiftKey' in nativeEvent && !!nativeEvent.shiftKey; if (nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || hasShiftModifier) { @@ -191,6 +215,14 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo submit(); }; + useEffect(() => { + if (isScreenFocused) { + return; + } + + hideSuggestionMenu(); + }, [isScreenFocused, hideSuggestionMenu]); + return ( @@ -199,6 +231,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo { - setValue(text); - debouncedCommentMaxLengthValidation(text); - debouncedSaveDraft(text); + raiseIsScrollLayoutTriggered(); + updateComment(text); }} + onScroll={onSaveScrollAndHideSuggestionMenu} selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} shouldCalculateCaretPosition onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} onKeyPress={handleKeyPress} onPasteFile={(files) => { // Concierge isn't reachable yet, so there is nowhere to send the paste. Mirrors the disabled "+" button. @@ -335,6 +371,16 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} + & onEnterKeyPress: () => void; measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - isScrollLikelyLayoutTriggered: RefObject; - raiseIsScrollLikelyLayoutTriggered: () => void; + + /** The ref to the suggestions */ suggestionsRef: React.RefObject; isNextModalWillOpenRef: RefObject; includeChronos?: boolean; @@ -196,8 +183,6 @@ function ComposerWithSuggestions({ disabled, onEnterKeyPress, measureParentContainer = () => {}, - isScrollLikelyLayoutTriggered, - raiseIsScrollLikelyLayoutTriggered, onClear: onClearProp = () => {}, onLayout, @@ -223,9 +208,6 @@ function ComposerWithSuggestions({ const isFocused = useIsFocused(); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -290,6 +272,13 @@ function ComposerWithSuggestions({ const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: initialText.length, end: initialText.length}); + const {measureParentContainerAndReportCursor, onSaveScrollAndHideSuggestionMenu, resetScrollPosition, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer, + suggestionsRef, + }); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(initialText); @@ -465,7 +454,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue: string, shouldDebounceSaveComment?: boolean) => { - raiseIsScrollLikelyLayoutTriggered(); + raiseIsScrollLayoutTriggered(); // previous text before change const prevText = lastTextRef.current; @@ -551,7 +540,7 @@ function ComposerWithSuggestions({ } }, [ - raiseIsScrollLikelyLayoutTriggered, + raiseIsScrollLayoutTriggered, selection.start, selection.end, findNewlyAddedChars, @@ -723,17 +712,6 @@ function ComposerWithSuggestions({ [setCurrentEditMessageSelection, suggestionsRef, currentEditMessageSelection, selection], ); - const hideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, - [suggestionsRef, isScrollLikelyLayoutTriggered], - ); - const setShouldBlockSuggestionCalcToFalse = useCallback(() => { if (!suggestionsRef.current) { return false; @@ -949,50 +927,12 @@ function ComposerWithSuggestions({ const onClear = useCallback( (textOnClear: string) => { - mobileInputScrollPosition.current = 0; + resetScrollPosition(); // Note: use the value when the clear happened, not the current value which might have changed already onClearProp(textOnClear); updateComment('', true); }, - [onClearProp, updateComment], - ); - - useEffect(() => { - // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); - }, [tag]); - - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], - ); - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureParentContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }, - [measureParentContainer, cursorPositionValue, selection], + [onClearProp, updateComment, resetScrollPosition], ); const isTouchEndedRef = useRef(false); @@ -1091,7 +1031,7 @@ function ComposerWithSuggestions({ testID={CONST.COMPOSER.NATIVE_ID} shouldCalculateCaretPosition onLayout={onLayout} - onScroll={hideSuggestionMenu} + onScroll={onSaveScrollAndHideSuggestionMenu} shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} forwardedFSClass={forwardedFSClass} diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts new file mode 100644 index 000000000000..21f768e95792 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -0,0 +1,114 @@ +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; + +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; + +// eslint-disable-next-line no-restricted-imports +import findNodeHandle from '@src/utils/findNodeHandle'; + +import type {RefObject} from 'react'; +import type {MeasureInWindowOnSuccessCallback, TextInputScrollEvent} from 'react-native'; + +import {useEffect, useRef} from 'react'; +import {useFocusedInputHandler} from 'react-native-keyboard-controller'; +import {useSharedValue} from 'react-native-reanimated'; + +import type {SuggestionsRef} from './ComposerContext'; + +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; + +type UseComposerSuggestionsParams = { + /** Ref to the composer input the suggestions are attached to */ + composerRef: RefObject; + + /** The current selection of the composer, used as the cursor position on web */ + selection: TextSelection; + + /** Measures the container the suggestion menu is positioned against */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + + /** Suggestions ref owned by a parent. When omitted, the hook owns the ref itself */ + suggestionsRef?: RefObject; +}; + +/** + * Wires up everything a composer needs to render + */ +function useComposerSuggestions({composerRef, selection, measureParentContainer, suggestionsRef: suggestionsRefProp}: UseComposerSuggestionsParams) { + const ownSuggestionsRef = useRef(null); + const suggestionsRef = suggestionsRefProp ?? ownSuggestionsRef; + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const mobileInputScrollPosition = useRef(0); + const cursorPositionValue = useSharedValue({x: 0, y: 0}); + const tag = useSharedValue(-1); + + useEffect(() => { + // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. + tag.set(findNodeHandle(composerRef.current) ?? -1); + }, [tag, composerRef]); + + useFocusedInputHandler( + { + onSelectionChange: (event) => { + 'worklet'; + + if (event.target === tag.get()) { + cursorPositionValue.set({ + x: event.selection.end.x, + y: event.selection.end.y, + }); + } + }, + }, + [], + ); + + const measureParentContainerAndReportCursor = (callback: MeasureParentContainerAndCursorCallback) => { + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); + const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); + measureParentContainer((x, y, width, height) => { + callback({ + x, + y, + width, + height, + scrollValue, + cursorCoordinates: {x: xPosition, y: yPosition}, + }); + }); + }; + + const hideSuggestionMenu = () => { + suggestionsRef.current?.updateShouldShowSuggestionMenuToFalse(false); + }; + + const onSaveScrollAndHideSuggestionMenu = (event: TextInputScrollEvent) => { + // The offset is recorded even for a layout-triggered scroll, so the menu stays anchored to the real caret position. + mobileInputScrollPosition.current = event?.nativeEvent?.contentOffset?.y ?? 0; + + if (isScrollLayoutTriggered.current) { + return; + } + + hideSuggestionMenu(); + }; + + /** Forgets the tracked scroll offset, e.g. when the composer input is cleared and scrolled back to the top */ + const resetScrollPosition = () => { + mobileInputScrollPosition.current = 0; + }; + + return { + suggestionsRef, + measureParentContainerAndReportCursor, + hideSuggestionMenu, + onSaveScrollAndHideSuggestionMenu, + resetScrollPosition, + raiseIsScrollLayoutTriggered, + }; +} + +export default useComposerSuggestions; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index c7bc3f0953af..90634e30cfd3 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,13 +1,11 @@ import type {Emoji} from '@assets/emojis/types'; -import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -37,24 +35,17 @@ import {isBlockedFromConcierge} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -// eslint-disable-next-line no-restricted-imports -import findNodeHandle from '@src/utils/findNodeHandle'; -import type {MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {useFocusedInputHandler} from 'react-native-keyboard-controller'; -import {useSharedValue} from 'react-native-reanimated'; - -import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; -import getCursorPosition from './ReportActionCompose/getCursorPosition'; -import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import SubmitDraftButton from './ReportActionCompose/SubmitDraftButton'; import Suggestions from './ReportActionCompose/Suggestions'; +import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; @@ -103,10 +94,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const suggestionsRef = useRef(null); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); @@ -142,8 +129,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy isEditing: true, }); - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const [modal = DEFAULT_MODAL_VALUE] = useOnyx(ONYXKEYS.MODAL); const [onyxInputFocused = false] = useOnyx(ONYXKEYS.INPUT_FOCUSED); @@ -151,6 +136,12 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + // Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. const {saveDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); @@ -283,24 +274,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy updateDraft(insertText(draft, selection, `${emoji} `)); }; - const hideSuggestionMenu = useCallback(() => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, [suggestionsRef]); - const onSaveScrollAndHideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - if (isScrollLayoutTriggered.current) { - return; - } - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - - hideSuggestionMenu(); - }, - [isScrollLayoutTriggered, hideSuggestionMenu], - ); - /** * Key event handlers that short cut to saving/canceling. * @@ -331,70 +304,13 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy deleteDraft(); } }, - [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft], - ); - - const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, []); - - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const performMeasurement = () => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }; - - performMeasurement(); - }, - [cursorPositionValue, measureContainer, selection], - ); - - useEffect(() => { - // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); - }, [tag]); - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], + [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft, suggestionsRef], ); useEffect(() => { debouncedCommentMaxLengthValidation(draft); }, [draft, debouncedCommentMaxLengthValidation]); - useEffect(() => { - if (isFocused) { - return; - } - - hideSuggestionMenu(); - }, [isFocused, hideSuggestionMenu]); - return ( <> setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} selection={selection} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} isGroupPolicyReport={isGroupPolicyReport} diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 92711a20f215..415630cad4e3 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -285,6 +285,7 @@ export default { reportActionItemImagesMoreCornerTriangleWidth: 40, popoverZIndex: 10000, modalBaseZIndex: 9999, + autoCompleteSuggestionsZIndex: 9999, sidePanelZIndex: 9998, modalRightDockedZIndex: 9997, modalLowestZIndex: 9996, diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index 2197eb0f4361..353fec45f42f 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -1,5 +1,9 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import type {Emoji} from '@assets/emojis/types'; + +import type Mention from '@components/MentionSuggestions/types'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -14,11 +18,16 @@ import {close} from '@userActions/Modal'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import type * as NavigationCore from '@react-navigation/core'; import type {ViewProps} from 'react-native'; +import {useIsFocused} from '@react-navigation/core'; import React, {useState} from 'react'; +import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -28,6 +37,10 @@ const SHORT_PLACEHOLDER = 'homePage.conciergePrompt.inputPlaceholderMobile'; const ADD_ATTACHMENT = 'reportActionCompose.addAttachment'; const PLUS_BUTTON = 'accessibilityHints.openActionsMenu'; const SEND_BUTTON = 'common.send'; +const TEAMMATE = {accountID: 2, login: 'alex@expensify.com', displayName: 'Alex Adams'}; +const OTHER_TEAMMATE = {accountID: 3, login: 'blake@expensify.com', displayName: 'Blake Brown'}; + +const SCROLL_LAYOUT_TRIGGER_RESET_TIME = 500; const mockAskConcierge = jest.fn(); const mockAskConciergeWithAttachment = jest.fn(); @@ -36,8 +49,53 @@ const mockOpenPicker = jest.fn(); const pickerHandler: {onConfirm?: (files: FileObject | FileObject[]) => void} = {}; +type MentionSuggestionsProps = { + mentions: Mention[]; + prefix: string; + onSelect: (index: number) => void; +}; + +type EmojiSuggestionsProps = { + emojis: Emoji[]; + prefix: string; + onSelect: (index: number) => void; +}; + +const mockMentionSuggestionsSpy = jest.fn(); +const mockEmojiSuggestionsSpy = jest.fn(); + +let mockPersonalDetails: PersonalDetailsList = {}; + jest.mock('@components/Search/SearchRouter/useAskConcierge', () => jest.fn()); +jest.mock('@components/MentionSuggestions', () => { + const ReactLib = jest.requireActual('react'); + const module = { + default: (props: MentionSuggestionsProps) => { + mockMentionSuggestionsSpy(props); + return ReactLib.createElement('mock-mention-suggestions', {...props, testID: 'mention-suggestions'}); + }, + }; + Object.defineProperty(module, '__esModule', {value: true}); + return module; +}); + +jest.mock('@components/EmojiSuggestions', () => { + const ReactLib = jest.requireActual('react'); + const module = { + default: (props: EmojiSuggestionsProps) => { + mockEmojiSuggestionsSpy(props); + return ReactLib.createElement('mock-emoji-suggestions', {...props, testID: 'emoji-suggestions'}); + }, + }; + Object.defineProperty(module, '__esModule', {value: true}); + return module; +}); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: jest.fn(), +})); + jest.mock('@pages/home/ForYouSection/useConciergeAttachmentPicker', () => ({ __esModule: true, default: (_reportID: string | undefined, onConfirm: (files: FileObject | FileObject[]) => void) => { @@ -77,9 +135,16 @@ jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: (key: string) => key, getLocalDateFromDatetime: () => new Date('2026-08-24T09:00:00'), + formatPhoneNumber: (value: string) => value, + localeCompare: (first: string, second: string) => first.localeCompare(second), })), ); +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual('@react-navigation/core'), + useIsFocused: jest.fn(() => true), +})); + jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useKeyboardState', () => jest.fn()); @@ -100,7 +165,9 @@ jest.mock('@userActions/Session', () => ({ signOutAndRedirectToSignIn: jest.fn(), })); +const mockUsePersonalDetails = jest.mocked(usePersonalDetails); const mockUseAskConcierge = jest.mocked(useAskConcierge); +const mockUseIsFocused = jest.mocked(useIsFocused); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); const mockUseKeyboardState = jest.mocked(useKeyboardState); const mockIsSafari = jest.mocked(isSafari); @@ -168,19 +235,74 @@ function pasteImage() { fireEvent(getInput(), 'paste', {nativeEvent: {items: [{type: 'image/png', data: 'file:///image.png'}]}}); } +function typeText(text: string) { + fireEvent(getInput(), 'focus'); + fireEvent.changeText(getInput(), text); + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: text.length, end: text.length}}}); + + act(() => jest.advanceTimersByTime(CONST.TIMING.SUGGESTION_DEBOUNCE_TIME)); +} + +function scrollInput(offsetY = 40) { + fireEvent(getInput(), 'scroll', {nativeEvent: {contentOffset: {y: offsetY}}}); +} + +function settleLayoutTriggeredScroll() { + act(() => jest.advanceTimersByTime(SCROLL_LAYOUT_TRIGGER_RESET_TIME)); +} + +/** + * On web the composer's key event carries `key`/`shiftKey` both at the top level (where the suggestion layer reads them) + * and on `nativeEvent` (where the submit handler reads them), so tests that involve suggestions must set both. + */ +function pressKeyWithSuggestions(key: string, options?: {shiftKey?: boolean}) { + const shiftKey = options?.shiftKey ?? false; + fireEvent(getInput(), 'keyPress', { + key, + shiftKey, + preventDefault: jest.fn(), + nativeEvent: {key, shiftKey}, + }); +} + +function getLastMentionSuggestionsProps(): MentionSuggestionsProps { + const props = mockMentionSuggestionsSpy.mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected mention suggestions to have rendered'); + } + return props; +} + +function getLastEmojiSuggestionsProps(): EmojiSuggestionsProps { + const props = mockEmojiSuggestionsSpy.mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected emoji suggestions to have rendered'); + } + return props; +} + function measureLongPlaceholder(height: number) { fireEvent(screen.getByText(LONG_PLACEHOLDER), 'layout', {nativeEvent: {layout: {height}}}); } describe('ConciergePromptBox', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + await act(async () => { + await Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, null); + }); pickerHandler.onConfirm = undefined; + mockPersonalDetails = { + [TEAMMATE.accountID]: TEAMMATE, + [OTHER_TEAMMATE.accountID]: OTHER_TEAMMATE, + }; + mockUsePersonalDetails.mockImplementation(() => mockPersonalDetails); setAskConcierge(); setResponsiveLayout(false); setKeyboardShown(false); mockIsSafari.mockReturnValue(false); mockIsAnonymousUser.mockReturnValue(false); + mockUseIsFocused.mockReturnValue(true); }); describe('sending a message', () => { @@ -224,6 +346,227 @@ describe('ConciergePromptBox', () => { }); }); + describe('mentions', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suggests users matching what was typed after the @', () => { + // Given a rendered prompt box + render(); + + // When a mention prefix is typed + typeText('Show me @ale'); + + // Then only the matching user is offered + const {mentions, prefix} = getLastMentionSuggestionsProps(); + expect(prefix).toBe('ale'); + expect(mentions.map((mention) => mention.handle)).toEqual(['alex@expensify.com']); + }); + + it('does not suggest anything without an @', () => { + // Given a rendered prompt box + render(); + + // When plain text is typed + typeText('Show me my expenses'); + + // Then no picker is rendered + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('inserts the selected mention into the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When the suggestion is selected + act(() => getLastMentionSuggestionsProps().onSelect(0)); + + // Then the typed prefix is replaced by the full mention and the list closes + expect(getInput()).toHaveDisplayValue('Show me @alex@expensify.com '); + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('selects the mention on Enter instead of sending the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the mention is inserted and the half-typed prompt is not sent + expect(getInput()).toHaveDisplayValue('Show me @alex@expensify.com '); + expect(mockAskConcierge).not.toHaveBeenCalled(); + }); + + it('still sends on Enter once no suggestions are showing', () => { + // Given a prompt with no open suggestion list + render(); + typeText('Show me my expenses'); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the prompt is sent as usual + expect(mockAskConcierge).toHaveBeenCalledWith('Show me my expenses'); + }); + + it('dismisses the suggestions on Escape', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When Escape is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey); + + // Then the list closes and the typed text is left alone + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + expect(getInput()).toHaveDisplayValue('Show me @ale'); + }); + + it('hides the suggestions when the input loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the input is blurred + fireEvent(getInput(), 'blur'); + + // Then the list closes + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('hides the suggestions when the screen loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the screen is navigated away from + mockUseIsFocused.mockReturnValue(false); + screen.rerender(); + + // Then the list closes + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + }); + + describe('emojis', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suggests emojis matching what was typed after the colon', () => { + // Given a rendered prompt box + render(); + + // When an emoji code is typed + typeText('Nice work :smile'); + + // Then the matching emojis are offered + const {emojis, prefix} = getLastEmojiSuggestionsProps(); + expect(prefix).toBe('smile'); + expect(emojis.map((emoji) => emoji.name)).toContain('smile'); + }); + + it('does not suggest anything without a colon', () => { + // Given a rendered prompt box + render(); + + // When plain text is typed + typeText('Nice work'); + + // Then no picker is rendered + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + + it('inserts the selected emoji into the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + const {emojis} = getLastEmojiSuggestionsProps(); + + // When the first suggestion is selected + act(() => getLastEmojiSuggestionsProps().onSelect(0)); + + // Then the typed code is replaced by the emoji itself and the list closes + expect(getInput()).toHaveDisplayValue(`Nice work ${emojis.at(0)?.code} `); + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + + it('selects the emoji on Enter instead of sending the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + const {emojis} = getLastEmojiSuggestionsProps(); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the emoji is inserted and the half-typed prompt is not sent + expect(getInput()).toHaveDisplayValue(`Nice work ${emojis.at(0)?.code} `); + expect(mockAskConcierge).not.toHaveBeenCalled(); + }); + + it('dismisses the suggestions on Escape', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + + // When Escape is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey); + + // Then the list closes and the typed text is left alone + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + expect(getInput()).toHaveDisplayValue('Nice work :smile'); + }); + + it('hides the suggestions when the input loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + expect(screen.getByTestId('emoji-suggestions')).toBeOnTheScreen(); + + // When the input is blurred + fireEvent(getInput(), 'blur'); + + // Then the list closes + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + }); + + describe('scrolling the input', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('hides the suggestions when the input is scrolled', () => { + // Given a visible suggestion list that is no longer settling after the text change + render(); + typeText('Show me @ale'); + settleLayoutTriggeredScroll(); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the input is scrolled + scrollInput(); + + // Then the list closes, so it never floats away from the text it belongs to + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('keeps the suggestions open when the text change itself moved the input', () => { + // Given a visible suggestion list right after typing, where the input reflows and fires onScroll on its own + render(); + typeText('Show me @ale'); + + // When that layout-triggered scroll arrives + scrollInput(); + + // Then the list stays open, since the user never scrolled + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + }); + }); + describe('focus', () => { it('claims taps that land on the box itself', () => { // Given a rendered prompt box diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 34540a8619a5..85e63d22442b 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -10,6 +10,7 @@ import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -68,6 +69,16 @@ jest.mock('@components/Navigation/TopBar', () => { } return MockTopBar; }); + +jest.mock('@gorhom/portal', () => { + const ReactModule = require('react'); + const {View: RNView} = require('react-native'); + return { + ...jest.requireActual('@gorhom/portal'), + PortalHost: ({name}: {name: string}) => ReactModule.createElement(RNView, {testID: `portal-host-${name}`}), + }; +}); + jest.mock('@components/ReceiptScanDropZone', () => { function MockReceiptScanDropZone() { return null; @@ -127,9 +138,11 @@ function setIsOffline(isOffline: boolean) { const renderHomePage = () => render( - - - , + + + + + , ); function renderedSectionOrder() { @@ -149,6 +162,23 @@ describe('HomePage', () => { await waitForBatchedUpdates(); }); + describe('suggestion portal host', () => { + it.each([ + ['narrow', setNarrowLayout], + ['wide', setWideLayout], + ])('renders the suggestions host on %s layout', async (_label, setLayout) => { + // Given a layout + setLayout(); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the suggestions portal host is on the page + expect(screen.getByTestId('portal-host-suggestions')).toBeOnTheScreen(); + }); + }); + // Offline, OpenApp/OpenReport never send, so IS_LOADING_APP / IS_LOADING_REPORT_DATA can stay true forever and the // top loading bar used to hang on Home. The bar must stay hidden while offline no matter what the flags say. describe('loading bar visibility', () => {