From ec09aa675742d3b4df4a878f1467784e807907f4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 12:29:16 +0100 Subject: [PATCH] feat: integrate keyboard-aware chat scrolling Render chat report actions with KeyboardAwareLegendList so RNKC moves the messages and the composer together, keeping the action-sheet-aware scroll view. Upgrade react-native-keyboard-controller to 1.23.0 and port its patches. Expense and invoice reports, expanded composers, and Android versions without gesture-area support keep their existing keyboard handling. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Codex --- jest/setupMockLegendList.ts | 4 + package-lock.json | 8 +- package.json | 2 +- ...2+008+keyboard-scroll-view-component.patch | 104 ++++++++++++++++++ patches/@legendapp/list/details.md | 11 +- .../details.md | 23 +++- ...+1.23.0+001+fix-insets-in-landscape.patch} | 3 +- ...initial-keyboard-state-on-ui-thread.patch} | 0 ...ilize-ios-interactive-keyboard-state.patch | 33 ++++++ ...er+1.23.0+004+namespace-ios-ui-utils.patch | 24 ++++ .../__mocks__/index.android.tsx | 4 + .../__mocks__/index.ios.tsx | 4 + .../__mocks__/index.tsx | 4 + .../index.android.tsx | 2 + .../ActionSheetAwareScrollView/index.ios.tsx | 2 + .../ActionSheetAwareScrollView/index.tsx | 2 + .../ActionSheetAwareScrollView/types.ts | 8 +- src/hooks/useLinkedReportActionScroll.ts | 5 +- .../index.android.ts | 10 ++ src/libs/canUseKeyboardGestureArea/index.ts | 6 + src/pages/inbox/ReportScreen.tsx | 83 ++++++++++---- .../ReportActionsListContent.tsx | 22 +++- .../useReportActionsListScroll.ts | 35 ++++-- src/pages/inbox/report/ReportFooter.tsx | 21 ++-- .../useReportUnreadMessageScrollTracking.ts | 15 +-- tests/ui/ReportActionsListTest.tsx | 75 ++++++++++++- tests/ui/ReportFooterTest.tsx | 61 +++++++++- tests/unit/ReportActionsListThresholdTest.tsx | 58 +++++++++- tests/unit/useReportActionsScrollTest.tsx | 23 ++++ ...seReportUnreadMessageScrollTrackingTest.ts | 27 +++++ 30 files changed, 597 insertions(+), 82 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch rename patches/react-native-keyboard-controller/{react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch => react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch} (93%) rename patches/react-native-keyboard-controller/{react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch => react-native-keyboard-controller+1.23.0+002+apply-initial-keyboard-state-on-ui-thread.patch} (100%) create mode 100644 patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch create mode 100644 patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch create mode 100644 src/libs/canUseKeyboardGestureArea/index.android.ts create mode 100644 src/libs/canUseKeyboardGestureArea/index.ts diff --git a/jest/setupMockLegendList.ts b/jest/setupMockLegendList.ts index 39530f50e142..91b1fefd9c51 100644 --- a/jest/setupMockLegendList.ts +++ b/jest/setupMockLegendList.ts @@ -165,6 +165,10 @@ export default function setupMockLegendList() { }; }); + jest.mock('@legendapp/list/keyboard', () => ({ + KeyboardAwareLegendList: jest.requireMock('@legendapp/list/react-native').LegendList, + })); + jest.mock('@legendapp/list/reanimated', () => ({ AnimatedLegendList: jest.requireMock('@legendapp/list/react-native').LegendList, })); diff --git a/package-lock.json b/package-lock.json index f9fb702fa1ad..c7ce274e3fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,7 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8a1c3b7359977462cdd4e63b09ea45afbd3d77c7", "react-native-key-command": "1.0.14", - "react-native-keyboard-controller": "1.22.4", + "react-native-keyboard-controller": "1.23.0", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-image": "0.15.2", @@ -38409,9 +38409,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.22.4", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.22.4.tgz", - "integrity": "sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.23.0.tgz", + "integrity": "sha512-UqHOiGeVQnUj1NBggzQ9LD9rqHf7k4u81hqDpIyS2k3r1noOp9w5/lLiSajipH88BsMO7J43E3N/L+bfKEiuZA==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" diff --git a/package.json b/package.json index 47091d423e93..2bd8d46a97bf 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8a1c3b7359977462cdd4e63b09ea45afbd3d77c7", "react-native-key-command": "1.0.14", - "react-native-keyboard-controller": "1.22.4", + "react-native-keyboard-controller": "1.23.0", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", "react-native-nitro-image": "0.15.2", diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch new file mode 100644 index 000000000000..8d617d401367 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch @@ -0,0 +1,104 @@ +diff --git a/node_modules/@legendapp/list/keyboard.js b/node_modules/@legendapp/list/keyboard.js +--- a/node_modules/@legendapp/list/keyboard.js ++++ b/node_modules/@legendapp/list/keyboard.js +@@ -132,6 +132,8 @@ + freeze, + keyboardLiftBehavior, + keyboardOffset, ++ onContentInsetChange: onContentInsetChangeProp, ++ ScrollViewComponent, + refScrollView, + ...rest + } = props; +@@ -236,8 +238,9 @@ + } else { + list == null ? void 0 : list.reportContentInset(insets); + } +- }, +- [endScrollFreeze, getEndInset, keyboard, keyboardTarget] ++ onContentInsetChangeProp == null ? void 0 : onContentInsetChangeProp(insets); ++ }, ++ [endScrollFreeze, getEndInset, keyboard, keyboardTarget, onContentInsetChangeProp] + ); + const reportClosingInset = React.useCallback((bottom) => { + var _a2; +@@ -295,6 +298,7 @@ + freeze, + keyboardLiftBehavior, + offset: keyboardOffset, ++ ScrollViewComponent, + onContentInsetChange, + onContentSizeChange: (width, height) => { + var _a2; +@@ -317,6 +321,7 @@ + freeze, + keyboardLiftBehavior, + keyboardOffset, ++ ScrollViewComponent, + onContentInsetChange + ] + ); +diff --git a/node_modules/@legendapp/list/keyboard.mjs b/node_modules/@legendapp/list/keyboard.mjs +--- a/node_modules/@legendapp/list/keyboard.mjs ++++ b/node_modules/@legendapp/list/keyboard.mjs +@@ -111,6 +111,8 @@ + freeze, + keyboardLiftBehavior, + keyboardOffset, ++ onContentInsetChange: onContentInsetChangeProp, ++ ScrollViewComponent, + refScrollView, + ...rest + } = props; +@@ -215,8 +217,9 @@ + } else { + list == null ? void 0 : list.reportContentInset(insets); + } +- }, +- [endScrollFreeze, getEndInset, keyboard, keyboardTarget] ++ onContentInsetChangeProp == null ? void 0 : onContentInsetChangeProp(insets); ++ }, ++ [endScrollFreeze, getEndInset, keyboard, keyboardTarget, onContentInsetChangeProp] + ); + const reportClosingInset = useCallback((bottom) => { + var _a2; +@@ -274,6 +277,7 @@ + freeze, + keyboardLiftBehavior, + offset: keyboardOffset, ++ ScrollViewComponent, + onContentInsetChange, + onContentSizeChange: (width, height) => { + var _a2; +@@ -296,6 +300,7 @@ + freeze, + keyboardLiftBehavior, + keyboardOffset, ++ ScrollViewComponent, + onContentInsetChange + ] + ); +diff --git a/node_modules/@legendapp/list/keyboard.d.ts b/node_modules/@legendapp/list/keyboard.d.ts +--- a/node_modules/@legendapp/list/keyboard.d.ts ++++ b/node_modules/@legendapp/list/keyboard.d.ts +@@ -318,7 +318,11 @@ + reportContentInset(inset?: Partial | null): void; + }; + +-type KeyboardChatScrollViewPropsUnique = Omit; ++type KeyboardChatScrollViewPropsUnique = Omit; ++type KeyboardAwareScrollViewComponentProps = NonNullable extends React.ForwardRefExoticComponent ? Omit & { ++ ref?: React.Ref>; ++} : never; ++type KeyboardAwareScrollViewComponent = (props: KeyboardAwareScrollViewComponentProps) => React.ReactElement | null; + type ScrollMessageToEndOptions = { + animated: boolean; + closeKeyboard: boolean; +@@ -350,6 +354,7 @@ + anchoredEndSpace?: AnchoredEndSpaceConfig; + contentInsetEndAdjustment?: SharedValue; + keyboardOffset?: number; ++ ScrollViewComponent?: KeyboardAwareScrollViewComponent; + } & React.RefAttributes) => React.ReactElement | null; + + export { KeyboardAwareLegendList, useKeyboardChatComposerInset, useKeyboardScrollToEnd }; diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 85637bd6e55d..166e36979bba 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,6 +1,6 @@ # `@legendapp/list` patches -Each patch is the build output of its upstream PR, so it changes exactly what that PR changes. Remove a patch once a LegendList release contains its PR. +Patches 001-007 match the build output of their upstream PRs. Patch 008 exposes the custom scroll view and inset callback needed by the keyboard integration. ### [@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch) @@ -64,3 +64,12 @@ Each patch is the build output of its upstream PR, so it changes exactly what th - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On iOS or Android, open a chat with more history than the first page. Turn off the network connection. Turn the network connection back on. The messages on screen should not move when the placeholder above them disappears. + +### [@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch](@legendapp+list+3.7.2+008+keyboard-scroll-view-component.patch) + +- Reason: `KeyboardAwareLegendList` renders its own scroll view and doesn't accept a custom one. The patch exposes and forwards `ScrollViewComponent`, so RNKC drives the action-sheet-aware scroll view and message menus keep their keyboard spacing and scroll ref. It also forwards `onContentInsetChange` after updating the list's insets, so the Latest messages pill and unread tracking can refresh when the keyboard settles without another scroll event. +- Scope: Only `KeyboardAwareLegendList`, and only when `ScrollViewComponent` or `onContentInsetChange` is passed. LegendList's own inset reporting is unchanged on both platforms. +- Upstream PR/issue: 🛑 +- E/App issue: https://github.com/Expensify/App/issues/54270 +- PR introducing patch: 🛑 +- Reproduction: On iOS, open a chat, focus the composer, and long-press a message. The message menu should open above the keyboard, and dragging the chat down should dismiss the keyboard with the composer attached. diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md index 088dfaebd621..8f3d3d309846 100644 --- a/patches/react-native-keyboard-controller/details.md +++ b/patches/react-native-keyboard-controller/details.md @@ -1,18 +1,19 @@ # `react-native-keyboard-controller` patches -### [react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch) +### [react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch) - Reason: ``` - In EdgeToEdgeReactViewGroup.setupWindowInsets(), the library unconditionally applies navBarInsets.left and navBarInsets.right as margins on the content view, even though the bottom margin is already conditional on navigationBarTranslucent. In landscape mode on Android, the navigation bar moves to the side of the screen, and these unconditional margins shrink the content area by the nav bar width. This causes useSafeAreaFrame() to report a reduced windowWidth and useSafeAreaInsets() to return 0 for left/right insets, since the SafeAreaProvider view no longer overlaps with the nav bar area. The patch makes left/right margins respect the same shouldApplyZeroPaddingBottom condition used for the bottom margin. + Android: In EdgeToEdgeReactViewGroup.setupWindowInsets(), the library unconditionally applies navBarInsets.left and navBarInsets.right as margins on the content view, even though the bottom margin is already conditional on navigationBarTranslucent. In landscape mode on Android, the navigation bar moves to the side of the screen, and these unconditional margins shrink the content area by the nav bar width. This causes useSafeAreaFrame() to report a reduced windowWidth and useSafeAreaInsets() to return 0 for left/right insets, since the SafeAreaProvider view no longer overlaps with the nav bar area. The patch makes left/right margins respect the same shouldApplyZeroPaddingBottom condition used for the bottom margin. + ``` -- Upstream PR/issue: - +- Upstream PR/issue: 🛑 - E/App issue: https://github.com/Expensify/App/issues/87307 - PR introducing patch: https://github.com/Expensify/App/pull/87376 -### [react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch](react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch) +### [react-native-keyboard-controller+1.23.0+002+apply-initial-keyboard-state-on-ui-thread.patch](react-native-keyboard-controller+1.23.0+002+apply-initial-keyboard-state-on-ui-thread.patch) - Reason: @@ -23,3 +24,17 @@ - Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1685 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/106003 + +### [react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch](react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch) + +- Reason: Rejects non-finite and negative keyboard layout-guide positions while preserving the upstream fractional-height rounding. Applies final keyboard events to Animated and Reanimated values so a cancelled dismissal returns the composer to its final position. +- Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1563 +- E/App issue: https://github.com/Expensify/App/issues/54270 +- PR introducing patch: https://github.com/margelo/Expensify/pull/38 + +### [react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch](react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch) + +- Reason: Prefixes the Objective-C helper name so the keyboard controller can link alongside HybridApp's existing UIUtils class. Swift callers keep the same API. +- Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1686 +- E/App issue: https://github.com/Expensify/App/issues/54270 +- PR introducing patch: https://github.com/margelo/Expensify/pull/38 diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch similarity index 93% rename from patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch rename to patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch index 24957c90dcea..aeff22e21cd6 100644 --- a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+001+fix-insets-in-landscape.patch +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+001+fix-insets-in-landscape.patch @@ -1,8 +1,7 @@ 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 a8b8a7a..e227a3e 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 -@@ -119,13 +119,21 @@ class EdgeToEdgeReactViewGroup( +@@ -119,13 +119,21 @@ if (!shouldApplyBottomPadding) 0 else insets.getInsets(WindowInsetsCompat.Type.ime()).bottom params.setMargins( diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+002+apply-initial-keyboard-state-on-ui-thread.patch similarity index 100% rename from patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch rename to patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+002+apply-initial-keyboard-state-on-ui-thread.patch diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch new file mode 100644 index 000000000000..eed4f1f65059 --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+003+stabilize-ios-interactive-keyboard-state.patch @@ -0,0 +1,33 @@ +diff --git a/node_modules/react-native-keyboard-controller/ios/observers/movement/KeyboardTrackingView.swift b/node_modules/react-native-keyboard-controller/ios/observers/movement/KeyboardTrackingView.swift +--- a/node_modules/react-native-keyboard-controller/ios/observers/movement/KeyboardTrackingView.swift ++++ b/node_modules/react-native-keyboard-controller/ios/observers/movement/KeyboardTrackingView.swift +@@ -141,7 +141,7 @@ + // for `keyboardLayoutGuide` case we can just read keyboard position directly - no interpolation needed + if KeyboardControllerConfiguration.usesKeyboardLayoutGuideTracking { + // KVO and keyboard notifications can round fractional accessory heights differently. +- if round(keyboardPosition) > round(keyboardHeight) { ++ if !keyboardPosition.isFinite || keyboardPosition < 0 || round(keyboardPosition) > round(keyboardHeight) { + return Self.invalidPosition + } + // when we are the top position KVO takes `inputAccessoryView` into consideration, +diff --git a/node_modules/react-native-keyboard-controller/src/animated.tsx b/node_modules/react-native-keyboard-controller/src/animated.tsx +--- a/node_modules/react-native-keyboard-controller/src/animated.tsx ++++ b/node_modules/react-native-keyboard-controller/src/animated.tsx +@@ -178,7 +178,7 @@ + onKeyboardMoveEnd: (event: NativeEvent) => { + "worklet"; + +- updateSharedValues(event, ["android"]); ++ updateSharedValues(event, ["android", "ios"]); + }, + }, + [], +@@ -226,7 +226,7 @@ + onKeyboardMoveStart={OS === "ios" ? onKeyboardMove : undefined} + onKeyboardMove={OS === "android" ? onKeyboardMove : undefined} + onKeyboardMoveInteractive={onKeyboardMove} +- onKeyboardMoveEnd={OS === "android" ? onKeyboardMove : undefined} ++ onKeyboardMoveEnd={onKeyboardMove} + onFocusedInputLayoutChangedReanimated={inputLayoutHandler} + > + {children} diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch new file mode 100644 index 000000000000..742637581490 --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.23.0+004+namespace-ios-ui-utils.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/react-native-keyboard-controller/ios/core/UIUtils.swift b/node_modules/react-native-keyboard-controller/ios/core/UIUtils.swift +--- a/node_modules/react-native-keyboard-controller/ios/core/UIUtils.swift ++++ b/node_modules/react-native-keyboard-controller/ios/core/UIUtils.swift +@@ -8,7 +8,7 @@ + + import Foundation + +-@objc(UIUtils) ++@objc(RNKCUIUtils) + public final class UIUtils: NSObject { + @objc public static let nextFrame = 1.0 / 60 + } +diff --git a/node_modules/react-native-keyboard-controller/ios/KeyboardControllerModule.mm b/node_modules/react-native-keyboard-controller/ios/KeyboardControllerModule.mm +--- a/node_modules/react-native-keyboard-controller/ios/KeyboardControllerModule.mm ++++ b/node_modules/react-native-keyboard-controller/ios/KeyboardControllerModule.mm +@@ -156,7 +156,7 @@ + #ifdef RCT_NEW_ARCH_ENABLED + // Layout events can precede Fabric mounting. Allow a display interval before the only lookup. + dispatch_after( +- dispatch_time(DISPATCH_TIME_NOW, (int64_t)(UIUtils.nextFrame * NSEC_PER_SEC)), ++ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(RNKCUIUtils.nextFrame * NSEC_PER_SEC)), + dispatch_get_main_queue(), + measure); + #else diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx index dd2e929205ae..99518af7d1d0 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx @@ -1,5 +1,9 @@ +import Reanimated from 'react-native-reanimated'; + import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default Reanimated.ScrollView; diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx index dd2e929205ae..99518af7d1d0 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx @@ -1,5 +1,9 @@ +import Reanimated from 'react-native-reanimated'; + import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default Reanimated.ScrollView; diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx index dd2e929205ae..99518af7d1d0 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx @@ -1,5 +1,9 @@ +import Reanimated from 'react-native-reanimated'; + import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default Reanimated.ScrollView; diff --git a/src/components/ActionSheetAwareScrollView/index.android.tsx b/src/components/ActionSheetAwareScrollView/index.android.tsx index ea833305ead7..399ba78d2e90 100644 --- a/src/components/ActionSheetAwareScrollView/index.android.tsx +++ b/src/components/ActionSheetAwareScrollView/index.android.tsx @@ -33,3 +33,5 @@ const renderScrollComponent: RenderActionSheetAwareScrollViewComponent = (props) }; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default ActionSheetAwareScrollView; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index a14eeb22d5fa..567aa4b008fb 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -39,3 +39,5 @@ const renderScrollComponent: RenderActionSheetAwareScrollViewComponent = (props) }; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default ActionSheetAwareScrollView; diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx index 818c7b48b751..0c125b34bdfc 100644 --- a/src/components/ActionSheetAwareScrollView/index.tsx +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -35,3 +35,5 @@ const renderScrollComponent: RenderActionSheetAwareScrollViewComponent = (props) }; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; + +export default ActionSheetAwareScrollView; diff --git a/src/components/ActionSheetAwareScrollView/types.ts b/src/components/ActionSheetAwareScrollView/types.ts index c5ce9708bab5..7f6d5ac59293 100644 --- a/src/components/ActionSheetAwareScrollView/types.ts +++ b/src/components/ActionSheetAwareScrollView/types.ts @@ -2,9 +2,9 @@ import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; import type {ComponentRef, Ref} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {ScrollView, ScrollViewProps} from 'react-native'; +import type {ScrollView, ScrollViewComponent, ScrollViewProps} from 'react-native'; import type Reanimated from 'react-native-reanimated'; -import type {SharedValue} from 'react-native-reanimated'; +import type {AnimatedScrollViewProps, SharedValue} from 'react-native-reanimated'; type ActionSheetAwareScrollViewMeasurements = { frameY?: number; @@ -27,9 +27,9 @@ type ActionSheetAwareScrollViewActionsContextValue = { type ActionSheetAwareScrollViewContextValue = ActionSheetAwareScrollViewStateContextValue & ActionSheetAwareScrollViewActionsContextValue; -type ActionSheetAwareScrollViewHandle = ComponentRef | Reanimated.ScrollView; +type ActionSheetAwareScrollViewHandle = ComponentRef | ComponentRef | Reanimated.ScrollView; -type ActionSheetAwareScrollViewProps = ScrollViewProps & { +type ActionSheetAwareScrollViewProps = Omit & { ref?: Ref; }; diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index 41d80690528f..cf40dacde64b 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -150,9 +150,10 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, if (position === undefined) { return; } - const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const {contentInset, contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const linkedActionOffset = position + (state?.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; - const maximumScrollOffset = contentSize.height - layoutMeasurement.height; + const contentHeight = state?.contentLength ?? contentSize.height + (contentInset?.bottom ?? 0); + const maximumScrollOffset = contentHeight - layoutMeasurement.height; const targetOffset = Math.max(0, Math.min(linkedActionOffset, maximumScrollOffset)); // Native anchoring can commit after the row-size correction. Check the resulting viewport // as well, so that late adjustment cannot move a successfully positioned link out of view. diff --git a/src/libs/canUseKeyboardGestureArea/index.android.ts b/src/libs/canUseKeyboardGestureArea/index.android.ts new file mode 100644 index 000000000000..a64ebc48777e --- /dev/null +++ b/src/libs/canUseKeyboardGestureArea/index.android.ts @@ -0,0 +1,10 @@ +// The keyboard controller's Android gesture view requires the system's interactive keyboard API. +import {Platform} from 'react-native'; + +const MIN_ANDROID_VERSION_FOR_KEYBOARD_GESTURES = 30; + +function canUseKeyboardGestureArea(): boolean { + return Number(Platform.Version) >= MIN_ANDROID_VERSION_FOR_KEYBOARD_GESTURES; +} + +export default canUseKeyboardGestureArea; diff --git a/src/libs/canUseKeyboardGestureArea/index.ts b/src/libs/canUseKeyboardGestureArea/index.ts new file mode 100644 index 000000000000..d57615a87f01 --- /dev/null +++ b/src/libs/canUseKeyboardGestureArea/index.ts @@ -0,0 +1,6 @@ +// The keyboard controller's gesture area needs no version gate on iOS or web. +function canUseKeyboardGestureArea(): boolean { + return true; +} + +export default canUseKeyboardGestureArea; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 89abccfddf72..2fe29bf29d13 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -1,20 +1,23 @@ import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScreenWrapperOfflineIndicators from '@components/ScreenWrapper/ScreenWrapperOfflineIndicators'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; import useConciergeAskState from '@hooks/useConciergeAskState'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import {clearReportErrors, removeFailedReport} from '@libs/actions/Report'; +import canUseKeyboardGestureArea from '@libs/canUseKeyboardGestureArea'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {isMoneyRequestReport} from '@libs/ReportUtils'; +import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -23,8 +26,9 @@ import SCREENS from '@src/SCREENS'; import type {ViewStyle} from 'react-native'; import {PortalHost} from '@gorhom/portal'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; +import {KeyboardGestureArea, KeyboardStickyView} from 'react-native-keyboard-controller'; import type ReportScreenNavigationProps from './types'; @@ -77,10 +81,16 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep const styles = useThemeStyles(); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportIDFromRoute); - const {isInNarrowPaneModal} = useResponsiveLayout(); + // Wide panes already render the composer's inline offline indicator. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isInNarrowPaneModal, isSmallScreenWidth} = useResponsiveLayout(); const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const viewportOffsetTop = useViewportOffsetTop(); const isTopMostReportId = currentReportIDValue === reportIDFromRoute; + const isKeyboardInteractionEnabled = isTopMostReportId || isInNarrowPaneModal; + const safeAreaPaddings = useSafeAreaPaddings(true); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; useSubmitToDestinationVisible( @@ -95,9 +105,11 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep // Fall back to the report errors (e.g. a failed submit) so they can be shown and dismissed in the report reportErrors: r?.errorFields?.createReport ?? r?.errors, hasReportCreationErrors: !!r?.errorFields?.createReport, + isKeyboardAwareList: canUseKeyboardGestureArea() && !!r && !isMoneyRequestReport(r) && !isInvoiceReport(r), }), }); - const {reportPendingAction, reportErrors, hasReportCreationErrors} = reportPendingActionAndErrors ?? {}; + const {reportPendingAction, reportErrors, hasReportCreationErrors, isKeyboardAwareList} = reportPendingActionAndErrors ?? {}; + const isKeyboardAwareLayout = !!isKeyboardAwareList && !isComposerFullSize; const dismissReportCreationError = () => { if (!hasReportCreationErrors) { @@ -119,7 +131,10 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep @@ -153,25 +168,49 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep - - - } + - - - + + setComposerHeight(event.nativeEvent.layout.height)} + offset={{ + closed: 0, + opened: safeAreaPaddings.paddingBottom, + }} + > + } + > + + + {isKeyboardAwareLayout && isSmallScreenWidth && ( + + )} + + + diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 6a8c8ceee9d8..83634706c7ed 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -1,5 +1,5 @@ /** Renders report messages with history controls, unread indicators, and loading states. */ -import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import ActionSheetAwareScrollView, {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -11,14 +11,16 @@ import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; +import canUseKeyboardGestureArea from '@libs/canUseKeyboardGestureArea'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {shouldHidePayAction} from '@libs/ReportActionsUtils'; -import {isArchivedReport} from '@libs/ReportUtils'; +import {isArchivedReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; @@ -38,6 +40,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {KeyboardAwareLegendList} from '@legendapp/list/keyboard'; import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; import React, {useEffect, useState} from 'react'; @@ -64,6 +67,7 @@ const REPORT_ACTIONS_ESTIMATED_ITEM_SIZE = 50; */ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) { const styles = useThemeStyles(); + const safeAreaPaddings = useSafeAreaPaddings(true); const {translate} = useLocalize(); const {isProduction} = useEnvironment(); @@ -80,6 +84,9 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) shouldShowConciergeWelcome, isConciergeHistoryExpanded, } = useReportActionsListState(); + // Expense and invoice tables still rely on the screen's keyboard avoidance. + const isKeyboardAwareList = canUseKeyboardGestureArea() && !!report && !isMoneyRequestReport(report) && !isInvoiceReport(report); + const ListComponent = isKeyboardAwareList ? KeyboardAwareLegendList : LegendList; // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. const paymentFilteredReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; @@ -162,6 +169,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const { listID, legendListProps, + syncScrollOffsetFromList, loadedInitialViewportListID, shouldShowInitialViewportSkeleton, shouldShowNewerActionsSkeleton, @@ -295,7 +303,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) report={report} isReportArchived={isReportArchived} > - ) => { trackLinkedMessageScroll(event); - const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; - const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + const {contentInset, contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + // The keyboard controller adds space through insets, which LegendList includes in its measured content length. + const contentHeight = legendListRef.current?.getState()?.contentLength ?? contentSize.height + (contentInset?.bottom ?? 0); + const distanceFromBottom = Math.max(0, contentHeight - layoutMeasurement.height - contentOffset.y); distanceFromBottomRef.current = distanceFromBottom; const previousMetrics = lastScrollMetricsRef.current; const canFollowListEnd = readyEndListIDRef.current === listID && !hasNewerActions; @@ -360,7 +362,7 @@ function useReportActionsListScroll({ const hasReaderMovedAwayFromEnd = (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || (previousMetrics && - previousMetrics.contentHeight === contentSize.height && + previousMetrics.contentHeight === contentHeight && previousMetrics.viewportHeight === layoutMeasurement.height && endCorrectionFrameRef.current === undefined); if (hasReaderMovedAwayFromEnd) { @@ -369,7 +371,7 @@ function useReportActionsListScroll({ } } } - lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; + lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight, viewportHeight: layoutMeasurement.height}; checkPaginationOnScroll(); @@ -395,16 +397,26 @@ function useReportActionsListScroll({ scrollToActionBadgeTarget, }); + // A keyboard inset can settle after the final native scroll event. + const syncScrollOffsetFromList = () => { + if (readyEndListIDRef.current !== listID) { + return; + } + const state = legendListRef.current?.getState(); + if (!state) { + return; + } + const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); + scrollOffsetRef.current = distanceFromBottom; + distanceFromBottomRef.current = distanceFromBottom; + trackVerticalScrolling(undefined); + onScrolledOverThresholdChange(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + }; + // Keep bottom-relative scroll metrics current when the viewport is resized. const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { onLayout?.(event); - const state = legendListRef.current?.getState(); - const canMeasureBottomOffset = state && readyEndListIDRef.current === listID; - if (canMeasureBottomOffset) { - const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); - scrollOffsetRef.current = distanceFromBottom; - distanceFromBottomRef.current = distanceFromBottom; - } + syncScrollOffsetFromList(); scheduleEndCorrection(); // A resized viewport changes the pagination threshold. checkPaginationOnScroll(); @@ -437,6 +449,7 @@ function useReportActionsListScroll({ shouldShowOlderPaginationLoadingIndicator, shouldShowNewerPaginationLoadingIndicator, onNewerFooterLayout: updateNewerFooterHeight, + syncScrollOffsetFromList, legendListProps: { ref: legendListRef, keyExtractor: getReportActionKey, diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index a991701e1c73..7eae0a4ea74d 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -21,12 +21,15 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import canUseKeyboardGestureArea from '@libs/canUseKeyboardGestureArea'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { canUserPerformWriteAction, canWriteInReport as canWriteInReportUtil, isAdminsOnlyPostingRoom as isAdminsOnlyPostingRoomUtil, isArchivedNonExpenseReport, + isInvoiceReport, + isMoneyRequestReport, isPublicRoom, isResolvedSupportTicket, isSupportTicket, @@ -108,7 +111,11 @@ function ReportFooter() { return null; } - const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; + const isKeyboardAwareLayout = canUseKeyboardGestureArea() && !isMoneyRequestReport(report) && !isInvoiceReport(report) && !isComposerFullSize; + const chatFooterStyles = { + ...styles.chatFooter, + minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0, + }; if (shouldShowResolvedSupportTicketFooter) { return ( @@ -124,11 +131,8 @@ function ReportFooter() { // Happy path — user can compose if (!shouldHideComposer) { - const composer = ( - - - - ); + const composerContent = ; + const composer = isKeyboardAwareLayout ? composerContent : {composerContent}; // The callout decides for itself whether this mount suits the layout and the composer size. const merchantRuleBanner = ( ; return ( {isEditingWithComposer && ( - - - + {isKeyboardAwareLayout ? editComposer : {editComposer}} )} { const hasUnreadMarkerReportAction = unreadMarkerReportActionIndex !== -1; + // Each position must update visibility because keyboard inset and scroll events can be batched. // display floating button if we're scrolled more than the offset - if ( - currentVerticalScrollingOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD && - !isFloatingMessageCounterVisible && - !hasUnreadMarkerReportAction && - !shouldBeAlignedToTop - ) { + if (currentVerticalScrollingOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD && !hasUnreadMarkerReportAction && !shouldBeAlignedToTop) { setIsFloatingMessageCounterVisible(true); } // hide floating button if we're scrolled closer than the offset - if ( - currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD && - isFloatingMessageCounterVisible && - !hasUnreadMarkerReportAction && - !hasNewerActions - ) { + if (currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD && !hasUnreadMarkerReportAction && !hasNewerActions) { setIsFloatingMessageCounterVisible(false); } }; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9c5a8b8f395d..c9f397a3f298 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,5 +1,6 @@ import {act, render, screen, waitFor} from '@testing-library/react-native'; +import ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {RenderActionSheetAwareScrollViewComponent} from '@components/ActionSheetAwareScrollView/types'; import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -23,6 +24,7 @@ import useSidePanelState from '@hooks/useSidePanelState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import type useUnreadMarker from '@hooks/useUnreadMarker'; +import canUseKeyboardGestureArea from '@libs/canUseKeyboardGestureArea'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -257,12 +259,19 @@ jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)); }); jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); + +jest.mock('@libs/canUseKeyboardGestureArea', () => jest.fn(() => true)); jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/CollapsedSystemMessages', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ShowPreviousMessagesButton', () => jest.fn(() => null)); type MockLegendListProps = { + ScrollViewComponent?: typeof ActionSheetAwareScrollView; + keyboardOffset?: number; + keyboardDismissMode?: string; + automaticallyAdjustContentInsets?: boolean; + contentInsetAdjustmentBehavior?: string; alignItemsAtEnd?: boolean; contentContainerStyle?: StyleProp; data?: OnyxTypes.ReportAction[]; @@ -577,17 +586,76 @@ describe('ReportActionsList (body)', () => { renderReportActionsList(); const listProps = getCapturedListProps(); - // When LegendList requests native scroll events on every frame. - const scrollView = listProps?.renderScrollComponent?.({scrollEventThrottle: 0}); + // When the keyboard controller requests native scroll events on every frame. + const ScrollViewComponent = listProps?.ScrollViewComponent; + if (!ScrollViewComponent) { + throw new Error('Expected the action-sheet-aware keyboard scroller'); + } + render( + , + ); // Then the wrapper preserves them while LegendList separately throttles the app callback. - expect(scrollView?.props.scrollEventThrottle).toBe(0); + expect(screen.getByTestId('keyboard-aware-scroller').props.scrollEventThrottle).toBe(0); expect(listProps?.scrollEventThrottle).toBe(16); } finally { platform.restore(); } }); + it('composes chat keyboard scrolling with the action-sheet-aware scroll view', () => { + // Given a chat using the chronological list. + mockUseNetwork.mockReturnValue({isOffline: false}); + + // When its list mounts after the viewport is measured. + renderReportActionsList(); + + // Then the keyboard controller owns keyboard scrolling and keeps the action-sheet-aware native view. + expect(getCapturedListProps()).toMatchObject({ + ScrollViewComponent: ActionSheetAwareScrollView, + keyboardDismissMode: 'interactive', + automaticallyAdjustContentInsets: false, + contentInsetAdjustmentBehavior: 'never', + maintainScrollAtEnd: false, + }); + expect(getCapturedListProps()?.renderScrollComponent).toBeUndefined(); + }); + + it('keeps the existing scroller when keyboard gestures are unavailable', () => { + // Given an Android version that does not support the keyboard controller's gesture area. + jest.mocked(canUseKeyboardGestureArea).mockReturnValue(false); + try { + // When its chat list mounts. + renderReportActionsList(); + + // Then the screen can keep its existing keyboard avoidance and swipe dismissal. + expect(getCapturedListProps()?.ScrollViewComponent).toBeUndefined(); + expect(getCapturedListProps()?.keyboardDismissMode).toBeUndefined(); + } finally { + jest.mocked(canUseKeyboardGestureArea).mockReturnValue(true); + } + }); + + it('retains the existing scroller for expense reports', () => { + // Given an expense report whose screen still uses keyboard avoidance. + const reportType = mockReport.type; + mockReport.type = CONST.REPORT.TYPE.EXPENSE; + try { + // When it renders its chronological message rows. + renderReportActionsList(); + + // Then it does not add a second keyboard adjustment. + expect(getCapturedListProps()?.ScrollViewComponent).toBeUndefined(); + expect(getCapturedListProps()?.keyboardOffset).toBeUndefined(); + expect(getCapturedListProps()?.keyboardDismissMode).toBeUndefined(); + } finally { + mockReport.type = reportType; + } + }); + it.each([800, 1000])('refreshes the newest-message offset without following successive viewport layouts at %i pixels', async (height) => { // Given a reader 600 pixels from the end before the viewport grows. mockUseNetwork.mockReturnValue({isOffline: false}); @@ -609,6 +677,7 @@ describe('ReportActionsList (body)', () => { act(() => { const listProps = getCapturedListProps(); listProps?.onScrollBeginDrag?.(); + mockLegendGetState.mockReturnValue({...originalState, contentLength: 2000, scrollLength: 500, scroll: 900}); listProps?.onScroll?.({ nativeEvent: { contentOffset: {x: 0, y: 900}, diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index 9a5d3d86c083..2c697f04826a 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -3,10 +3,15 @@ import {act, render, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import SwipeableView from '@components/SwipeableView'; +import type SwipeableViewProps from '@components/SwipeableView/types'; import useConciergeAskState from '@hooks/useConciergeAskState'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import canUseKeyboardGestureArea from '@libs/canUseKeyboardGestureArea'; + +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import ReportFooter from '@pages/inbox/report/ReportFooter'; import CONST from '@src/CONST'; @@ -16,6 +21,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React from 'react'; +import {Keyboard} from 'react-native'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -24,6 +30,16 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockedReportActionCompose = jest.mocked(ReportActionCompose); + +jest.mock('@components/MerchantRuleSuggestionBanner', () => jest.fn(() => null)); + +jest.mock('@components/SwipeableView', () => jest.fn(({children}: SwipeableViewProps) => children)); +const mockedSwipeableView = jest.mocked(SwipeableView); + +jest.mock('@libs/canUseKeyboardGestureArea', () => jest.fn(() => true)); +const mockedCanUseKeyboardGestureArea = jest.mocked(canUseKeyboardGestureArea); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -40,7 +56,10 @@ jest.mock('@hooks/useConciergeAskState', () => ({ })); jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionCompose', () => { - const MockReportActionCompose = Object.assign(() => null, {EditOnly: () => null}); + const MockReportActionCompose = Object.assign( + jest.fn(() => null), + {EditOnly: () => null}, + ); return {__esModule: true, default: MockReportActionCompose}; }); @@ -94,6 +113,7 @@ describe('ReportFooter', () => { beforeEach(async () => { mockedUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportID: REPORT_ID}}); + mockedCanUseKeyboardGestureArea.mockReturnValue(true); await act(async () => { await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); }); @@ -104,6 +124,7 @@ describe('ReportFooter', () => { await Onyx.clear(); }); jest.clearAllMocks(); + jest.restoreAllMocks(); jest.mocked(useConciergeAskState).mockImplementation(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default); }); @@ -128,6 +149,44 @@ describe('ReportFooter', () => { expect(screen.getByText('Ask a new question')).toBeOnTheScreen(); }); + it.each([ + {name: 'regular chat', type: CONST.REPORT.TYPE.CHAT, fullSize: false, gestureSupported: true, usesLegacySwipe: false}, + {name: 'expanded chat', type: CONST.REPORT.TYPE.CHAT, fullSize: true, gestureSupported: true, usesLegacySwipe: true}, + {name: 'expense report', type: CONST.REPORT.TYPE.EXPENSE, fullSize: false, gestureSupported: true, usesLegacySwipe: true}, + {name: 'chat on older Android', type: CONST.REPORT.TYPE.CHAT, fullSize: false, gestureSupported: false, usesLegacySwipe: true}, + ])('preserves keyboard dismissal for $name', async ({type, fullSize, gestureSupported, usesLegacySwipe}) => { + // Given a writable report with its requested composer size. + mockedCanUseKeyboardGestureArea.mockReturnValue(gestureSupported); + mockLayout({isSmallScreenWidth: true, isInNarrowPaneModal: false}); + const dismissKeyboard = jest.spyOn(Keyboard, 'dismiss'); + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, { + accountID: 1, + email: 'tester@example.com', + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { + reportID: REPORT_ID, + type, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, fullSize); + }); + + // When the composer mounts. + renderReportFooter(); + await waitForBatchedUpdatesWithAct(); + + // Then the keyboard controller handles regular chat swipes while legacy layouts retain their dismissal callback. + expect(mockedReportActionCompose).toHaveBeenCalled(); + if (!usesLegacySwipe) { + expect(mockedSwipeableView).not.toHaveBeenCalled(); + return; + } + const swipeProps = mockedSwipeableView.mock.calls.at(-1)?.[0]; + expect(swipeProps).toBeDefined(); + swipeProps?.onSwipeDown(); + expect(dismissKeyboard).toHaveBeenCalledTimes(1); + }); + describe.each([ {name: 'archived', setUpReport: setUpArchivedReport, bannerText: 'This chat room has been archived.'}, {name: 'read-only', setUpReport: setUpReadOnlyReport, bannerText: 'This conversation is read-only.'}, diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 8857ed995216..b7d1c797a318 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -33,27 +33,38 @@ const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; type ScrollEvent = { nativeEvent: { contentOffset: {x: number; y: number}; + contentInset?: {bottom: number}; contentSize: {height: number; width: number}; layoutMeasurement: {height: number; width: number}; }; }; type CapturedListProps = { onScroll?: (event: ScrollEvent) => void; + onReady?: () => void; + onContentInsetChange?: () => void; }; let capturedListProps: CapturedListProps = {}; +let mockListContentLength: number | undefined; +let mockListScroll = 0; jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); jest.mock('@legendapp/list/react-native', () => { - const {forwardRef} = jest.requireActual('react'); + const {forwardRef, useEffect, useImperativeHandle} = jest.requireActual('react'); return { // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. // eslint-disable-next-line @typescript-eslint/no-unused-vars LegendList: forwardRef((props, ref) => { - capturedListProps = props; + useImperativeHandle(ref, () => ({ + getState: () => ({contentLength: mockListContentLength, scroll: mockListScroll, scrollLength: 500}), + scrollToEnd: jest.fn(), + })); + useEffect(() => { + capturedListProps = props; + }); return null; }), }; @@ -126,6 +137,8 @@ async function renderList(initialOffset: number) { beforeEach(async () => { capturedListProps = {}; + mockListContentLength = undefined; + mockListScroll = 0; mockUseUnreadMarker.mockClear(); mockUseMarkAsRead.mockClear(); setHasRadio(true); @@ -171,6 +184,47 @@ describe('ReportActionsList visible-action threshold', () => { expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); + it.each([ + {platform: 'iOS', bottomInset: 300, contentLength: undefined}, + {platform: 'Android', bottomInset: 0, contentLength: 1300}, + ])('keeps a reader above the newest message when the $platform keyboard extends the scroll range', async ({bottomInset, contentLength}) => { + // Given an open keyboard adds space beyond the native content height. + await renderList(0); + mockListContentLength = contentLength; + const event = createScrollEvent(THRESHOLD + 50); + event.nativeEvent.contentInset = {bottom: bottomInset}; + event.nativeEvent.contentOffset.y += 300; + + // When the reader is above the visible end even though the native content ends within the viewport. + act(() => capturedListProps.onScroll?.(event)); + + // Then unread tracking and mark-as-read preserve the reader's position. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); + }); + + it('updates both consumers when the keyboard inset settles without another scroll event', async () => { + // Given the keyboard inset leaves the last native scroll event above the end. + await renderList(0); + mockListContentLength = 1000 + THRESHOLD + 50; + mockListScroll = 500; + act(() => { + capturedListProps.onReady?.(); + capturedListProps.onScroll?.(createScrollEvent(0)); + }); + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); + + // When the keyboard closes and updates the list's insets without scrolling it. + mockListContentLength = 1000; + expect(capturedListProps.onContentInsetChange).toBeDefined(); + act(() => capturedListProps.onContentInsetChange?.()); + + // Then unread tracking and mark-as-read see the newest action again. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); + }); + it('updates both consumers when scrolling across the threshold', async () => { // Given a report initially at the newest action. await renderList(0); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index a9a10560373d..cdd1907fdcf9 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -548,6 +548,29 @@ describe('useReportActionsScroll', () => { platform.restore(); }); + it('keeps an aligned linked message within the keyboard-extended scroll range', async () => { + // Given a linked action is aligned beyond the native content's end while the keyboard is open. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 4700; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When native scrolling reports content without the keyboard inset that LegendList includes in its state. + act(() => + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 4400}, contentSize: {width: 300, height: 4700}, layoutMeasurement: {width: 300, height: 600}}, + }), + ), + ); + flushTransitions(); + + // Then the already clamped link does not trigger another positioning scroll. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + it('restores a linked message moved out of view by a late native scroll adjustment', async () => { // Given the linked message has already landed below a measured long predecessor. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index b11d21073c4b..4d32ebd27382 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -250,6 +250,33 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + it('keeps the pill hidden when keyboard updates move away from and back to the end in one batch', () => { + // Given a chat is at its newest message without an unread marker. + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: jest.fn(), + onTrackScrolling: jest.fn(), + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + }), + ); + + // When a keyboard inset and its matching scroll correction arrive before React renders again. + act(() => { + offsetRef.current = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD + 100; + result.current.trackVerticalScrolling(undefined); + offsetRef.current = 0; + result.current.trackVerticalScrolling(undefined); + }); + + // Then the last position wins, so the Latest messages pill stays hidden. + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + it('returns floatingMessage visibility as true when scrolling outside of threshold', () => { // Given const offsetRef = {current: 0};