diff --git a/ios/Podfile.lock b/ios/Podfile.lock index aca46d6b18ed..3c9b7029f61f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1303,6 +1303,25 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-keyboard-controller (1.12.2): + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-launch-arguments (4.0.2): - React - react-native-netinfo (11.2.1): @@ -2137,6 +2156,7 @@ DEPENDENCIES: - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) + - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) @@ -2335,6 +2355,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-key-command: :path: "../node_modules/react-native-key-command" + react-native-keyboard-controller: + :path: "../node_modules/react-native-keyboard-controller" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" react-native-netinfo: @@ -2541,6 +2563,7 @@ SPEC CHECKSUMS: react-native-geolocation: f9e92eb774cb30ac1e099f34b3a94f03b4db7eb3 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 + react-native-keyboard-controller: 47c01b0741ae5fc84e53cf282e61cfa5c2edb19b react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: ccd4bbf9fc7effaf8f91f8dae43389844d9ef9fa @@ -2606,7 +2629,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 1394a316c7add37e619c48d7aa40b38b954bf055 - Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 + Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 PODFILE CHECKSUM: 66a5c97ae1059e4da1993a4ad95abe5d819f555b diff --git a/jest/setup.ts b/jest/setup.ts index 174e59a7e493..543439b76d38 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -53,3 +53,6 @@ jest.mock('react-native-sound', () => { jest.mock('react-native-share', () => ({ default: jest.fn(), })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); diff --git a/package-lock.json b/package-lock.json index 79f4e6053de1..c63092a19246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31425,6 +31426,16 @@ "version": "5.0.1", "license": "MIT" }, + "node_modules/react-native-keyboard-controller": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.2.tgz", + "integrity": "sha512-10Sy0+neSHGJxOmOxrUJR8TQznnrQ+jTFQtM1PP6YnblNQeAw1eOa+lO6YLGenRr5WuNSMZbks/3Ay0e2yMKLw==", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=2.3.0" + } + }, "node_modules/react-native-launch-arguments": { "version": "4.0.2", "license": "MIT", diff --git a/package.json b/package.json index fe69f4594efd..2e9f1cf7b58b 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.12.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", diff --git a/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch b/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch new file mode 100644 index 000000000000..1a5b4c40477b --- /dev/null +++ b/patches/react-native+0.73.4+016+iOS-textinput-onscroll-event.patch @@ -0,0 +1,70 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp +index 88ae3f3..497569a 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputEventEmitter.cpp +@@ -36,6 +36,54 @@ static jsi::Value textInputMetricsPayload( + return payload; + }; + ++static jsi::Value textInputMetricsScrollPayload( ++ jsi::Runtime& runtime, ++ const TextInputMetrics& textInputMetrics) { ++ auto payload = jsi::Object(runtime); ++ ++ { ++ auto contentOffset = jsi::Object(runtime); ++ contentOffset.setProperty(runtime, "x", textInputMetrics.contentOffset.x); ++ contentOffset.setProperty(runtime, "y", textInputMetrics.contentOffset.y); ++ payload.setProperty(runtime, "contentOffset", contentOffset); ++ } ++ ++ { ++ auto contentInset = jsi::Object(runtime); ++ contentInset.setProperty(runtime, "top", textInputMetrics.contentInset.top); ++ contentInset.setProperty( ++ runtime, "left", textInputMetrics.contentInset.left); ++ contentInset.setProperty( ++ runtime, "bottom", textInputMetrics.contentInset.bottom); ++ contentInset.setProperty( ++ runtime, "right", textInputMetrics.contentInset.right); ++ payload.setProperty(runtime, "contentInset", contentInset); ++ } ++ ++ { ++ auto contentSize = jsi::Object(runtime); ++ contentSize.setProperty( ++ runtime, "width", textInputMetrics.contentSize.width); ++ contentSize.setProperty( ++ runtime, "height", textInputMetrics.contentSize.height); ++ payload.setProperty(runtime, "contentSize", contentSize); ++ } ++ ++ { ++ auto layoutMeasurement = jsi::Object(runtime); ++ layoutMeasurement.setProperty( ++ runtime, "width", textInputMetrics.layoutMeasurement.width); ++ layoutMeasurement.setProperty( ++ runtime, "height", textInputMetrics.layoutMeasurement.height); ++ payload.setProperty(runtime, "layoutMeasurement", layoutMeasurement); ++ } ++ ++ payload.setProperty(runtime, "zoomScale", textInputMetrics.zoomScale ?: 1); ++ ++ ++ return payload; ++ }; ++ + static jsi::Value textInputMetricsContentSizePayload( + jsi::Runtime& runtime, + const TextInputMetrics& textInputMetrics) { +@@ -140,7 +188,9 @@ void TextInputEventEmitter::onKeyPressSync( + + void TextInputEventEmitter::onScroll( + const TextInputMetrics& textInputMetrics) const { +- dispatchTextInputEvent("scroll", textInputMetrics); ++ dispatchEvent("scroll", [textInputMetrics](jsi::Runtime& runtime) { ++ return textInputMetricsScrollPayload(runtime, textInputMetrics); ++ }); + } + + void TextInputEventEmitter::dispatchTextInputEvent( diff --git a/patches/react-native-keyboard-controller+1.12.2.patch.patch b/patches/react-native-keyboard-controller+1.12.2.patch.patch new file mode 100644 index 000000000000..3c8034354481 --- /dev/null +++ b/patches/react-native-keyboard-controller+1.12.2.patch.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +index 83884d8..5d9e989 100644 +--- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt ++++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +@@ -99,12 +99,12 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R + } + + private fun goToEdgeToEdge(edgeToEdge: Boolean) { +- reactContext.currentActivity?.let { +- WindowCompat.setDecorFitsSystemWindows( +- it.window, +- !edgeToEdge, +- ) +- } ++ // reactContext.currentActivity?.let { ++ // WindowCompat.setDecorFitsSystemWindows( ++ // it.window, ++ // !edgeToEdge, ++ // ) ++ // } + } + + private fun setupKeyboardCallbacks() { +@@ -158,13 +158,13 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R + // region State managers + private fun enable() { + this.goToEdgeToEdge(true) +- this.setupWindowInsets() ++ // this.setupWindowInsets() + this.setupKeyboardCallbacks() + } + + private fun disable() { + this.goToEdgeToEdge(false) +- this.setupWindowInsets() ++ // this.setupWindowInsets() + this.removeKeyboardCallbacks() + } + // endregion \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 6316fa80fba1..64c75e61941e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; @@ -84,6 +85,7 @@ function App({url}: AppProps) { FullScreenContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, + KeyboardProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 56c2e70838ce..dd3e7d1622f6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1236,6 +1236,8 @@ const CONST = { MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', + SUGGESTION_BOX_MAX_SAFE_DISTANCE: 38, + BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts new file mode 100644 index 000000000000..5bb671c5edac --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts @@ -0,0 +1,5 @@ +function getBottomSuggestionPadding(): number { + return 16; +} + +export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts new file mode 100644 index 000000000000..3ad9bbe7b152 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts @@ -0,0 +1,5 @@ +function getBottomSuggestionPadding(): number { + return 0; +} + +export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx new file mode 100644 index 000000000000..9848d77e479e --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -0,0 +1,33 @@ +import {Portal} from '@gorhom/portal'; +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import getBottomSuggestionPadding from './getBottomSuggestionPadding'; +import type {AutoCompleteSuggestionsPortalProps} from './types'; + +function AutoCompleteSuggestionsPortal({left = 0, width = 0, bottom = 0, ...props}: AutoCompleteSuggestionsPortalProps) { + const StyleUtils = useStyleUtils(); + const styles = useMemo(() => StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: bottom + getBottomSuggestionPadding()}), [StyleUtils, left, width, bottom]); + + if (!width) { + return null; + } + + return ( + + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + width={width} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + + + ); +} + +AutoCompleteSuggestionsPortal.displayName = 'AutoCompleteSuggestionsPortal'; + +export default AutoCompleteSuggestionsPortal; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx new file mode 100644 index 000000000000..2d1d533c2859 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type {ReactElement} from 'react'; +import ReactDOM from 'react-dom'; +import {View} from 'react-native'; +import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import getBottomSuggestionPadding from './getBottomSuggestionPadding'; +import type {AutoCompleteSuggestionsPortalProps} from './types'; + +/** + * 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). + * The desired pattern for all platforms is to do nothing on long-press. + * On the native platform, tapping on auto-complete suggestions will not blur the main input. + */ + +function AutoCompleteSuggestionsPortal({left = 0, width = 0, bottom = 0, ...props}: AutoCompleteSuggestionsPortalProps): ReactElement | null | false { + const StyleUtils = useStyleUtils(); + + const bodyElement = document.querySelector('body'); + + const componentToRender = ( + + width={width} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); + + return ( + !!width && + bodyElement && + ReactDOM.createPortal( + {componentToRender}, + bodyElement, + ) + ); +} + +AutoCompleteSuggestionsPortal.displayName = 'AutoCompleteSuggestionsPortal'; + +export default AutoCompleteSuggestionsPortal; +export type {AutoCompleteSuggestionsPortalProps}; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts new file mode 100644 index 000000000000..61fa3e8dcd48 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -0,0 +1,13 @@ +import type {AutoCompleteSuggestionsProps} from '@components/AutoCompleteSuggestions/types'; + +type ExternalProps = Omit, 'measureParentContainerAndReportCursor'>; + +type AutoCompleteSuggestionsPortalProps = ExternalProps & { + left: number; + width: number; + bottom: number; + measuredHeightOfSuggestionRows: number; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {AutoCompleteSuggestionsPortalProps}; diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 4c11f1f0e35c..70d70a8c1844 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -1,49 +1,32 @@ import type {ReactElement} from 'react'; import React, {useCallback, useEffect, useRef} from 'react'; import {FlatList} from 'react-native-gesture-handler'; -import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; -import type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps} from './types'; +import type {AutoCompleteSuggestionsPortalProps} from './AutoCompleteSuggestionsPortal'; +import type {RenderSuggestionMenuItemProps} from './types'; -const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge: boolean): number => { - if (isSuggestionPickerLarge) { - if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { - // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available - return CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - if (numRows > 2) { - // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible - return CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; -}; - -/** - * 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). - * The desired pattern for all platforms is to do nothing on long-press. - * On the native platform, tapping on auto-complete suggestions will not blur the main input. - */ +type ExternalProps = Omit, 'left' | 'bottom'>; function BaseAutoCompleteSuggestions({ - highlightedSuggestionIndex, + highlightedSuggestionIndex = 0, onSelect, accessibilityLabelExtractor, renderSuggestionMenuItem, suggestions, - isSuggestionPickerLarge, keyExtractor, -}: AutoCompleteSuggestionsProps) { + measuredHeightOfSuggestionRows, +}: ExternalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); + const prevRowHeightRef = useRef(measuredHeightOfSuggestionRows); + const fadeInOpacity = useSharedValue(0); const scrollRef = useRef>(null); /** * Render a suggestion menu item component. @@ -56,7 +39,6 @@ function BaseAutoCompleteSuggestions({ onMouseDown={(e) => e.preventDefault()} onPress={() => onSelect(index)} onLongPress={() => {}} - shouldUseHapticsOnLongPress={false} accessibilityLabel={accessibilityLabelExtractor(item, index)} > {renderSuggestionMenuItem(item, index)} @@ -66,26 +48,45 @@ function BaseAutoCompleteSuggestions({ ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); + + const animatedStyles = useAnimatedStyle(() => ({ + opacity: fadeInOpacity.value, + ...StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value), + })); useEffect(() => { - rowHeight.value = withTiming(measureHeightOfSuggestionRows(suggestions.length, isSuggestionPickerLarge), { - duration: 100, - easing: Easing.inOut(Easing.ease), - }); - }, [suggestions.length, isSuggestionPickerLarge, rowHeight]); + if (measuredHeightOfSuggestionRows === prevRowHeightRef.current) { + fadeInOpacity.value = withTiming(1, { + duration: 70, + easing: Easing.inOut(Easing.ease), + }); + rowHeight.value = measuredHeightOfSuggestionRows; + } else { + fadeInOpacity.value = 1; + rowHeight.value = withTiming(measuredHeightOfSuggestionRows, { + duration: 100, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }); + } + + prevRowHeightRef.current = measuredHeightOfSuggestionRows; + }, [suggestions.length, rowHeight, measuredHeightOfSuggestionRows, prevRowHeightRef, fadeInOpacity]); useEffect(() => { if (!scrollRef.current) { return; } - scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true}); + // When using cursor control (moving the cursor with the space bar on the keyboard) on Android, moving the cursor too fast may cause an error. + try { + scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true}); + } catch (e) { + // eslint-disable-next-line no-console + } }, [highlightedSuggestionIndex]); return ( { if (DeviceCapabilities.hasHoverSupport()) { return; diff --git a/src/components/AutoCompleteSuggestions/index.native.tsx b/src/components/AutoCompleteSuggestions/index.native.tsx deleted file mode 100644 index fbfa7d953581..000000000000 --- a/src/components/AutoCompleteSuggestions/index.native.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {Portal} from '@gorhom/portal'; -import React from 'react'; -import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; -import type {AutoCompleteSuggestionsProps} from './types'; - -function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) { - return ( - - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {...props} /> - - ); -} - -AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions'; - -export default AutoCompleteSuggestions; diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index c7f2aaea4d82..8634d6dd0ca0 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -1,38 +1,134 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import {View} from 'react-native'; +import React, {useEffect} from 'react'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; -import type {AutoCompleteSuggestionsProps} from './types'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; +import AutoCompleteSuggestionsPortal from './AutoCompleteSuggestionsPortal'; +import type {AutoCompleteSuggestionsProps, MeasureParentContainerAndCursor} from './types'; -function AutoCompleteSuggestions({measureParentContainer = () => {}, ...props}: AutoCompleteSuggestionsProps) { - const StyleUtils = useStyleUtils(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const [{width, left, bottom}, setContainerState] = React.useState({ +const measureHeightOfSuggestionRows = (numRows: number, canBeBig: boolean): number => { + if (canBeBig) { + if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { + // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available + return CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + if (numRows > 2) { + // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible + return CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; +}; +function isSuggestionRenderedAbove(isEnoughSpaceAboveForBig: boolean, isEnoughSpaceAboveForSmall: boolean): boolean { + return isEnoughSpaceAboveForBig || isEnoughSpaceAboveForSmall; +} + +/** + * 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). + * The desired pattern for all platforms is to do nothing on long-press. + * On the native platform, tapping on auto-complete suggestions will not blur the main input. + */ +function AutoCompleteSuggestions({measureParentContainerAndReportCursor = () => {}, ...props}: AutoCompleteSuggestionsProps) { + const containerRef = React.useRef(null); + const isInitialRender = React.useRef(true); + const isSuggestionAboveRef = React.useRef(false); + const leftValue = React.useRef(0); + const prevLeftValue = React.useRef(0); + const {windowHeight, windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const [suggestionHeight, setSuggestionHeight] = React.useState(0); + const [containerState, setContainerState] = React.useState({ width: 0, left: 0, bottom: 0, }); + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + const {keyboardHeight} = useKeyboardState(); + const {paddingBottom: bottomInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); - React.useEffect(() => { - if (!measureParentContainer) { + useEffect(() => { + const container = containerRef.current; + if (!container) { + return () => {}; + } + container.onpointerdown = (e) => { + if (DeviceCapabilities.hasHoverSupport()) { + return; + } + e.preventDefault(); + }; + return () => (container.onpointerdown = null); + }, []); + + const suggestionsLength = props.suggestions.length; + + useEffect(() => { + if (!measureParentContainerAndReportCursor) { return; } - measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [measureParentContainer, windowHeight, windowWidth]); - const componentToRender = ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - /> - ); + measureParentContainerAndReportCursor(({x, y, width, scrollValue, cursorCoordinates}: MeasureParentContainerAndCursor) => { + const xCoordinatesOfCursor = x + cursorCoordinates.x; + const leftValueForBigScreen = + xCoordinatesOfCursor + CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH > windowWidth + ? windowWidth - CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH + : xCoordinatesOfCursor; + + let bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - (keyboardHeight || bottomInset); + const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; + + const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true); + const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false); + const isEnoughSpaceAboveForBig = windowHeight - bottomValue - contentMaxHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; + const isEnoughSpaceAboveForSmall = windowHeight - bottomValue - contentMinHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; - const bodyElement = document.querySelector('body'); + const newLeftValue = isSmallScreenWidth ? x : leftValueForBigScreen; + // 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 - leftValueForBigScreen) > 150; + if (isInitialRender.current || isAdjustmentNeeded) { + isSuggestionAboveRef.current = isSuggestionRenderedAbove(isEnoughSpaceAboveForBig, isEnoughSpaceAboveForSmall); + leftValue.current = newLeftValue; + isInitialRender.current = false; + prevLeftValue.current = newLeftValue; + } + let measuredHeight = 0; + if (isSuggestionAboveRef.current && isEnoughSpaceAboveForBig) { + // calculation for big suggestion box above the cursor + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); + } else if (isSuggestionAboveRef.current && isEnoughSpaceAboveForSmall) { + // calculation for small suggestion box above the cursor + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false); + } else { + // calculation for big suggestion box below the cursor + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); + bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + setSuggestionHeight(measuredHeight); + setContainerState({ + left: leftValue.current, + bottom: bottomValue, + width: widthValue, + }); + }); + }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset]); + + if (containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) { + return null; + } return ( - !!width && bodyElement && ReactDOM.createPortal({componentToRender}, bodyElement) + ); } diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 61d614dcf2e4..48bb6b713032 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -1,6 +1,15 @@ import type {ReactElement} from 'react'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerAndCursor = { + x: number; + y: number; + width: number; + height: number; + scrollValue: number; + cursorCoordinates: {x: number; y: number}; +}; + +type MeasureParentContainerAndCursorCallback = (props: MeasureParentContainerAndCursor) => void; type RenderSuggestionMenuItemProps = { item: TSuggestion; @@ -31,8 +40,8 @@ type AutoCompleteSuggestionsProps = { /** create accessibility label for each item */ accessibilityLabelExtractor: (item: TSuggestion, index: number) => string; - /** Meaures the parent container's position and dimensions. */ - measureParentContainer?: (callback: MeasureParentContainerCallback) => void; + /** Measures the parent container's position and dimensions. Also add a cursor coordinates */ + measureParentContainerAndReportCursor?: (props: MeasureParentContainerAndCursorCallback) => void; }; -export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; +export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps, MeasureParentContainerAndCursorCallback, MeasureParentContainerAndCursor}; diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 14762b2d4bc1..b309bdea439d 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -86,6 +86,8 @@ function Composer( | { start: number; end?: number; + positionX?: number; + positionY?: number; } | undefined >({ diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 531bcd03f8bf..6df9c96a73bb 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -3,6 +3,12 @@ import type {NativeSyntheticEvent, StyleProp, TextInputProps, TextInputSelection type TextSelection = { start: number; end?: number; + positionX?: number; + positionY?: number; +}; +type CustomSelectionChangeEvent = NativeSyntheticEvent & { + positionX?: number; + positionY?: number; }; type ComposerProps = TextInputProps & { @@ -45,7 +51,7 @@ type ComposerProps = TextInputProps & { autoFocus?: boolean; /** Update selection position on change */ - onSelectionChange?: (event: NativeSyntheticEvent) => void; + onSelectionChange?: (event: CustomSelectionChangeEvent) => void; /** Selection Object */ selection?: TextSelection; @@ -72,4 +78,4 @@ type ComposerProps = TextInputProps & { shouldContainScroll?: boolean; }; -export type {TextSelection, ComposerProps}; +export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx index 1c0306741048..3781507b544c 100644 --- a/src/components/EmojiSuggestions.tsx +++ b/src/components/EmojiSuggestions.tsx @@ -7,10 +7,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as EmojiUtils from '@libs/EmojiUtils'; import getStyledTextArray from '@libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; +import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; import Text from './Text'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; - type EmojiSuggestionsProps = { /** The index of the highlighted emoji */ highlightedEmojiIndex?: number; @@ -33,8 +32,8 @@ type EmojiSuggestionsProps = { /** Stores user's preferred skin tone */ preferredSkinToneIndex: number; - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: (callback: MeasureParentContainerCallback) => void; + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; }; /** @@ -42,7 +41,15 @@ type EmojiSuggestionsProps = { */ const keyExtractor = (item: Emoji, index: number): string => `${item.name}+${index}}`; -function EmojiSuggestions({emojis, onSelect, prefix, isEmojiPickerLarge, preferredSkinToneIndex, highlightedEmojiIndex = 0, measureParentContainer = () => {}}: EmojiSuggestionsProps) { +function EmojiSuggestions({ + emojis, + onSelect, + prefix, + isEmojiPickerLarge, + preferredSkinToneIndex, + highlightedEmojiIndex = 0, + measureParentContainerAndReportCursor = () => {}, +}: EmojiSuggestionsProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); /** @@ -85,7 +92,7 @@ function EmojiSuggestions({emojis, onSelect, prefix, isEmojiPickerLarge, preferr onSelect={onSelect} isSuggestionPickerLarge={isEmojiPickerLarge} accessibilityLabelExtractor={keyExtractor} - measureParentContainer={measureParentContainer} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} /> ); } diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index 877133b196cc..1142a90c87d1 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -1,5 +1,4 @@ import React, {useCallback} from 'react'; -import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -8,6 +7,7 @@ import getStyledTextArray from '@libs/GetStyledTextArray'; import CONST from '@src/CONST'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; +import type {MeasureParentContainerAndCursorCallback} from './AutoCompleteSuggestions/types'; import Avatar from './Avatar'; import Text from './Text'; @@ -53,8 +53,8 @@ type MentionSuggestionsProps = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isMentionPickerLarge: boolean; - /** Measures the parent container's position and dimensions. */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; }; /** @@ -62,7 +62,7 @@ type MentionSuggestionsProps = { */ const keyExtractor = (item: Mention) => item.alternateText; -function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSelect, isMentionPickerLarge, measureParentContainer = () => {}}: MentionSuggestionsProps) { +function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSelect, isMentionPickerLarge, measureParentContainerAndReportCursor = () => {}}: MentionSuggestionsProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -148,7 +148,7 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe onSelect={onSelect} isSuggestionPickerLarge={isMentionPickerLarge} accessibilityLabelExtractor={keyExtractor} - measureParentContainer={measureParentContainer} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} /> ); } diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 04d857a8faeb..7fc0299fa393 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -1,3 +1,4 @@ +import type {TextSelection} from '@components/Composer/types'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; type Selection = { @@ -8,7 +9,7 @@ type Selection = { /** * Replace substring between selection with a text. */ -function insertText(text: string, selection: Selection, textToInsert: string): string { +function insertText(text: string, selection: TextSelection, textToInsert: string): string { return text.slice(0, selection.start) + textToInsert + text.slice(selection.end, text.length); } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index c557229aca72..ea909f4ce976 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -1,3 +1,4 @@ +import {PortalHost} from '@gorhom/portal'; import {useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import lodashIsEqual from 'lodash/isEqual'; @@ -6,7 +7,6 @@ import type {FlatList, ViewStyle} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; -import type {LayoutChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import Banner from '@components/Banner'; import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -269,7 +269,6 @@ function ReportScreen({ }, [route, reportActionIDFromRoute]); const [isBannerVisible, setIsBannerVisible] = useState(true); - const [listHeight, setListHeight] = useState(0); const [scrollPosition, setScrollPosition] = useState({}); const wasReportAccessibleRef = useRef(false); @@ -596,8 +595,7 @@ function ReportScreen({ }; }, [report, didSubscribeToReportLeavingEvents, reportIDFromRoute]); - const onListLayout = useCallback((event: LayoutChangeEvent) => { - setListHeight((prev) => event.nativeEvent?.layout?.height ?? prev); + const onListLayout = useCallback(() => { if (!markReadyForHydration) { return; } @@ -735,12 +733,12 @@ function ReportScreen({ policy={policy} pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} - listHeight={listHeight} isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} /> ) : null} + diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 0515ca011517..7ca423f55245 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -10,15 +10,19 @@ import type { TextInput, TextInputFocusEventData, TextInputKeyPressEventData, - TextInputSelectionChangeEventData, + TextInputScrollEventData, } from 'react-native'; import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; +import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import {useSharedValue} from 'react-native-reanimated'; import type {useAnimatedRef} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; +import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; @@ -38,9 +42,10 @@ import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import * as SuggestionUtils from '@libs/SuggestionUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import getCursorPosition from '@pages/home/report/ReportActionCompose/getCursorPosition'; +import getScrollPosition from '@pages/home/report/ReportActionCompose/getScrollPosition'; import type {ComposerRef, SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; @@ -133,9 +138,6 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** Function to measure the parent container */ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** The height of the list */ - listHeight: number; - /** Whether the scroll is likely to trigger a layout */ isScrollLikelyLayoutTriggered: RefObject; @@ -248,7 +250,6 @@ function ComposerWithSuggestions( handleSendMessage, shouldShowComposeInput, measureParentContainer = () => {}, - listHeight, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, @@ -271,6 +272,9 @@ 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 draftComment = getDraftComment(reportID) ?? ''; const [value, setValue] = useState(() => { if (draftComment) { @@ -295,7 +299,7 @@ function ComposerWithSuggestions( const valueRef = useRef(value); valueRef.current = value; - const [selection, setSelection] = useState(() => ({start: 0, end: 0})); + const [selection, setSelection] = useState(() => ({start: 0, end: 0, positionX: 0, positionY: 0})); const [composerHeight, setComposerHeight] = useState(0); @@ -304,12 +308,6 @@ function ComposerWithSuggestions( const syncSelectionWithOnChangeTextRef = useRef(null); - const suggestions = suggestionsRef.current?.getSuggestions() ?? []; - - const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions?.length ?? 0); - - const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); @@ -389,9 +387,9 @@ function ComposerWithSuggestions( if (currentIndex < newText.length) { startIndex = currentIndex; - const commonSuffixLength = ComposerUtils.findCommonSuffixLength(prevText, newText, selection.end); + const commonSuffixLength = ComposerUtils.findCommonSuffixLength(prevText, newText, selection?.end ?? 0); // if text is getting pasted over find length of common suffix and subtract it from new text length - if (commonSuffixLength > 0 || selection.end - selection.start > 0) { + if (commonSuffixLength > 0 || (selection?.end ?? 0) - selection.start > 0) { endIndex = newText.length - commonSuffixLength; } else { endIndex = currentIndex + newText.length; @@ -438,16 +436,18 @@ function ComposerWithSuggestions( emojisPresentBefore.current = emojis; setValue(newCommentConverted); if (commentValue !== newComment) { - const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition ?? 0); + const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), cursorPosition ?? 0); if (commentWithSpaceInserted !== newComment && isIOSNative) { syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; } - setSelection({ + setSelection((prevSelection) => ({ start: position, end: position, - }); + positionX: prevSelection.positionX, + positionY: prevSelection.positionY, + })); } commentRef.current = newCommentConverted; @@ -490,7 +490,7 @@ function ComposerWithSuggestions( debouncedSaveReportComment.cancel(); isCommentPendingSaved.current = false; - setSelection({start: 0, end: 0}); + setSelection({start: 0, end: 0, positionX: 0, positionY: 0}); updateComment(''); setTextInputShouldClear(true); if (isComposerFullSize) { @@ -570,22 +570,27 @@ function ComposerWithSuggestions( ); const onSelectionChange = useCallback( - (e: NativeSyntheticEvent) => { - if (textInputRef.current?.isFocused() && suggestionsRef.current?.onSelectionChange?.(e)) { + (e: CustomSelectionChangeEvent) => { + if (!textInputRef.current?.isFocused()) { return; } + suggestionsRef.current?.onSelectionChange?.(e); setSelection(e.nativeEvent.selection); }, [suggestionsRef], ); - const hideSuggestionMenu = useCallback(() => { - if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, [suggestionsRef, isScrollLikelyLayoutTriggered]); + const hideSuggestionMenu = useCallback( + (e: NativeSyntheticEvent) => { + 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) { @@ -741,6 +746,43 @@ function ComposerWithSuggestions( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + tag.value = findNodeHandle(textInputRef.current) ?? -1; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useFocusedInputHandler( + { + onSelectionChange: (event) => { + 'worklet'; + + if (event.target === tag.value) { + cursorPositionValue.value = { + x: event.selection.end.x, + y: event.selection.end.y, + }; + } + }, + }, + [], + ); + const measureParentContainerAndReportCursor = useCallback( + (callback: MeasureParentContainerAndCursorCallback) => { + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef}); + const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.value, positionOnWeb: selection}); + measureParentContainer((x, y, width, height) => { + callback({ + x, + y, + width, + height, + scrollValue, + cursorCoordinates: {x: xPosition, y: yPosition}, + }); + }); + }, + [measureParentContainer, cursorPositionValue, selection], + ); + return ( <> @@ -780,12 +822,9 @@ function ComposerWithSuggestions( & { + Pick & { /** A method to call when the form is submitted */ onSubmit: (newComment: string) => void; @@ -111,7 +110,6 @@ function ReportActionCompose({ pendingAction, report, reportID, - listHeight = 0, shouldShowComposeInput = true, isReportReadyForDisplay = true, isEmptyChat, @@ -384,7 +382,6 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - { if (value.length === 0 && isComposerFullSize) { Report.setIsComposerFullSize(reportID, false); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx index b23c0be72592..b08ee77745db 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx @@ -1,6 +1,5 @@ import type {ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import EmojiSuggestions from '@components/EmojiSuggestions'; @@ -55,7 +54,7 @@ function SuggestionEmoji( updateComment, isAutoSuggestionPickerLarge, resetKeyboardInput, - measureParentContainer, + measureParentContainerAndReportCursor, isComposerFocused, }: SuggestionEmojiProps, ref: ForwardedRef, @@ -150,8 +149,8 @@ function SuggestionEmoji( * Calculates and cares about the content of an Emoji Suggester */ const calculateEmojiSuggestion = useCallback( - (selectionEnd: number) => { - if (shouldBlockCalc.current || !value) { + (selectionEnd?: number) => { + if (!selectionEnd || shouldBlockCalc.current || !value) { shouldBlockCalc.current = false; resetSuggestions(); return; @@ -185,18 +184,6 @@ function SuggestionEmoji( calculateEmojiSuggestion(selection.end); }, [selection, calculateEmojiSuggestion, isComposerFocused]); - const onSelectionChange = useCallback( - (e: NativeSyntheticEvent) => { - /** - * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion - * because in other case calculateEmojiSuggestion will have an old calculation value - * of suggestion instead of current one - */ - calculateEmojiSuggestion(e.nativeEvent.selection.end); - }, - [calculateEmojiSuggestion], - ); - const setShouldBlockSuggestionCalc = useCallback( (shouldBlockSuggestionCalc: boolean) => { shouldBlockCalc.current = shouldBlockSuggestionCalc; @@ -210,13 +197,12 @@ function SuggestionEmoji( ref, () => ({ resetSuggestions, - onSelectionChange, triggerHotkeyActions, setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, getSuggestions, }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], + [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], ); if (!isEmojiSuggestionsMenuVisible) { @@ -231,7 +217,7 @@ function SuggestionEmoji( onSelect={insertSelectedEmoji} preferredSkinToneIndex={preferredSkinTone} isEmojiPickerLarge={!!isAutoSuggestionPickerLarge} - measureParentContainer={measureParentContainer} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} /> ); } diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index dcae4b674fc3..9b8201fd6900 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -46,7 +46,7 @@ const defaultSuggestionsValues: SuggestionValues = { }; function SuggestionMention( - {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainer, isComposerFocused, isGroupPolicyReport, policyID}: SuggestionProps, + {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, isGroupPolicyReport, policyID}: SuggestionProps, ref: ForwardedRef, ) { const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; @@ -277,8 +277,8 @@ function SuggestionMention( ); const calculateMentionSuggestion = useCallback( - (selectionEnd: number) => { - if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { + (selectionEnd?: number) => { + if (!selectionEnd || shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { shouldBlockCalc.current = false; resetSuggestions(); return; @@ -392,7 +392,7 @@ function SuggestionMention( prefix={suggestionValues.mentionPrefix} onSelect={insertSelectedMention} isMentionPickerLarge={!!isAutoSuggestionPickerLarge} - measureParentContainer={measureParentContainer} + measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} /> ); } diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 8ebd52f62428..f82b38c3e154 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -1,18 +1,15 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react'; -import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import {View} from 'react-native'; +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; +import type {TextSelection} from '@components/Composer/types'; import {DragAndDropContext} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; import type {SuggestionsRef} from './ReportActionCompose'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; -type Selection = { - start: number; - end: number; -}; - type SuggestionProps = { /** The current input value */ value: string; @@ -21,19 +18,16 @@ type SuggestionProps = { setValue: (newValue: string) => void; /** The current selection value */ - selection: Selection; + selection: TextSelection; /** Callback to update the current selection */ - setSelection: (newSelection: Selection) => void; + setSelection: (newSelection: TextSelection) => void; /** Callback to update the comment draft */ updateComment: (newComment: string, shouldDebounceSaveComment?: boolean) => void; - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - - /** Whether the composer is expanded */ - isComposerFullSize: boolean; + /** Measures the parent container's position and dimensions. Also add cursor coordinates */ + measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; /** Report composer focus state */ isComposerFocused?: boolean; @@ -61,15 +55,13 @@ type SuggestionProps = { */ function Suggestions( { - isComposerFullSize, value, setValue, selection, setSelection, updateComment, - composerHeight, resetKeyboardInput, - measureParentContainer, + measureParentContainerAndReportCursor, isAutoSuggestionPickerLarge = true, isComposerFocused, isGroupPolicyReport, @@ -119,6 +111,7 @@ function Suggestions( const onSelectionChange = useCallback((e: NativeSyntheticEvent) => { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); + suggestionMentionRef.current?.onSelectionChange?.(e); return emojiHandler; }, []); @@ -157,11 +150,9 @@ function Suggestions( setValue, setSelection, selection, - isComposerFullSize, updateComment, - composerHeight, isAutoSuggestionPickerLarge, - measureParentContainer, + measureParentContainerAndReportCursor, isComposerFocused, isGroupPolicyReport, policyID, diff --git a/src/pages/home/report/ReportActionCompose/getCursorPosition/index.native.ts b/src/pages/home/report/ReportActionCompose/getCursorPosition/index.native.ts new file mode 100644 index 000000000000..5107e2c37362 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getCursorPosition/index.native.ts @@ -0,0 +1,10 @@ +import type {CursorPositionParamsType, PositionType} from './types'; + +function getCursorPosition({positionOnMobile}: CursorPositionParamsType): PositionType { + return { + x: positionOnMobile?.x ?? 0, + y: positionOnMobile?.y ?? 0, + }; +} + +export default getCursorPosition; diff --git a/src/pages/home/report/ReportActionCompose/getCursorPosition/index.ts b/src/pages/home/report/ReportActionCompose/getCursorPosition/index.ts new file mode 100644 index 000000000000..e1619b4cd45c --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getCursorPosition/index.ts @@ -0,0 +1,9 @@ +import type {CursorPositionParamsType, PositionType} from './types'; + +function getCursorPosition({positionOnWeb}: CursorPositionParamsType): PositionType { + const x = positionOnWeb?.positionX ?? 0; + const y = positionOnWeb?.positionY ?? 0; + return {x, y}; +} + +export default getCursorPosition; diff --git a/src/pages/home/report/ReportActionCompose/getCursorPosition/types.ts b/src/pages/home/report/ReportActionCompose/getCursorPosition/types.ts new file mode 100644 index 000000000000..424e71377ecd --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getCursorPosition/types.ts @@ -0,0 +1,13 @@ +type PositionType = { + x: number; + y: number; +}; + +type CursorPositionParamsType = { + positionOnMobile?: PositionType; + positionOnWeb?: {positionX?: number; positionY?: number}; +}; + +type GetCursorPositionType = (params: CursorPositionParamsType) => PositionType; + +export type {PositionType, CursorPositionParamsType, GetCursorPositionType}; diff --git a/src/pages/home/report/ReportActionCompose/getScrollPosition/index.native.ts b/src/pages/home/report/ReportActionCompose/getScrollPosition/index.native.ts new file mode 100644 index 000000000000..2045549959b8 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getScrollPosition/index.native.ts @@ -0,0 +1,14 @@ +import type {GetScrollPositionType, TextInputScrollProps} from './types'; + +function getScrollPosition({mobileInputScrollPosition}: TextInputScrollProps): GetScrollPositionType { + if (!mobileInputScrollPosition.current) { + return { + scrollValue: 0, + }; + } + return { + scrollValue: mobileInputScrollPosition.current, + }; +} + +export default getScrollPosition; diff --git a/src/pages/home/report/ReportActionCompose/getScrollPosition/index.ts b/src/pages/home/report/ReportActionCompose/getScrollPosition/index.ts new file mode 100644 index 000000000000..0d9fb701cabd --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getScrollPosition/index.ts @@ -0,0 +1,13 @@ +import type {GetScrollPositionType, TextInputScrollProps} from './types'; + +function getScrollPosition({textInputRef}: TextInputScrollProps): GetScrollPositionType { + let scrollValue = 0; + if (textInputRef?.current) { + if ('scrollTop' in textInputRef.current) { + scrollValue = textInputRef.current.scrollTop; + } + } + return {scrollValue}; +} + +export default getScrollPosition; diff --git a/src/pages/home/report/ReportActionCompose/getScrollPosition/types.ts b/src/pages/home/report/ReportActionCompose/getScrollPosition/types.ts new file mode 100644 index 000000000000..abb48e2cc079 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/getScrollPosition/types.ts @@ -0,0 +1,10 @@ +import type {TextInput} from 'react-native'; + +type TextInputScrollProps = { + mobileInputScrollPosition: React.RefObject; + textInputRef: React.RefObject; +}; + +type GetScrollPositionType = {scrollValue: number}; + +export type {TextInputScrollProps, GetScrollPositionType}; diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 349d967f5c1b..1037ed367577 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -52,9 +52,6 @@ type ReportFooterProps = { /** The pending action when we are adding a chat */ pendingAction?: PendingAction; - /** Height of the list which the composer is part of */ - listHeight?: number; - /** Whether the report is ready for display */ isReportReadyForDisplay?: boolean; @@ -77,7 +74,6 @@ function ReportFooter({ policy, isEmptyChat = true, isReportReadyForDisplay = true, - listHeight = 0, isComposerFullSize = false, onComposerBlur, onComposerFocus, @@ -215,7 +211,6 @@ function ReportFooter({ lastReportAction={lastReportAction} pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} - listHeight={listHeight} isReportReadyForDisplay={isReportReadyForDisplay} /> @@ -232,7 +227,6 @@ export default memo( (prevProps, nextProps) => lodashIsEqual(prevProps.report, nextProps.report) && prevProps.pendingAction === nextProps.pendingAction && - prevProps.listHeight === nextProps.listHeight && prevProps.isComposerFullSize === nextProps.isComposerFullSize && prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && diff --git a/src/styles/index.ts b/src/styles/index.ts index d0cc09a9ccf0..61fe0347e649 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -265,10 +265,8 @@ const styles = (theme: ThemeColors) => borderWidth: 1, borderColor: theme.border, justifyContent: 'center', + overflow: 'hidden', boxShadow: variables.popoverMenuShadow, - position: 'absolute', - left: 0, - right: 0, paddingVertical: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index f3c145210764..a6372131dfc9 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -845,7 +845,7 @@ type GetBaseAutoCompleteSuggestionContainerStyleParams = { */ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetBaseAutoCompleteSuggestionContainerStyleParams): ViewStyle { return { - ...positioning.pFixed, + position: 'absolute', bottom, left, width, @@ -863,11 +863,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0); - // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, - // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { - overflow: 'hidden', - top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + (shouldPreventScroll ? 0 : borderWidth)), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 242008965c83..136b9c86de71 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -95,7 +95,6 @@ function ReportActionComposeWrapper() { disabled={false} report={LHNTestUtils.getFakeReport()} isComposerFullSize - listHeight={200} /> );