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}
/>
);