diff --git a/package-lock.json b/package-lock.json index 8945f71935ee..20ae8830c43d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.3.10", + "@legendapp/list": "^3.6.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11690,9 +11690,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.3.10", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.10.tgz", - "integrity": "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.6.0.tgz", + "integrity": "sha512-ndGQjSYdA32LWErJYbXEm4+eta+4bo0NxQO7eA3Xcvh0rXSb1mTGPbUtUkpea5w4MYciLKaE+0hoPxlMcaMtrw==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index a2b6bca970cd..4d4b2fea6a2a 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.3.10", + "@legendapp/list": "^3.6.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch b/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch new file mode 100644 index 000000000000..c3d167d4c3a2 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 0b730ec..9e14f1d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -6097,4 +6097,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + // src/components/Containers.native.tsx ++// Fabric positions and the MVCP anchor update through React. Commit the content ++// size with them so an imperative Animated update cannot clamp the old viewport. ++var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { ++ const [size = 0] = useArr$(["totalSize"]); ++ return size; ++} : function useContainersSizeAnimated() { ++ return useValue$("totalSize"); ++}; + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6102,7 +6110,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const animSize = useValue$("totalSize"); ++ const animSize = useContainersSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); + const style = horizontal ? { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index a3d8d9a..1f2bcf9 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -6076,4 +6076,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + // src/components/Containers.native.tsx ++// Fabric positions and the MVCP anchor update through React. Commit the content ++// size with them so an imperative Animated update cannot clamp the old viewport. ++var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { ++ const [size = 0] = useArr$(["totalSize"]); ++ return size; ++} : function useContainersSizeAnimated() { ++ return useValue$("totalSize"); ++}; + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6081,7 +6089,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const animSize = useValue$("totalSize"); ++ const animSize = useContainersSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); + const style = horizontal ? { diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch b/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch new file mode 100644 index 000000000000..329f3102e456 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch @@ -0,0 +1,68 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 9e14f1d..0822107 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1544,6 +1544,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + } + return 0; + } ++// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the ++// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. ++function getAndroidNativeClampedAdjust(ctx, positionDiff) { ++ const state = ctx.state; ++ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function getProgressTowardAmount(targetDelta, nativeDelta) { + return targetDelta < 0 ? -nativeDelta : nativeDelta; + } +@@ -1770,6 +1783,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { ++ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 1f2bcf9..5cc805b 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1523,6 +1523,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + } + return 0; + } ++// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the ++// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. ++function getAndroidNativeClampedAdjust(ctx, positionDiff) { ++ const state = ctx.state; ++ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function getProgressTowardAmount(targetDelta, nativeDelta) { + return targetDelta < 0 ? -nativeDelta : nativeDelta; + } +@@ -1749,6 +1762,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { ++ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md new file mode 100644 index 000000000000..262148fc96ce --- /dev/null +++ b/patches/@legendapp/list/details.md @@ -0,0 +1,23 @@ +# `@legendapp/list` patches + +### [@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch](@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch) + +- Reason: On Android, the entire report-action list can briefly jump to older rows while item measurements settle. LegendList updates its content size through an imperative `Animated.Value`, while Fabric row positions and the scroll-preservation anchor update through React. An early content-size update can clamp the old viewport before the matching positions mount. Read the content size through the same React store on the New Architecture so these layout changes commit together. +- Scope: Both native module formats use the synchronized size on the New Architecture. The legacy architecture retains its animated size updates, and the web entries are unchanged. Native scroll preservation and the initial loading skeleton remain enabled. +- Upstream PR/issue: Fixed upstream in https://github.com/LegendApp/legend-list/commit/a2d20a9b, which isn't in a release yet. +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-5886543887 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: Open an expense chat containing a report preview and messages with different heights. Wait for the actual rows to replace the skeleton. The highlighted preview should stay visible while measurements settle, without a frame displaying older rows. Also scroll through older history and return to the latest messages to check scroll preservation. + +### [@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch](@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch) + +- Reason: On Android, a chat that opens at its newest messages can briefly show older messages before it snaps back to the end. When rows above the viewport get their real, smaller heights, the content shrinks. Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the full shrink again. The list stays above the end by that amount until the next scroll to the end. Account for Android's clamp and only move the anchor by what the clamp doesn't cover. +- Scope: Only item-size scroll preservation on Android, and only when the content shrinks below the current offset. Data changes and active scroll targets already handle the clamp and are unchanged. iOS and web are unchanged. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/582 upstreams this patch. https://github.com/LegendApp/legend-list/pull/481 covers the related bottom-inset case. Keep this patch until the installed LegendList release includes this fix. +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6287555197 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: In a DM, mark the third-newest message as unread and go back to the inbox. Open the DM again. It should open at the newest messages without a frame showing older messages. Also scroll through older history and send a new message to check scroll preservation and end following. + +### Related upstream work + +- https://github.com/LegendApp/legend-list/pull/583 refreshes the cached end-follow state when `maintainScrollAtEndThreshold` changes. This supports a fixed pixel follow distance across viewport changes when using LegendList's built-in end following. diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 5337a186ce8a..b2b600758941 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -9,10 +9,15 @@ ### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch) -- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements: +- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch: 1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates 2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position -- Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 + 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink. LegendList 3.6 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. +- Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. +- Upstream PR/issue: https://github.com/react/react-native/pull/57959 covers z-index anchor selection. The follow-up guard preserving LegendList's positioned, zero-sized anchor is proposed in https://github.com/react/react-native/pull/57959#issuecomment-6341492122. +- Reproducer: https://github.com/react/react-native/pull/59360 adds the RNTester reproduction, without a runtime fix. The standalone reproduction and proposed anchor patch are in https://github.com/margelo/react-native-mvcp-reproducer. +- Earlier proposal: https://github.com/react/react-native/pull/46247 closed without merging. +- Removal: Drop this patch only when the adopted React Native release preserves all three anchoring behaviors described above. - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch index 223cce3db9b3..90b705c6df69 100644 --- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch +++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644 for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) -@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper( +@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height ++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor. ++ // Preserve it instead of choosing the container that holds all rendered items. ++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) { ++ firstVisibleView = child ++ break ++ } ++ // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644 } + val frame = Rect() + firstVisibleView.getHitRect(frame) -+ if (frame.width() > 0 || frame.height() > 0) { ++ // Zero-sized anchors have a meaningful position even though they have no area. ++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) { + prevFirstVisibleFrame = frame + } else { + prevFirstVisibleFrame = null diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx deleted file mode 100644 index bc11ccf61296..000000000000 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx deleted file mode 100644 index b343f95b8be1..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; - -import type {FlashListProps} from '@shopify/flash-list'; - -import React from 'react'; - -import FlashList from '..'; -import CellRendererComponent from './CellRendererComponent'; - -type InvertedFlashListProps = FlashListProps & { - data: T[]; - keyExtractor: (item: T, index: number) => string; - - /** Ref to the underlying list instance. */ - ref: FlatListRefType; -}; - -function InvertedFlashList(props: InvertedFlashListProps) { - return ( - - {...props} - inverted - CellRendererComponent={CellRendererComponent} - /> - ); -} - -export default InvertedFlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index e98507c9a3bd..f9a7b9528735 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; import React from 'react'; function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts deleted file mode 100644 index cf7718d3d148..000000000000 --- a/src/components/FlashList/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; - -/** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; - -export default FlatListRefType; diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..05bfe439c045 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -26,30 +26,21 @@ function CustomFlatList({ }: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const styles = useThemeStyles(); - const handleScrollBegin = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollBegin?.(event); - setIsScrolling(true); - }, - [onMomentumScrollBegin], - ); + const handleScrollBegin = (event: NativeSyntheticEvent) => { + onMomentumScrollBegin?.(event); + setIsScrolling(true); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - setIsScrolling(false); - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + setIsScrolling(false); + }; - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + emitComposerScrollEvents(); + }; const listRef = useRef | null>(null); useFlatListHandle({ diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fc35d965553e..c50c521eee01 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -223,7 +223,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 043dba4d39db..effaefefceaa 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -8,10 +8,10 @@ import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-re import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext'; -function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { +function KeyboardDismissibleFlatList({onScroll: onScrollProp, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/components/LegendList/allowLegendListItemOverflow.ts b/src/components/LegendList/allowLegendListItemOverflow.ts new file mode 100644 index 000000000000..a222d6d81055 --- /dev/null +++ b/src/components/LegendList/allowLegendListItemOverflow.ts @@ -0,0 +1,19 @@ +import {isRecord} from '@libs/ObjectUtils'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +/** + * LegendList uses paint containment for web item containers, which clips descendants that render + * outside their row. Remove only paint containment so inline menus can overflow while layout and + * style containment keep the rest of the row isolated. + */ +function allowLegendListItemOverflow(list: LegendListRef | null, index: number): void { + const itemContainer: unknown = list?.getState().elementAtIndex(index); + if (!isRecord(itemContainer) || !isRecord(itemContainer.style) || typeof itemContainer.style.contain !== 'string') { + return; + } + + itemContainer.style.contain = 'layout style'; +} + +export default allowLegendListItemOverflow; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 49823c8253a6..2becf033932a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -232,6 +232,20 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon fullPolicySnapshot: report?.policyID && fullPolicySnapshotLastModified ? {policyID: report.policyID, lastModified: fullPolicySnapshotLastModified} : undefined, }); + const [editScrollRequestID, setEditScrollRequestID] = useState(0); + const completedEditScrollRequestIDRef = useRef(0); + const requestScrollToNewestAfterEdit = () => { + // Force a layout commit even when closing the editor leaves the row's height unchanged. + setEditScrollRequestID((requestID) => requestID + 1); + }; + const onCommitLayoutEffect = () => { + if (editScrollRequestID <= completedEditScrollRequestIDRef.current) { + return; + } + completedEditScrollRequestIDRef.current = editScrollRequestID; + scrollToBottom(); + }; + // Wait for the actions to arrive before jumping, and clear the route param afterwards so a later remount // doesn't yank the user back down. useEffect(() => { @@ -251,7 +265,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const isNewestReportAction = indexWithinReportActions === visibleReportActions.length - 1; return ( - + } /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 23ea750eefb8..cb7daa9911ba 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,5 +1,4 @@ import LinkButton from '@components/Button/composed/LinkButton'; -import type FlatListRefType from '@components/FlashList/types'; import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -18,6 +17,8 @@ import {getTransactionPendingAction} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; + import {resolveLayoutSpacing} from '@styles/layoutSpacing'; import CONST from '@src/CONST'; @@ -128,7 +129,7 @@ type MoneyRequestReportTransactionListProps = { linkedReportActionID: string | undefined; /** Ref forwarded to the underlying FlashList. */ - listRef: FlatListRefType; + listRef: ActionListRefType; /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ onLastItemIndexChange?: (index: number) => void; @@ -139,6 +140,9 @@ type MoneyRequestReportTransactionListProps = { /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ onListLayout: (event: LayoutChangeEvent) => void; + /** Called after FlashList finishes measuring and committing its rows. */ + onCommitLayoutEffect?: () => void; + /** FlashList onScroll callback. */ onScroll: (event: NativeSyntheticEvent) => void; @@ -189,6 +193,7 @@ function MoneyRequestReportTransactionList({ onLastItemIndexChange, accessibilityLabel, onListLayout, + onCommitLayoutEffect, onScroll, onScrollBeginDrag, onContentSizeChange, @@ -485,6 +490,7 @@ function MoneyRequestReportTransactionList({ onLastItemIndexChange={onLastItemIndexChange} accessibilityLabel={accessibilityLabel} onLayout={onListLayout} + onCommitLayoutEffect={onCommitLayoutEffect} onScroll={onScroll} onScrollBeginDrag={onScrollBeginDrag} onContentSizeChange={onContentSizeChange} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index a1c1a466fc5c..44a681fdab31 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,8 +1,8 @@ import FlashList from '@components/FlashList'; -import type FlatListRefType from '@components/FlashList/types'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditingReportAction'; import variables from '@styles/variables'; @@ -48,15 +48,13 @@ function unifiedListItemType(item: UnifiedListItem) { } type MoneyRequestReportFlashListProps = FlashListProps & { - /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ - ref: FlatListRefType; + /** Ref to the underlying list, shared via the common ActionList imperative API. */ + ref: ActionListRefType; }; /** - * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based - * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the - * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is - * needed. The scroll manager relies on the FlashList registering into this slot. + * Forwards the shared ActionList ref through @components/FlashList to the underlying list. + * FlashList implements the common imperative API consumed by the report scroll manager. */ function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { return ( @@ -93,13 +91,16 @@ type MoneyRequestReportUnifiedListProps = { newTransactionID?: string; /** Ref to the underlying list, shared via the ActionList context. */ - listRef: FlatListRefType; + listRef: ActionListRefType; accessibilityLabel: string; /** Called when the list lays out. */ onLayout: (event: LayoutChangeEvent) => void; + /** Called after FlashList finishes measuring and committing its rows. */ + onCommitLayoutEffect?: () => void; + onScroll: (event: NativeSyntheticEvent) => void; /** Called when the user starts dragging the list. */ @@ -142,6 +143,7 @@ function MoneyRequestReportUnifiedList({ listRef, accessibilityLabel, onLayout, + onCommitLayoutEffect, onScroll, onScrollBeginDrag, onContentSizeChange, @@ -359,6 +361,7 @@ function MoneyRequestReportUnifiedList({ maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}} onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} + onCommitLayoutEffect={onCommitLayoutEffect} onEndReached={onEndReached} onEndReachedThreshold={0.75} onStartReached={onStartReached} diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts index 886dca0b06d2..beb3ebd786d3 100644 --- a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts @@ -197,7 +197,7 @@ function useMoneyRequestReportScroll({ resetKey, }); - // The indicator renders in the list footer, below the row scrollToBottom targets, so only scrollToEnd reveals it + // The indicator is below the last row, so use the manager's full-list scroll to include the footer. const {candidateAgentIDs} = useAgentZeroStatus(); const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; // Scroll once per appearance, because the label changes many times per run and re-firing would yank the viewport @@ -214,7 +214,7 @@ function useMoneyRequestReportScroll({ // Wait for the footer to lay out, otherwise there is nothing to scroll to yet const timeoutID = setTimeout(() => { - reportScrollManager.scrollToEnd(); + reportScrollManager.scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); return () => clearTimeout(timeoutID); diff --git a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx index 27171c3e183d..84f9189259f4 100644 --- a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx +++ b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError'; import useThemeStyles from '@hooks/useThemeStyles'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; -import React, {useState} from 'react'; import {View} from 'react-native'; import type LocalPDFReceiptPreviewProps from './types'; @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true}; function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) { const styles = useThemeStyles(); - const [failedToLoad, setFailedToLoad] = useState(false); - const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined); - const [pageAspectRatio, setPageAspectRatio] = useState(undefined); + const [failedToLoad, setFailedToLoad] = useReportActionItemState(false); + const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined); + const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState(undefined); const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => { pdf.getPage(1) diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index df6582ee17b5..ccc4f0311263 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -5,6 +5,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import variables from '@styles/variables'; import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize'; @@ -125,7 +127,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure // Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh), // mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render. - const [failedURL, setFailedURL] = useState(null); + const [failedURL, setFailedURL] = useReportActionItemState(null); const hasFailed = failedURL !== null && failedURL === fileURL; // The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 3f0bb956114f..8e5eb5de0693 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -105,7 +105,9 @@ function ThumbnailImage({ const failedToLoad = failedLoadKey !== null && failedLoadKey.url === previewSourceURL && failedLoadKey.isOffline === isOffline; const cachedDimensions = shouldDynamicallyResize && typeof previewSourceURL === 'string' ? thumbnailDimensionsCache.get(previewSourceURL) : null; - const [imageDimensions, setImageDimensions] = useState({width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}); + const [measuredDimensions, setMeasuredDimensions] = useState<{url: string | ImageSourcePropType; width: number; height: number} | null>(null); + const imageDimensions = + measuredDimensions?.url === previewSourceURL ? measuredDimensions : {width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}; const {thumbnailDimensionsStyles} = useThumbnailDimensions(imageDimensions.width, imageDimensions.height); const StyleUtils = useStyleUtils(); @@ -118,7 +120,7 @@ function ThumbnailImage({ thumbnailDimensionsCache.set(previewSourceURL, {width, height}); } - setImageDimensions({width, height}); + setMeasuredDimensions({url: previewSourceURL, width, height}); }; const sizeStyles = shouldDynamicallyResize ? [thumbnailDimensionsStyles] : [styles.w100, styles.h100]; diff --git a/src/hooks/useEmitComposerScrollEvents/index.ts b/src/hooks/useEmitComposerScrollEvents/index.ts index ba65bfcdcf5e..cfd42ee98773 100644 --- a/src/hooks/useEmitComposerScrollEvents/index.ts +++ b/src/hooks/useEmitComposerScrollEvents/index.ts @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native'; type UseEmitComposerScrollEventsOptions = { enabled?: boolean; - inverted: boolean | null | undefined; }; /** * This is used to trigger scroll behavior in the composer on web. On native, this is a no-op. - * The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer. * Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`, * we need to emit the scroll events inside the scroll handler of the specific implementation. - * @param inverted - Whether the list is inverted. * @returns A function that can be used to emit the scroll events. */ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) { - const {enabled = true, inverted} = options ?? {}; + const {enabled = true} = options ?? {}; const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * invokes the onScroll callback function from props. */ const onScroll = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * Emits when the scrolling has ended. */ const onScrollEnd = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * */ const emitComposerScrollEvents = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts new file mode 100644 index 000000000000..b8a77f24856d --- /dev/null +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -0,0 +1,156 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; + +import CONST from '@src/CONST'; + +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; +import {Platform} from 'react-native'; + +import useReportScrollManager from './useReportScrollManager'; + +type LinkedReportActionScrollParams = { + listID: string; + linkedReportActionID: string | undefined; + targetIndex: number; + isLastAction: boolean; +}; + +/** Keeps a linked action positioned as its measured layout settles, until the reader takes over scrolling. */ +function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, isLastAction}: LinkedReportActionScrollParams) { + const isFocused = useIsFocused(); + const {getListRef} = useActionListContext(); + const reportScrollManager = useReportScrollManager(); + const [readyListID, setReadyListID] = useState(); + const [measurementVersion, setMeasurementVersion] = useState(0); + const shouldPositionRef = useRef(false); + const wasLastActionRef = useRef(false); + const cancelPositionRef = useRef<(() => void) | undefined>(undefined); + + const stopPositioning = () => { + shouldPositionRef.current = false; + cancelPositionRef.current?.(); + }; + const stopPositioningFromEffect = useEffectEvent(stopPositioning); + + useEffect(() => { + shouldPositionRef.current = !!linkedReportActionID; + wasLastActionRef.current = false; + return stopPositioningFromEffect; + }, [linkedReportActionID, listID]); + + useEffect(() => { + if (!isFocused) { + return; + } + + // Returning from a child screen must keep the reader's viewport, not reopen the old link. + return stopPositioningFromEffect; + }, [isFocused]); + + const positionLinkedAction = useEffectEvent(() => { + if (!shouldPositionRef.current || targetIndex < 0) { + return; + } + // Once a reply follows a linked final action, normal live-tail following owns the viewport. + if (wasLastActionRef.current && !isLastAction) { + shouldPositionRef.current = false; + return; + } + wasLastActionRef.current = isLastAction; + if (Platform.OS === 'android' && !isLastAction && linkedReportActionID) { + const state = getListRef()?.current?.getState?.(); + const position = state?.positionByKey(linkedReportActionID); + if (position !== undefined && state?.scroll !== undefined && state.contentLength !== undefined && state.scrollLength !== undefined) { + const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, state.contentLength - state.scrollLength)); + // LegendList includes pending native anchoring in its scroll offset. An imperative + // scroll to that same offset would apply the height correction twice on Android. + if (Math.abs(targetOffset - state.scroll) <= 1) { + return; + } + } + } + reportScrollManager.scrollToIndex( + targetIndex, + isLastAction ? {animated: false, viewPosition: 1} : {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}, + ); + }); + + useEffect(() => { + if (!isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current) { + return; + } + + let frame: number | undefined; + // Row size notifications precede LegendList's position recalculation. Wait for that layout + // and the navigation transition before using the measured target position. + const transition = TransitionTracker.runAfterTransitions({ + callback: () => { + frame = requestAnimationFrame(positionLinkedAction); + }, + }); + const cancel = () => { + transition.cancel(); + if (frame !== undefined) { + cancelAnimationFrame(frame); + } + }; + cancelPositionRef.current = cancel; + return cancel; + }, [isFocused, linkedReportActionID, listID, readyListID, targetIndex, isLastAction, measurementVersion]); + + useEffect(() => { + if (Platform.OS !== 'web' || readyListID !== listID) { + return; + } + const scrollElement = getListRef()?.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) { + return; + } + const onScrollKey = (event: KeyboardEvent) => { + if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { + return; + } + stopPositioningFromEffect(); + }; + scrollElement.addEventListener('wheel', stopPositioningFromEffect, {passive: true}); + scrollElement.addEventListener('pointerdown', stopPositioningFromEffect, {passive: true}); + scrollElement.addEventListener('keydown', onScrollKey); + return () => { + scrollElement.removeEventListener('wheel', stopPositioningFromEffect); + scrollElement.removeEventListener('pointerdown', stopPositioningFromEffect); + scrollElement.removeEventListener('keydown', onScrollKey); + }; + }, [getListRef, listID, readyListID]); + + const onReady = () => setReadyListID(listID); + const onScroll = (event: NativeSyntheticEvent) => { + if (!shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction) { + return; + } + const position = getListRef()?.current?.getState?.()?.positionByKey(linkedReportActionID); + if (position === undefined) { + return; + } + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, contentSize.height - layoutMeasurement.height)); + // 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. + if (Math.abs(targetOffset - contentOffset.y) > 1) { + setMeasurementVersion((version) => version + 1); + } + }; + const onItemSizeChanged = ({index, size, previous}: {index: number; size: number; previous: number}) => { + if (!shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous) { + return; + } + setMeasurementVersion((version) => version + 1); + }; + + return {onReady, onItemSizeChanged, onScroll, stopPositioning}; +} + +export default useLinkedReportActionScroll; diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 6de80a82b8de..4b7331487bf1 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -12,6 +12,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; import {useIsAppLoadPending} from './useInFlightRequests'; +import useInitial from './useInitial'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; @@ -55,8 +56,11 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, { selector: reportActionsListLoadingStateSelector, }); - const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; + // This flag is monotonic for one report visit. Keep it true if the RAM-only entry is briefly + // absent while optimistic writes are merged, so the hydrated list never returns to its loading state. + const hasOnceLoadedReportActions = useInitial(reportLoadingState?.hasOnceLoadedReportActions ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; @@ -149,7 +153,12 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + hasOlderActions, hasNewerActions, + oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 76fff4bcf9b8..12c0f09b830b 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -32,14 +32,17 @@ import {fullPolicySnapshotLastModifiedSelector} from '@selectors/Policy'; import {useEffect, useEffectEvent, useState} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLinkedReportActionScroll from './useLinkedReportActionScroll'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { + /** Identifies the mounted list, including linked-message and hydration remounts. */ + listID: string; + /** The Concierge chat report */ conciergeChat: OnyxEntry; @@ -57,15 +60,13 @@ type UseReportActionsScrollParams = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** Actions actually rendered by the list (may include a synthetic draft), used for mount scroll positioning */ + /** Actions actually rendered by the list in chronological order (may include a synthetic draft), used for scroll positioning */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Extracts the list key for an action; used to locate the initial scroll target */ keyExtractor: (item: OnyxTypes.ReportAction) => string; /** Whether the user has scrolled past the "visible" threshold */ - hasScrolledOverThreshold: boolean; - /** Marks the newest action as read and clears any pending skipped mark-as-read */ markNewestActionAsRead: () => void; @@ -81,9 +82,6 @@ type UseReportActionsScrollParams = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; - /** Stable key that changes when a streamed concierge draft becomes visible, used to trigger autoscroll */ - draftAutoScrollKey: string; - /** The index of the action badge target in the rendered actions list (-1 if none) */ actionBadgeTargetIndex: number; @@ -114,32 +112,27 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout and content size change */ - flushPendingScrollToBottom: () => void; - /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; - /** Whether the list should focus to the visual top on mount */ - shouldFocusToTopOnMount: boolean; - - /** The initial scroll target key for the list */ - initialScrollKey: string | undefined; - - /** maintainVisibleContentPosition config for the inverted list */ - maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - /** onLoad handler that disables autoscroll-to-top once the initial render settles */ + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; + + onItemSizeChanged: (info: {index: number; size: number; previous: number}) => void; + + trackLinkedMessageScroll: (event: NativeSyntheticEvent) => void; + + stopLinkedMessagePositioning: () => void; }; function useReportActionsScroll({ + listID, conciergeChat, reportID, report, @@ -148,13 +141,11 @@ function useReportActionsScroll({ sortedVisibleReportActions, renderedVisibleReportActions, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -162,7 +153,6 @@ function useReportActionsScroll({ }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useActionListContext(); - const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const backTo = route?.params?.backTo; @@ -210,18 +200,8 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); - const maintainVisibleContentPosition = { - disabled: !shouldMaintainVisibleContentPosition, - // Focus-to-top mode: once autoscroll is released, keep the threshold at 0 rather than - // removing it — FlashList only clears its pending-autoscroll flag while threshold >= 0, - // otherwise the next content change (e.g. mark-as-unread) scrolls back to top. - ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), - }; - const { isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, @@ -236,7 +216,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, // The list is hidden behind the expanded composer, so scrolling it must not bring the Latest messages pill back shouldDisablePillTracking: shouldDisablePillTracking || isComposerFullSize, onTrackScrolling: (event: NativeSyntheticEvent) => { @@ -272,7 +252,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -282,57 +262,16 @@ function useReportActionsScroll({ fullPolicySnapshot, }); - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${reportID}:${linkedReportActionID}`, - }); - - const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - + // Consume an explicit live-tail request after the data render, not on an unrelated future + // viewport layout (for example, opening the keyboard after the user has scrolled away). useEffect(() => { - if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { - return; - } - - if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (!isScrollToBottomEnabled) { return; } - - setIsFloatingMessageCounterVisible(false); - requestAnimationFrame(() => { - reportScrollManager.scrollToBottom(); - }); - }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - - const scheduleInitialScrollToBottom = useEffectEvent(() => { - if (initialScrollKey) { - return undefined; - } - - return TransitionTracker.runAfterTransitions({ - callback: () => { - if (shouldFocusToTopOnMount) { - return; - } - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - }, - waitForUpcomingTransition: true, - }); - }); - - // The initial scroll-to-bottom must be scheduled exactly once, on mount; re-running it as deps change would yank the user back down while they read history. - useEffect(() => { - const handle = scheduleInitialScrollToBottom(); - return () => handle?.cancel(); - }, []); + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // Clear the shouldScrollToLatest route param once the mount scroll above has consumed it, so a later remount of // this report doesn't pull the user down again. MoneyRequestReportActionsList clears it the same way for the @@ -391,7 +330,52 @@ function useReportActionsScroll({ return () => handle?.cancel(); }, [lastAction]); + // Decide where the list should be positioned on mount. + // 1. If we're opening a linked or unread message, find that action in the chronological list. + // 2. Otherwise, aligned-to-top reports start at the first action. + const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; + let initialScrollIndex: number | undefined; + let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; + if (targetIndex >= 0) { + initialScrollIndex = targetIndex; + // A linked final action can be taller than the viewport, so show its highlighted end. + // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so + // LegendList includes the footer (for example, Concierge's thinking indicator). + initialScrollIndexParams = + initialScrollKey === linkedReportActionID && targetIndex === renderedVisibleReportActions.length - 1 + ? {viewPosition: 1} + : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + } else if (shouldFocusToTopOnMount) { + initialScrollIndex = 0; + } + + const { + onReady, + onItemSizeChanged, + onScroll: trackLinkedMessageScroll, + stopPositioning: stopLinkedMessagePositioning, + } = useLinkedReportActionScroll({ + listID, + linkedReportActionID: initialScrollKey === linkedReportActionID ? linkedReportActionID : undefined, + targetIndex, + isLastAction: targetIndex === renderedVisibleReportActions.length - 1, + }); + + useScrollToEndOnNewMessageReceived({ + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: sortedVisibleReportActions.length, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: () => { + stopLinkedMessagePositioning(); + reportScrollManager.scrollToBottom(); + }, + resetKey: `${reportID}:${linkedReportActionID}`, + }); + const scrollToBottomAndMarkReportAsRead = () => { + stopLinkedMessagePositioning(); setIsFloatingMessageCounterVisible(false); if (unreadMarkerReportActionIndex >= 0) { @@ -427,71 +411,26 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } + stopLinkedMessagePositioning(); // The expanded composer covers the list, so the target never becomes viewable to hide the pill if (isComposerFullSize) { setIsActionBadgeAboveViewport(false); setIsFloatingMessageCounterVisible(false); } // `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place. - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); - }; - - const flushPendingScrollToBottom = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(false); - completeLiveTailPruneAfterScrollToBottom(); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; - // Data is ready at the moment FlashList finishes its first render. const onLoad = () => { - if (shouldDisablePillTracking) { - // Wait one frame so the initial positioning can settle, then disable it. - requestAnimationFrame(() => { - setShouldDisablePillTracking(false); - updatePillVisibility(); - }); - } - if (!shouldFocusToTopOnMount) { + onReady(); + if (!shouldDisablePillTracking) { return; } - if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { - return; - } - // Wait one frame so the initial autoscroll-to-top can settle, then disable it. - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); + requestAnimationFrame(() => { + setShouldDisablePillTracking(false); + updatePillVisibility(); + }); }; - const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); - - // Data finished initial loading after the list mounted. onLoad has already fired, so we need - // a separate trigger to turn off autoscroll-to-top. - useEffect(() => { - if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { - return; - } - if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); - - // Decide where the list should be positioned on mount. - // 1. If we're opening a linked message (initialScrollKey), find that action in the list and scroll it to the top - // of the viewport (viewPosition: 1) with a small offset so the message above is partly visible. - // 2. Otherwise, if the report should be opened at top (ex: for transaction threads), scroll to the top message and offset by - // the window height so we land at top of the top message for sure. - const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; - let initialScrollIndex: number | undefined; - let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - if (targetIndex > 0) { - initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; - } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; - } return { trackVerticalScrolling, @@ -500,14 +439,13 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, + onItemSizeChanged, + trackLinkedMessageScroll, + stopLinkedMessagePositioning, }; } diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 756ae8fc3b76..8c9dea34397a 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,9 +1,5 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; -import type {ComponentRef} from 'react'; -// eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; - import type {ReportScrollManagerData, ScrollToIndexOptions} from './types'; function useReportScrollManager(): ReportScrollManagerData { @@ -22,35 +18,13 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); - }; - - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - - const scrollViewRef = listRef.current.getNativeScrollRef?.() as ComponentRef | undefined; - // Try to scroll on underlying scrollView if available, fallback to usual listRef - if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { - scrollViewRef.scrollToEnd({animated: false}); - return; - } - listRef.current.scrollToEnd({animated: false}); }; @@ -62,7 +36,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({offset, animated: false}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..9ff4bafbcc4e 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,28 +18,13 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); - }; - - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - listRef.current.scrollToEnd({animated: false}); }; @@ -52,7 +37,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({animated: true, offset}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index c7649ecc334a..01587bf2f60e 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -18,7 +18,6 @@ type ReportScrollManagerData = { */ scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; scrollToBottom: () => void; - scrollToEnd: () => void; scrollToOffset: (offset: number) => void; }; diff --git a/src/libs/Notification/notificationPermission/index.native.ts b/src/libs/Notification/notificationPermission/index.native.ts index 29123ad37089..bd0fe625309e 100644 --- a/src/libs/Notification/notificationPermission/index.native.ts +++ b/src/libs/Notification/notificationPermission/index.native.ts @@ -1,6 +1,7 @@ import type NotificationPermissionModule from './types'; const NotificationPermissionNative: NotificationPermissionModule = { + getStatusSync: () => 'denied', getStatus: () => Promise.resolve('denied'), request: () => Promise.resolve('denied'), }; diff --git a/src/libs/Notification/notificationPermission/index.ts b/src/libs/Notification/notificationPermission/index.ts index 62f333a376f4..333fecf4bf54 100644 --- a/src/libs/Notification/notificationPermission/index.ts +++ b/src/libs/Notification/notificationPermission/index.ts @@ -10,12 +10,17 @@ function toStatus(permission: NotificationPermission): NotificationPermissionSta return 'default'; } +function getStatusSync(): NotificationPermissionStatus { + if (typeof window === 'undefined' || !window.Notification) { + return 'denied'; + } + return toStatus(Notification.permission); +} + const NotificationPermission: NotificationPermissionModule = { + getStatusSync, getStatus(): Promise { - if (typeof window === 'undefined' || !window.Notification) { - return Promise.resolve('denied'); - } - return Promise.resolve(toStatus(Notification.permission)); + return Promise.resolve(getStatusSync()); }, request(): Promise { diff --git a/src/libs/Notification/notificationPermission/types.ts b/src/libs/Notification/notificationPermission/types.ts index 9ffca38417ae..b6c834f2ed36 100644 --- a/src/libs/Notification/notificationPermission/types.ts +++ b/src/libs/Notification/notificationPermission/types.ts @@ -1,6 +1,7 @@ type NotificationPermissionStatus = 'granted' | 'denied' | 'default'; type NotificationPermissionModule = { + getStatusSync: () => NotificationPermissionStatus; getStatus: () => Promise; request: () => Promise; }; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0958bee7ac74..0e797c874321 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5593,7 +5593,7 @@ function showReportActionNotification( LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName, formatPhoneNumber); } - notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID); + notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID, 'realtime'); } /** Clear the errors associated with the IOUs of a given report. */ diff --git a/src/libs/actions/Report/reportActionSubscribers.ts b/src/libs/actions/Report/reportActionSubscribers.ts index 8fc4644ac0eb..66b78ef98872 100644 --- a/src/libs/actions/Report/reportActionSubscribers.ts +++ b/src/libs/actions/Report/reportActionSubscribers.ts @@ -3,7 +3,8 @@ */ import type ReportAction from '@src/types/onyx/ReportAction'; -type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined) => void; +type ActionEventSource = 'local' | 'realtime'; +type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined, source: ActionEventSource) => void; type ActionSubscriber = { reportID: string; @@ -26,7 +27,7 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac } /** Notify the ReportActionsList that a new comment has arrived */ -function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { +function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean, source: ActionEventSource = 'local') { if (!reportID) { return; } @@ -34,9 +35,10 @@ function notifyNewAction(reportID: string | string[] | undefined, reportAction: for (const id of ids) { const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === id); if (actionSubscriber) { - actionSubscriber.callback(isFromCurrentUser, reportAction); + actionSubscriber.callback(isFromCurrentUser, reportAction, source); } } } export {subscribeToNewActionEvent, notifyNewAction}; +export type {ActionEventSource}; diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index bcefb4bdc200..08e9b6ca7566 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -12,16 +12,14 @@ import setTextInputSelection from './setTextInputSelection'; /** * Create a function that focuses the composer. + * A getter resolves the current input after the delay, since editing can replace it during layout. * @param [canFocusAfterDelay] Re-checked when the delay is up, so a composer that lost its focus claim meanwhile does not take focus back */ -function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY, canFocusAfterDelay?: () => boolean): FocusComposerWithDelay { - function getIsFocused() { - if (textInput && 'isFocused' in textInput) { - return textInput.isFocused(); - } - return false; - } - +function focusComposerWithDelay( + textInput: InputType | null | (() => InputType | null), + delay: number = CONST.COMPOSER_FOCUS_DELAY, + canFocusAfterDelay?: () => boolean, +): FocusComposerWithDelay { /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input @@ -35,8 +33,12 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON return; } - function focusAndUpdateSelection(input: InputType) { - if (getIsFocused()) { + function focusAndUpdateSelection() { + const input = typeof textInput === 'function' ? textInput() : textInput; + if (!input) { + return; + } + if ('isFocused' in input && input.isFocused()) { if (forceKeyboardIfAlreadyFocused) { requestKeyboardForFocusedComposer(input, forcedSelectionRange); } @@ -50,23 +52,19 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON } if (!shouldDelay) { - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); return; } await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]); - if (!textInput) { - return; - } - // When the closing modal has a focused text input focus() needs a delay to properly work. // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. setTimeout(() => { if (canFocusAfterDelay && !canFocusAfterDelay()) { return; } - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); }, delay); }; } diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..f620b9e53724 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,10 +1,10 @@ -import type FlatListRefType from '@components/FlashList/types'; - import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; +import type ActionListRefType from './ActionListTypes'; +import type {ActionListRef} from './ActionListTypes'; + type ActionListContextType = { scrollOffsetRef: RefObject; @@ -12,10 +12,10 @@ type ActionListContextType = { getScrollOffset: () => number; /** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */ - registerListRef: (ref: FlatListRefType) => void; + registerListRef: (ref: ActionListRefType) => void; /** Reads the currently registered list ref. Call from handlers only, never during render. */ - getListRef: () => FlatListRefType; + getListRef: () => ActionListRefType; }; const ActionListContext = createContext({ @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); @@ -49,7 +49,7 @@ function useActionListRef() { function ActionListContextProvider({children}: {children: ReactNode}) { // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. - const listRefHolder = useRef(null); + const listRefHolder = useRef(null); const scrollOffsetRef = useRef(0); const value: ActionListContextType = { diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts new file mode 100644 index 000000000000..a890d2947e28 --- /dev/null +++ b/src/pages/inbox/ActionListTypes.ts @@ -0,0 +1,34 @@ +import type {RefObject} from 'react'; + +type ScrollToIndexParams = { + animated?: boolean; + index: number; + viewOffset?: number; + viewPosition?: number; +}; + +type ScrollToOffsetParams = { + animated?: boolean; + offset: number; +}; + +type ScrollToEndParams = { + animated?: boolean; +}; + +/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */ +type ActionListRef = { + scrollToIndex: (params: ScrollToIndexParams) => void; + scrollToOffset: (params: ScrollToOffsetParams) => void; + scrollToEnd: (params?: ScrollToEndParams) => void; + getNativeScrollRef?: () => unknown; + getScrollableNode?: () => unknown; + /** Measured item positions supplied by LegendList. Other list implementations can omit this. */ + getState?: () => {positionByKey: (key: string) => number | undefined; scroll?: number; contentLength?: number; scrollLength?: number} | undefined; +}; + +/** Ref to the underlying list instance attached via `ref={}`. */ +type ActionListRefType = RefObject | null; + +export default ActionListRefType; +export type {ActionListRef}; diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 46096456685d..efd9f93c761b 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -75,6 +75,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre !!ReportActionComposeFocusManager.sidePanelComposerRef.current : undefined; - focusComposerWithDelay(composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); + focusComposerWithDelay(() => composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); }, []); const shouldIgnoreEditSelectionResetRef = useRef(false); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 68fb2fd3ebf3..f04852e7ddde 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -62,17 +62,16 @@ function useEditMessage({ clearAllReportActionDrafts(); - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (!shouldScrollToLastMessage) { return; } + // The list owns this request after the editor unmounts and waits for its layout commit. if (scrollToLastMessage) { scrollToLastMessage(); return; } - - reportScrollManager.scrollToIndex(0); + reportScrollManager.scrollToBottom(); } /** diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 3cc5eab3b727..df37daebe0f3 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,10 +1,12 @@ -import type {PropsWithChildren} from 'react'; +import type {Dispatch, PropsWithChildren, SetStateAction} from 'react'; -import {createContext} from 'react'; +import {useRecyclingState} from '@legendapp/list/react-native'; +import {createContext, useContext, useState} from 'react'; type ReportActionPosition = { index: number; isNewest: boolean; + isRecycling?: boolean; }; /** @@ -20,9 +22,23 @@ const ReportActionIndexContext = createContext({index: 0, /** Lets shared list implementations provide their own reliable way to reach the newest action. */ const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); -function ReportActionPositionContextProvider({children, index, isNewest}: PropsWithChildren) { - return {children}; +/** + * Each list passes the index from its row renderer and marks the last action as newest. The + * LegendList caller also sets isRecycling because it can reuse a mounted row for another action. + * Descendants use that flag to reset transient state (such as open menus or PDF load errors) + * with useRecyclingState instead of carrying it over to the next action in that row. + */ +function ReportActionPositionContextProvider({children, index, isNewest, isRecycling}: PropsWithChildren) { + return {children}; +} + +/** Uses LegendList's recycling-aware state in the main report list and useState in shared, non-recycled lists. */ +function useReportActionItemState(initialState: State | (() => State)): [State, Dispatch>] { + const {isRecycling = false} = useContext(ReportActionIndexContext); + const state = useState(initialState); + const recyclingState = useRecyclingState(initialState); + return isRecycling ? [...recyclingState] : state; } -export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext}; +export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext, useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 4474b1c0e308..e28eb707ad0a 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -95,7 +95,7 @@ import {displayNameOrDefaultSelector} from '@selectors/PersonalDetails'; import {agentZeroProcessingAgentIDsSelector} from '@selectors/ReportNameValuePairs'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -106,6 +106,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import {useReportActionItemState} from './ReportActionIndexContext'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; @@ -243,16 +244,16 @@ function ReportActionItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); + const [isContextMenuActive, setIsContextMenuActive] = useReportActionItemState(() => isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useReportActionItemState(undefined); + const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useReportActionItemState(undefined); + const [isHidden, setIsHidden] = useReportActionItemState(false); const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); - const downloadedPreviews = useRef([]); + const downloadedPreviews = useRef<{reportActionID: string; urls: string[]}>({reportActionID: action.reportActionID, urls: []}); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); + const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); const shouldBreakGrouping = shouldBreakAccessibilityGrouping(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); @@ -371,11 +372,14 @@ function ReportActionItem({ } const urls = extractLinksFromMessageHtml(action); - if (deepEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + if ( + (downloadedPreviews.current.reportActionID === action.reportActionID && deepEqual(downloadedPreviews.current.urls, urls)) || + action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE + ) { return; } - downloadedPreviews.current = urls; + downloadedPreviews.current = {reportActionID: action.reportActionID, urls}; expandURLPreview(reportID, action.reportActionID); }, [action, reportID]); @@ -398,7 +402,7 @@ function ReportActionItem({ return; } setIsHidden(false); - }, [latestDecision, action]); + }, [latestDecision, action, setIsHidden]); const toggleContextMenuFromActiveReportAction = () => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index 8f7403298d26..bf735ecf6478 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -97,6 +97,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf { - didLayout.current = false; - }, [reportID]); + const lastRequestedOldestActionIDRef = useRef(undefined); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. useRetireMerchantRuleSuggestionOnLeave(reportID); - // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. - const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + // Clearing a link after sending returns to the live tail of the same list. Only opening a link + // needs fresh initial positioning, including reopening the same action after its link was cleared. + const [linkedActionListState, setLinkedActionListState] = useState({reportActionID: reportActionIDFromRoute, version: 0}); + if (linkedActionListState.reportActionID !== reportActionIDFromRoute) { + setLinkedActionListState({ + reportActionID: reportActionIDFromRoute, + version: linkedActionListState.version + (reportActionIDFromRoute ? 1 : 0), + }); + } + + // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting + // gives the complete dataset a fresh initial layout so initialScrollAtEnd targets its actual end. + const listID = [ + reportID, + linkedActionListState.version, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const isReportArchived = !!isArchivedReport(reportNameValuePairs); @@ -188,16 +256,50 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const linkedReportActionID = reportActionIDFromRoute; - const {getScrollOffset} = useActionListContext(); + const {getScrollOffset, scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getNativeScrollRef: () => legendListRef.current?.getNativeScrollRef(), + getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), + getState: () => legendListRef.current?.getState(), + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; - const onShowPreviousMessages = handleShowPreviousMessages; const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const shouldRestoreTailAfterHistoryRevealRef = useRef(false); + const oldestLoadedActionID = allReportActionIDs.at(-1); + const oldestLoadedActionIDAtHistoryRevealRef = useRef(undefined); + const distanceFromBottomRef = useRef(getScrollOffset()); + const shouldFollowEndOnResizeRef = useRef(false); + const onShowPreviousMessages = () => { + // Revealing older actions can make a short session scrollable before the requested history page arrives. + // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. + shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; + oldestLoadedActionIDAtHistoryRevealRef.current = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions ? oldestLoadedActionID : undefined; + if (shouldRestoreTailAfterHistoryRevealRef.current) { + shouldFollowEndOnResizeRef.current = true; + } + handleShowPreviousMessages(); + }; const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, @@ -283,10 +385,45 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ) : visibleReportActions; + // OpenReport starts with a tiny cached page before replacing it with the hydrated page. Keep that + // already-visible page mounted until hydration finishes instead of exposing intermediate estimated + // layouts. The hydrated list then mounts from scratch using the full dataset. + const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); + const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); + if (!hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory)) { + setHasReleasedInitialSnapshot(true); + } + const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; + const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; + + // Report actions are stored newest-first. LegendList intentionally has no inverted mode, so + // give it chronological data and use its normal start/end and scrolling semantics. + const listData = reportActionsToRender.toReversed(); + + useEffect(() => { + if (!showFullHistory) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + return; + } + if (!shouldRestoreTailAfterHistoryRevealRef.current) { + return; + } + const animationFrame = requestAnimationFrame(() => { + if (!shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current) { + return; + } + legendListRef.current?.scrollToEnd({animated: false}); + const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; + if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + } + }); + return () => cancelAnimationFrame(animationFrame); + }, [showFullHistory, listData.length, oldestLoadedActionID, hasOlderActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { @@ -313,7 +450,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // The rendered indexes are known here, so scrolling the row into view mounts it and keeps the message visible while it's edited. useScrollToEditingReportAction({ - visibleReportActions: renderedVisibleReportActions, + visibleReportActions: listData, scrollToIndex: (index) => { // Already on screen, so its editor has mounted and taken focus — scrolling would only yank the user. if (viewableRowIndexesRef.current.has(index)) { @@ -324,11 +461,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }, }); - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + // Find the index of the action badge target in the chronological data rendered by LegendList. const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID ? renderedVisibleReportActions.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; - - const unreadMarkerReportActionIndex = unreadMarkerReportActionID ? renderedVisibleReportActions.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; + const actionBadgeTargetIndex = actionBadgeTargetID ? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const unreadMarkerListIndex = unreadMarkerReportActionID ? listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; const { trackVerticalScrolling, @@ -337,52 +473,168 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, onLoad, + onItemSizeChanged, + trackLinkedMessageScroll, + stopLinkedMessagePositioning, } = useReportActionsScroll({ + listID, reportID, conciergeChat, report, transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + // Cover every initial LegendList layout, including cached chats, until the rows are positioned. + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + + useEffect(() => { + if (!isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID) { + return; + } + // onReady positions the list, but only this committed render has removed its cover. + // Refocus also completes new spans when the positioned list stays mounted. + markOpenReportEnd(reportID, report, {warm: true}); + }, [isFocused, listID, loadedInitialViewportListID, report, reportID, shouldShowInitialViewportSkeleton]); + + const readyEndListIDRef = useRef(undefined); + const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); + const endCorrectionFrameRef = useRef(undefined); + const userScrolledListIDRef = useRef(undefined); + + const stopFollowingEnd = () => { + userScrolledListIDRef.current = listID; + shouldFollowEndOnResizeRef.current = false; + shouldRestoreTailAfterHistoryRevealRef.current = false; + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }; + + const handleScrollBeginDrag = () => { + stopLinkedMessagePositioning(); + stopFollowingEnd(); + }; + + const scheduleEndCorrection = () => { + if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { + return; + } + endCorrectionFrameRef.current = requestAnimationFrame(() => { + endCorrectionFrameRef.current = undefined; + if (readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current) { + legendListRef.current?.scrollToEnd({animated: false}); + } + }); }; - const trackViewableRowIndexes = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { - viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index).filter((index) => index !== null)); + useEffect( + () => () => { + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }, + [listID], + ); + + const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { + viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index)); onViewableItemsChanged(info); + for (const item of info.changed) { + if (!item.isViewable) { + continue; + } + allowLegendListItemOverflow(legendListRef.current, item.index); + } + }; + + const handleListReady = () => { + // onLoad only means rows rendered; keep their estimated positions covered until initial scrolling settles. + onLoad(); + setLoadedInitialViewportListID(listID); + readyEndListIDRef.current = listID; + // An unread marker near the newest action lands at the end. Rows above it are still measured after + // onReady, so keep that end in view instead of exposing older messages. + const isUnreadInitialScroll = initialScrollIndex !== undefined && !reportActionIDFromRoute && !shouldBeAlignedToTop; + const didLandAtEnd = initialScrollIndex === undefined || (isUnreadInitialScroll && !!legendListRef.current?.getState()?.isAtEnd); + shouldFollowEndOnResizeRef.current = didLandAtEnd && !hasNewerActions && userScrolledListIDRef.current !== listID; + lastScrollMetricsRef.current = undefined; + // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). + scheduleEndCorrection(); }; - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + const loadOlderChatsOnStartReached = () => { + if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; } + + lastRequestedOldestActionIDRef.current = oldestReportActionID; loadOlderChats(false); }; + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + trackLinkedMessageScroll(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + distanceFromBottomRef.current = distanceFromBottom; + const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; + const previousMetrics = lastScrollMetricsRef.current; + if (readyEndListIDRef.current === listID && !hasNewerActions) { + if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { + shouldFollowEndOnResizeRef.current = true; + } else if ( + (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || + (previousMetrics && + previousMetrics.contentHeight === contentSize.height && + previousMetrics.viewportHeight === layoutMeasurement.height && + endCorrectionFrameRef.current === undefined) + ) { + // A pending history request must not reclaim the end after the reader has moved away. + stopFollowingEnd(); + } + } + lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; + + if (isNearStart) { + loadOlderChatsOnStartReached(); + } else { + lastRequestedOldestActionIDRef.current = undefined; + } + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, + }, + }; + + trackVerticalScrolling(bottomRelativeEvent); + setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); + }; + const loadNewerChatsAfterTransitions = () => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); @@ -414,7 +666,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -446,13 +698,15 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); })(); - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const reportActionIndex = reportActionsToRender.length - index - 1; return ( 1} + shouldDisplayReplyDivider={reportActionsToRender.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} shouldUseThreadDividerLine={shouldUseThreadDividerLine} @@ -489,10 +743,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ); }; - // Native mobile does not render updates flatlist the changes even though component did update called. - // To notify there something changes we can use extraData prop to flatlist + // Ensure row presentation updates when these values change without changing the action data. const extraData = [ - shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, + unreadMarkerReportActionID, isArchivedNonExpenseReport(report, isReportArchived), draftReportActionID, draftMessageHTML, @@ -500,7 +753,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct latestConciergeFeedbackActionIDs, ]; - const listHeaderComponent = ( + const latestActionsFooter = ( action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const offlineHistoryHeader = shouldShowOfflineSkeleton ? : undefined; const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -523,13 +776,13 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct */ const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { onLayout?.(event); - if (didLayout.current) { - return; + const state = legendListRef.current?.getState(); + if (state && readyEndListIDRef.current === listID) { + const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); + scrollOffsetRef.current = distanceFromBottom; + distanceFromBottomRef.current = distanceFromBottom; } - - didLayout.current = true; - - markOpenReportEnd(reportID, report, {warm: true}); + scheduleEndCorrection(); }; // The guard only mounts this content when the report is loaded, so this is effectively unreachable. @@ -562,47 +815,58 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report={report} isReportArchived={isReportArchived} > - { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} + onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} - onViewableItemsChanged={trackViewableRowIndexes} + onScrollBeginDrag={handleScrollBeginDrag} + onStartReached={loadOlderChatsOnStartReached} + onStartReachedThreshold={PAGINATION_THRESHOLD} + onViewableItemsChanged={updateVisibleItemOverflow} extraData={extraData} key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onLoad={onLoad} + getItemType={getItemType} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} + alignItemsAtEnd={!shouldBeAlignedToTop} + // Keep the existing pixel-based new-message policy in useReportActionsScroll. + maintainScrollAtEnd={false} + maintainVisibleContentPosition + onReady={handleListReady} + onItemSizeChanged={onItemSizeChanged} onContentSizeChange={() => { trackVerticalScrolling(undefined); - flushPendingScrollToBottom(); + scheduleEndCorrection(); }} /> + {shouldShowInitialViewportSkeleton && ( + + + + )} ); @@ -614,8 +878,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct */ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { return ( - + {isEditingInline ? ( = 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; + return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex > prevActionBadgeTargetIndex; } export default shouldFollowActionBadgeTarget; diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index bbf859b72291..4d2f426e3946 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -15,10 +15,10 @@ type UseFollowActionBadgeTargetParams = { /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** The rendered (inverted) report actions the list is displaying */ + /** The chronological report actions the list is displaying */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Scrolls the list to the current action-badge target */ diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index bbce6b00c960..bf1fc24a282b 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -5,6 +5,7 @@ import type useReportScrollManager from '@hooks/useReportScrollManager'; import type {OpenReportActionParams} from '@libs/actions/Report'; import {openReport, pruneReportActionPagesToNewestWindow} from '@libs/actions/Report'; import {subscribeToNewActionEvent} from '@libs/actions/Report/reportActionSubscribers'; +import type {ActionEventSource} from '@libs/actions/Report/reportActionSubscribers'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -47,7 +48,8 @@ type UseReportActionsNewActionLiveTailParams = { hasNewerActions: boolean; linkedReportActionID: string | undefined; hasNewestReportAction: boolean; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; + /** Actions rendered by the list in chronological order. */ + renderedVisibleReportActions: OnyxTypes.ReportAction[]; sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; reportActionPages: OnyxTypes.Pages | undefined; setTreatAsNoPaginationAnchor: (value: boolean) => void; @@ -58,12 +60,12 @@ type UseReportActionsNewActionLiveTailParams = { }; type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune'; +const PENDING_SENT_ACTION_TIMEOUT_MS = 5000; /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the - * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it - * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke - * it from list `onLayout` outside this hook. + * deferred scroll + pagination prune after the data render. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The prune callback completes the explicit scroll request in the caller. */ function useReportActionsNewActionLiveTail({ conciergeChat, @@ -79,7 +81,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -93,8 +95,35 @@ function useReportActionsNewActionLiveTail({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + const [pendingScrollActionID, setPendingScrollActionID] = useState(); + const [scrollRequestReportID, setScrollRequestReportID] = useState(reportID); + if (scrollRequestReportID !== reportID) { + setScrollRequestReportID(reportID); + setIsScrollToBottomEnabled(false); + setPendingScrollActionID(undefined); + } + + const setShouldScrollToBottom = useCallback((enabled: boolean) => { + setIsScrollToBottomEnabled(enabled); + if (!enabled) { + setPendingScrollActionID(undefined); + } + }, []); + + const isPendingScrollActionRendered = !!pendingScrollActionID && renderedVisibleReportActions.some((item) => item.reportActionID === pendingScrollActionID); + + useEffect(() => { + if (!pendingScrollActionID) { + return; + } - const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { + // The optimistic row normally arrives immediately. A filtered or failed row must not + // scroll the reader much later when pagination or reconnecting makes it visible. + const timeout = setTimeout(() => setPendingScrollActionID(undefined), PENDING_SENT_ACTION_TIMEOUT_MS); + return () => clearTimeout(timeout); + }, [pendingScrollActionID]); + + const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action: OnyxTypes.ReportAction | undefined, source: ActionEventSource) => { TransitionTracker.runAfterTransitions({ callback: () => { // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where @@ -102,6 +131,11 @@ function useReportActionsNewActionLiveTail({ if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } + // A realtime echo can represent an edit or deletion, whereas local payload-less + // notifications from money requests still need the usual live-tail behavior. + if (!action && source === 'realtime') { + return; + } // An unread marker only needs openReport when the newest actions are not loaded yet. When they are, they are // just off-screen, so scroll to them directly instead of waiting on a network round trip. @@ -133,14 +167,14 @@ function useReportActionsNewActionLiveTail({ setTreatAsNoPaginationAnchor(true); navigation.setParams({reportActionID: ''}); } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { + setIsFloatingMessageCounterVisible(false); + if (index >= 0 && index < renderedVisibleReportActions.length - 1) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } if (action?.reportActionID) { @@ -148,22 +182,24 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + if (action?.reportActionID) { + setPendingScrollActionID(action.reportActionID); + } else { + setShouldScrollToBottom(true); + } } - - setIsScrollToBottomEnabled(true); }, }); }); - const completeLiveTailPruneAfterScrollToBottom = useCallback(() => { + const completeLiveTailPruneAfterScrollToBottom = () => { if (liveTailJumpRef.current.stage !== 'await_prune') { return; } pruneReportActionPagesToNewestWindow(reportID, sortedAllReportActionsForPagination, reportActionPages); setTreatAsNoPaginationAnchor(false); liveTailJumpRef.current = {stage: 'idle'}; - }, [reportID, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor]); + }; useEffect(() => { liveTailJumpRef.current = {stage: 'idle'}; @@ -208,9 +244,9 @@ function useReportActionsNewActionLiveTail({ setIsFloatingMessageCounterVisible(false); // Defer so this effect does not synchronously chain a second render from setState (eslint react-hooks/set-state-in-effect). queueMicrotask(() => { - setIsScrollToBottomEnabled(true); + setShouldScrollToBottom(true); }); - }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible]); + }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible, setShouldScrollToBottom]); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? @@ -240,8 +276,8 @@ function useReportActionsNewActionLiveTail({ }, [reportID]); return { - isScrollToBottomEnabled, - setIsScrollToBottomEnabled, + isScrollToBottomEnabled: isScrollToBottomEnabled || isPendingScrollActionRendered, + setIsScrollToBottomEnabled: setShouldScrollToBottom, completeLiveTailPruneAfterScrollToBottom, }; } diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 0b6451b97d23..73db7173a9fc 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -158,11 +158,9 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above the viewport. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex; setIsActionBadgeAboveViewport(isAbove); } else { diff --git a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts index 82a875706485..c60c604a7497 100644 --- a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts +++ b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts @@ -14,7 +14,8 @@ import {useEffect, useState} from 'react'; function useShouldShowEnableNotificationsBanner(report: OnyxEntry): boolean { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [hasDismissed] = useOnyx(ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER); - const [permissionStatus, setPermissionStatus] = useState(); + // Reserve the banner's space before the report list makes its initial scroll-to-end measurement. + const [permissionStatus, setPermissionStatus] = useState(() => NotificationPermission.getStatusSync()); const isConcierge = isConciergeChatReport(report, conciergeReportID); diff --git a/src/setup/telemetry/debugTransport.ts b/src/setup/telemetry/debugTransport.ts index 3dfd00f51a85..08e51d397c05 100644 --- a/src/setup/telemetry/debugTransport.ts +++ b/src/setup/telemetry/debugTransport.ts @@ -1,3 +1,5 @@ +import {isRecord} from '@libs/ObjectUtils'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {BaseTransportOptions, Envelope, Transport} from '@sentry/core'; @@ -43,10 +45,6 @@ function formatLogPrefix(category: string, op?: string): string { return `${SENTRY_LOG_PREFIX}[${category}]`; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isString(value: unknown): value is string { return typeof value === 'string'; } diff --git a/tests/navigation/LinkedActionNotFoundGuardTest.tsx b/tests/navigation/LinkedActionNotFoundGuardTest.tsx index 653773865d14..bb0bffd09b8c 100644 --- a/tests/navigation/LinkedActionNotFoundGuardTest.tsx +++ b/tests/navigation/LinkedActionNotFoundGuardTest.tsx @@ -16,6 +16,8 @@ const NAVIGATOR_KEY = 'test-navigator-key'; const mockSetParams = jest.fn(); const mockCleanStaleBackToParam = jest.fn(); const mockIsNavigationReady = jest.fn(() => Promise.resolve()); +const mockChildrenMount = jest.fn(); +const mockChildrenUnmount = jest.fn(); jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, @@ -43,7 +45,7 @@ jest.mock('@react-navigation/native', () => { useRoute: () => ({ key: ROUTE_KEY, name: 'Report', - params: mockRouteParams, + params: {...mockRouteParams}, }), useNavigation: () => ({ getState: () => ({key: NAVIGATOR_KEY}), @@ -145,6 +147,10 @@ function createReportAction(overrides: Partial = {}): ReportAction } function TestChildren() { + React.useEffect(() => { + mockChildrenMount(); + return () => mockChildrenUnmount(); + }, []); return ; } @@ -170,6 +176,8 @@ describe('LinkedActionNotFoundGuard', () => { mockSetParams.mockReset(); mockCleanStaleBackToParam.mockClear(); mockIsNavigationReady.mockClear(); + mockChildrenMount.mockClear(); + mockChildrenUnmount.mockClear(); mockRouteParams.reportID = REPORT_ID; mockRouteParams.reportActionID = REPORT_ACTION_ID; mockLinkedAction = createReportAction(); @@ -177,6 +185,29 @@ describe('LinkedActionNotFoundGuard', () => { mockIsReportActionVisible = true; }); + it('preserves the report content when a link is cleared or a different link is opened', () => { + // Given a linked report has mounted its list and composer. + const view = render( + + + , + ); + + // When sending clears the link, and the reader subsequently opens another link. + for (const reportActionID of ['', 'another-action', undefined, REPORT_ACTION_ID]) { + mockRouteParams.reportActionID = reportActionID; + view.rerender( + + + , + ); + + // Then the report keeps its mounted list, editor, and keyboard state. + expect(mockChildrenMount).toHaveBeenCalledTimes(1); + expect(mockChildrenUnmount).not.toHaveBeenCalled(); + } + }); + it('renders children when linked action exists', () => { render( diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index d39902dc322c..78ba39d7e827 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -2,7 +2,6 @@ import {act, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {setHasRadio} from '@libs/NetworkState'; @@ -15,7 +14,9 @@ import ComposeProviders from '@src/components/ComposeProviders'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportActions} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; import {NavigationContainer} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; @@ -26,13 +27,11 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -const mockedNavigate = jest.fn(); - jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); + const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, - useRoute: () => mockedNavigate, + useRoute: () => ({key: 'report', name: 'Report', params: {reportID: '1'}}), useIsFocused: () => true, }; }); @@ -64,9 +63,20 @@ const signUpWithTestUser = () => { TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); }; -const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(500); -const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); -const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); +function buildComment(index: number) { + const text = `Benchmark message ${index}`; + return ReportTestUtils.getFakeReportAction(index, { + actionName: index === 1 ? CONST.REPORT.ACTIONS.TYPE.CREATED : CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: TEST_USER_ACCOUNT_ID, + created: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString().replace('T', ' ').replace('Z', ''), + isAttachmentOnly: false, + message: [{type: 'COMMENT', html: text, text}], + }); +} + +const actions = Array.from({length: 500}, (_unused, index) => buildComment(index + 1)); +const reportActions: ReportActions = Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: actions.at(-1)?.created}); beforeEach(async () => { // Initialize the network key for OfflineWithFeedback @@ -81,6 +91,9 @@ beforeEach(async () => { // Seed the report under test: the report, its 500 actions, and a settled loading state. await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [ + [CONST.PAGINATION_START_ID, ...actions.map((action) => action.reportActionID).reverse(), CONST.PAGINATION_END_ID], + ]); await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, @@ -120,10 +133,34 @@ function ReportActionsListWrapper() { ); } -test('[ReportActionsList] should render ReportActionsList with 500 reportActions stored', async () => { +// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used +// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. +test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { + // Given a settled report whose complete 500-action page is stored in Onyx. const scenario = async () => { + // When the report list mounts, then its final comment must actually render. await screen.findByTestId('report-actions-list'); + await screen.findByText('Benchmark message 500'); }; await waitForBatchedUpdates(); await measureRenders(, {scenario}); }); + +test('[ReportActionsList] should render a new comment in a report with 500 stored actions', async () => { + // Given a mounted report with 500 stored actions. + let run = 0; + const scenario = async () => { + await screen.findByTestId('report-actions-list'); + // When a new comment arrives, update the real Onyx collection and the report's latest timestamp. + // Reassure repeats this scenario, so every run must append a new action to the continuous chain. + const action = buildComment(501 + run++); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[action.reportActionID]: action}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {lastVisibleActionCreated: action.created}); + await waitForBatchedUpdates(); + }); + // Then the new comment renders before this measurement completes. + await screen.findByText(`Benchmark message ${action.reportActionID}`); + }; + await measureRenders(, {scenario}); +}); diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index 4977a91fdb09..7df46bcbdd35 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -44,8 +44,11 @@ const MOCK_UNIFIED_LAST_ITEM_INDEX = 37; const mockScrollToIndex = jest.fn(); const mockScrollToEnd = jest.fn(); const mockScrollToOffset = jest.fn(); +const mockNewestActionUnmount = jest.fn(); let mockIsNewestReportAction: boolean | undefined; let mockScrollToNewestAction: (() => void) | undefined; +let mockOnCommitLayoutEffect: (() => void) | undefined; +let mockShouldRenderNewestAction = true; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -103,6 +106,7 @@ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList' isLoadingInitialActions, listRef, onLastItemIndexChange, + onCommitLayoutEffect, visibleReportActions, renderReportAction, }: { @@ -114,17 +118,19 @@ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList' scrollToOffset: typeof mockScrollToOffset; }>; onLastItemIndexChange?: (index: number) => void; + onCommitLayoutEffect?: () => void; visibleReportActions: ReportAction[]; renderReportAction: (reportAction: ReportAction, index: number) => React.ReactElement; }) => { ReactActual.useImperativeHandle(listRef, () => ({scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToEnd, scrollToOffset: mockScrollToOffset})); ReactActual.useLayoutEffect(() => onLastItemIndexChange?.(MOCK_UNIFIED_LAST_ITEM_INDEX), [onLastItemIndexChange]); + mockOnCommitLayoutEffect = onCommitLayoutEffect; const newestAction = visibleReportActions.at(-1); return ( {isLoadingInitialActions ? : null} - {newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} + {newestAction && mockShouldRenderNewestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} {listFooterComponent} ); @@ -209,6 +215,12 @@ jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); return jest.fn(() => { + ReactActual.useEffect( + () => () => { + mockNewestActionUnmount(); + }, + [], + ); mockIsNewestReportAction = ReactActual.useContext(ReportActionIndexContextActual).isNewest; mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); return null; @@ -320,6 +332,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { jest.clearAllMocks(); mockIsNewestReportAction = undefined; mockScrollToNewestAction = undefined; + mockOnCommitLayoutEffect = undefined; + mockShouldRenderNewestAction = true; jest.spyOn(NativeNavigation, 'useIsFocused').mockReturnValue(true); mockUseIsReportLoadPending.mockReturnValue(false); mockUseNetwork.mockReturnValue({isOffline: false}); @@ -329,7 +343,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { }); }); - it('should use the unified list index when the newest action requests a bottom scroll', async () => { + it('keeps newest-action scroll requests until FlashList commits after the editor unmounts', async () => { + // Given a loaded money report with an editable newest action and an unchanged row height. await act(async () => { await Onyx.multiSet({ [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, @@ -348,9 +363,34 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { await waitForBatchedUpdatesWithAct(); expect(mockIsNewestReportAction).toBe(true); - act(() => mockScrollToNewestAction?.()); + mockScrollToIndex.mockClear(); + act(() => mockOnCommitLayoutEffect?.()); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // When closing the editor queues a request and unmounts its consumer, without a size-change event. + const requestScroll = mockScrollToNewestAction; + mockShouldRenderNewestAction = false; + act(() => requestScroll?.()); + expect(mockNewestActionUnmount).toHaveBeenCalledTimes(1); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // Then the list's layout commit consumes the request once and preserves its indexed landing. + const firstCommit = mockOnCommitLayoutEffect; + act(() => { + firstCommit?.(); + firstCommit?.(); + }); expect(mockScrollToIndex).toHaveBeenCalledWith({index: MOCK_UNIFIED_LAST_ITEM_INDEX, animated: false, viewPosition: 1}); + expect(mockScrollToIndex).toHaveBeenCalledTimes(1); expect(mockScrollToEnd).not.toHaveBeenCalled(); + + // A later edit needs its own layout commit; a repeated older callback cannot consume it. + mockShouldRenderNewestAction = true; + act(() => requestScroll?.()); + act(() => firstCommit?.()); + expect(mockScrollToIndex).toHaveBeenCalledTimes(1); + act(() => mockOnCommitLayoutEffect?.()); + expect(mockScrollToIndex).toHaveBeenCalledTimes(2); }); it('should show reject educational modal when reject option is selected and explanation has NOT been dismissed', async () => { diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 411f2277558e..8331a9ee7247 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,12 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; +const PAGINATED_LIST_CONTENT_SIZE = { + ...LIST_CONTENT_SIZE, + height: LIST_CONTENT_SIZE.height * 2, +}; +const PAGINATED_LIST_END_OFFSET = PAGINATED_LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -58,14 +64,14 @@ function getReportScreen(reportID = REPORT_ID) { return screen.getByTestId(`report-screen-${reportID}`); } -function scrollToOffset(offset: number) { +function scrollToOffset(offset: number, contentSize = LIST_CONTENT_SIZE) { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { nativeEvent: { contentOffset: { y: offset, }, - contentSize: LIST_CONTENT_SIZE, + contentSize, layoutMeasurement: LIST_SIZE, }, }); @@ -315,7 +321,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(LIST_END_OFFSET); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); @@ -338,8 +344,8 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); - // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + // Reaching the list start must load older history without relying on the onScroll fallback. + fireEvent(within(getReportScreen()).getByLabelText(TestHelper.translateLocal('sidebarScreen.listOfChatMessages')), 'onStartReached'); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -370,8 +376,8 @@ describe('Pagination', () => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onStartReached` which triggers other updates - scrollToOffset(0); + // we need to scroll too to trigger `onEndReached` which triggers other updates + scrollToOffset(LIST_END_OFFSET); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); await waitForNetworkPromises(); @@ -393,9 +399,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. @@ -405,9 +411,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 68a58987d683..dcbc839e8510 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,4 +1,4 @@ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -219,14 +219,17 @@ describe('ReportActionCompose Integration Tests', () => { expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); }); - it('should use the list-specific scroll after saving the newest message', () => { + it('should use the list-specific scroll after saving the newest message', async () => { + // Given the newest message is being edited in a report list const scrollToNewestAction = jest.fn(); renderReportActionItemMessageEdit(undefined, {index: 9, isNewest: true, scrollToNewestAction}); + // When the edit is saved and the restored message row has time to lay out fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); fireEvent.press(screen.getByLabelText('common.saveChanges')); - expect(scrollToNewestAction).toHaveBeenCalledTimes(1); + // Then the list scrolls to the newest action after the scheduled frame + await waitFor(() => expect(scrollToNewestAction).toHaveBeenCalledTimes(1)); }); it('should not scroll after saving a non-newest message at index zero', () => { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 76f9ec963a27..191ef48ed8bd 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,7 @@ -import {render, screen, waitFor} from '@testing-library/react-native'; +import {act, render, screen, waitFor} from '@testing-library/react-native'; + +import type {RenderActionSheetAwareScrollViewComponent} from '@components/ActionSheetAwareScrollView/types'; +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -9,6 +12,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; +import type useReportActionsScroll from '@hooks/useReportActionsScroll'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; @@ -17,6 +21,8 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import DateUtils from '@libs/DateUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; @@ -26,14 +32,20 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; +import type {OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; import type * as ReactNavigation from '@react-navigation/native'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import {useRoute} from '@react-navigation/native'; import React from 'react'; +import {Platform, StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const mockUseIsFocused = jest.fn().mockReturnValue(false); +jest.unmock('@components/ActionSheetAwareScrollView'); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -61,6 +73,7 @@ jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); jest.mock('@hooks/useSidePanelState', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@components/LegendList/allowLegendListItemOverflow', () => jest.fn()); jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ useConciergeSessionState: jest.fn(), useConciergeSessionActions: jest.fn(), @@ -71,6 +84,7 @@ jest.mock('@pages/inbox/ConciergeDraftContext', () => ({ })); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseRoute = useRoute as jest.MockedFunction; const mockUseIsReportLoadPending = useIsReportLoadPending as jest.MockedFunction; const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; @@ -84,9 +98,12 @@ const mockUseConciergeDraft = useConciergeDraft as jest.MockedFunction; const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; +const mockAllowLegendListItemOverflow = allowLegendListItemOverflow as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; +function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false, isLoadingOlderReportActions = false) { + return selector === reportActionsListLoadingStateSelector + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError: false} + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -113,10 +130,12 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +const mockLoadOlderChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => - jest.fn(() => ({ - loadOlderChats: jest.fn(), + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ + loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -124,28 +143,63 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedFlashList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds chronological data directly to LegendList. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/FlashList/InvertedFlashList', () => jest.fn(() => null)); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); +const mockLegendScrollToEnd = jest.fn(); +const mockLegendPositionByKey = jest.fn(() => 1500); +const mockLegendGetState = jest.fn(() => ({ + positionByKey: mockLegendPositionByKey, + isAtEnd: false, + contentLength: 1000, + scrollLength: 500, + scroll: 500, +})); +const mockLegendGetScrollableNode = jest.fn(() => ({scrollTop: 0})); +let mockShouldCallLegendListOnLoad = true; +jest.mock('@legendapp/list/react-native', () => { + const reactModule = jest.requireActual('react'); + const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void; onReady?: () => void}>(({onLoad, onReady}, ref) => { + reactModule.useImperativeHandle(ref, () => ({scrollToEnd: mockLegendScrollToEnd, getState: mockLegendGetState, getScrollableNode: mockLegendGetScrollableNode}), []); + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + onReady?.(); + } + return () => { + mockLegendListUnmount(); + }; + }, []); + return null; + }); + return { + LegendList: jest.fn((props: {onLoad?: () => void; onReady?: () => void; ref?: React.Ref<{scrollToEnd: typeof mockLegendScrollToEnd}>}) => + reactModule.createElement(MockLegendListContent, props), + ), + }; +}); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, + trackLinkedMessageScroll: jest.fn(), trackVerticalScrolling: jest.fn(), onViewableItemsChanged: jest.fn(), isFloatingMessageCounterVisible: false, isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, onLoad: jest.fn(), + onItemSizeChanged: jest.fn(), + stopLinkedMessagePositioning: jest.fn(), })), ); jest.mock('@pages/inbox/report/FloatingMessageCounter', () => jest.fn(() => null)); @@ -156,24 +210,51 @@ jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); -type MockInvertedFlashListProps = { +type MockLegendListProps = { + alignItemsAtEnd?: boolean; + contentContainerStyle?: StyleProp; data?: OnyxTypes.ReportAction[]; + drawDistance?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + experimental_hideItemsUntilMeasured?: boolean; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + initialScrollAtEnd?: boolean; + maintainScrollAtEnd?: {animated: boolean; on: {dataChange: boolean}} | false; + maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: boolean; + onLayout?: (event: LayoutChangeEvent) => void; + onLoad?: () => void; + onReady?: () => void; + onScrollBeginDrag?: () => void; + onStartReachedThreshold?: number; + onContentSizeChange?: (width: number, height: number) => void; + onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; - onContentSizeChange?: () => void; + renderScrollComponent?: RenderActionSheetAwareScrollViewComponent; + scrollEventThrottle?: number; + onStartReached?: () => void; + onScroll?: (event: { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; + }) => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); -/** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ -const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0)?.data; -const getCapturedListProps = (): MockInvertedFlashListProps | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0); +/** Returns the chronological report actions the body fed into the mocked LegendList on its latest render. */ +const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; +const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); const getRenderedReportActionsListItemProps = ( reportAction: OnyxTypes.ReportAction, index = 0, -): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean} => { +): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean; shouldDisplayNewMarker?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); if (!React.isValidElement<{children: React.ReactNode}>(renderedItem)) { @@ -192,9 +273,24 @@ const getRenderedReportActionsListItemProps = ( return child.props; }; +const getShowHistoryPress = (action: OnyxTypes.ReportAction, index: number): (() => void) | undefined => { + const renderedItem = getCapturedListProps()?.renderItem?.({item: action, index}); + if (!React.isValidElement<{children: React.ReactNode}>(renderedItem)) { + return undefined; + } + const historyButton = React.Children.toArray(renderedItem.props.children).find( + (child): child is React.ReactElement<{onPress: () => void}> => React.isValidElement<{onPress?: () => void}>(child) && typeof child.props.onPress === 'function', + ); + return historyButton?.props.onPress; +}; + const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); +const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); +let mockHasOnceLoadedReportActions = true; +let mockIsLoadingInitialReportActions = false; +let mockIsLoadingOlderReportActions = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -237,6 +333,19 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; +const olderMockReportAction: OnyxTypes.ReportAction = { + reportActionID: '0', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2022-12-31', + actorAccountID: 125, + message: [{type: 'COMMENT', html: 'Older message', text: 'Older message'}], + originalMessage: {}, + shouldShow: true, + person: [{type: 'TEXT', style: 'strong', text: 'Older User'}], + pendingAction: null, + errors: {}, +}; + const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; return render( @@ -282,7 +391,14 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(false); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; + mockIsLoadingOlderReportActions = false; + mockShouldCallLegendListOnLoad = true; + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}); mockUseIsReportLoadPending.mockReturnValue(false); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {}}); mockUseCurrentUserPersonalDetails.mockReturnValue({ accountID: 100, @@ -332,7 +448,12 @@ describe('ReportActionsList (body)', () => { mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); - mockUseOnyx.mockImplementation(getMockOnyxValue); + mockUseOnyx.mockImplementation((key, options) => { + if (key.includes('reportLoadingState')) { + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions, mockIsLoadingOlderReportActions), {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); }); afterEach(async () => { @@ -340,6 +461,876 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('publishes measured positions and the scroll element through the action-list adapter', () => { + // Given a mounted LegendList with measured rows and its own scroll element. + mockUseNetwork.mockReturnValue({isOffline: false}); + const registerListRef = jest.fn(); + const contextValue = {scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, getListRef: () => null, registerListRef}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + + // When linked positioning reads the registered adapter rather than the raw LegendList ref. + const adapter = registerListRef.mock.calls.at(-1)?.at(0)?.current; + const position = adapter?.getState?.()?.positionByKey('linked-action'); + const scrollElement = adapter?.getScrollableNode?.(); + + // Then measurement recovery and web input cancellation can reach the real list. + expect(position).toBe(1500); + expect(mockLegendPositionByKey).toHaveBeenCalledWith('linked-action'); + expect(scrollElement).toEqual({scrollTop: 0}); + expect(mockLegendGetScrollableNode).toHaveBeenCalledTimes(1); + }); + + it('corrects the initial end position after the latest row has been measured', async () => { + // Given a report opens at its latest action and the virtualized list finishes its initial placement. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + // When the last row is taller than the estimate, its feedback controls must not remain below the viewport. + // Then the list makes one final end scroll after the layout is ready. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('keeps the native scroll-view throttle supplied by LegendList', () => { + // Given a report list using the action-sheet-aware native scroller. + mockUseNetwork.mockReturnValue({isOffline: false}); + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + try { + renderReportActionsList(); + const listProps = getCapturedListProps(); + + // When LegendList requests native scroll events on every frame. + const scrollView = listProps?.renderScrollComponent?.({scrollEventThrottle: 0}); + + // Then the wrapper preserves them while LegendList separately throttles the app callback. + expect(scrollView?.props.scrollEventThrottle).toBe(0); + expect(listProps?.scrollEventThrottle).toBe(16); + } finally { + platform.restore(); + } + }); + + 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}); + const originalState = mockLegendGetState(); + const scrollOffsetRef = {current: 600}; + const contextValue = {scrollOffsetRef, getScrollOffset: () => scrollOffsetRef.current, getListRef: () => null, registerListRef: jest.fn()}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const frames: FrameRequestCallback[] = []; + const requestFrame = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => frames.push(callback)); + try { + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 900}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When successive layouts resize the viewport without new content or scroll events. + for (const viewportHeight of [height, height + 50]) { + mockLegendGetState.mockReturnValue({...originalState, contentLength: 2000, scrollLength: viewportHeight, scroll: 900}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: viewportHeight}}}))); + + // Then a future incoming message uses the current pixel offset without resizing itself starting a scroll. + expect(scrollOffsetRef.current).toBe(2000 - viewportHeight - 900); + expect(frames).toHaveLength(0); + } + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + } finally { + requestFrame.mockRestore(); + mockLegendGetState.mockReturnValue(originalState); + } + }); + + it('keeps following the newest action when the keyboard shrinks the viewport', async () => { + // Given the reader is following the newest action before opening the keyboard. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalState = mockLegendGetState(); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + try { + // When the smaller viewport temporarily leaves the newest action 300 pixels below it. + mockLegendGetState.mockReturnValue({...originalState, contentLength: 1000, scrollLength: 200, scroll: 500}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 200}}}))); + + // Then resizing keeps the existing end-following intent. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockLegendGetState.mockReturnValue(originalState); + } + }); + + it('keeps the latest message visible when its measured height grows after opening', async () => { + // Given the chat has opened at the end with a measured viewport. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a late feedback row makes the content taller without a user scroll. + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1025, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the last 25 pixels are brought into view. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('follows content growth when the reader is 50 pixels above the newest action', async () => { + // Given a reader still within the existing 250-pixel follow distance. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 450}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a new message or streamed text increases the content height. + act(() => getCapturedListProps()?.onContentSizeChange?.(300, 1025)); + + // Then it follows the new content even though the reader is more than 1% from the end. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('does not pull the chat back down when the reader has scrolled away', async () => { + // Given the chat opened at the end and the reader scrolled upward. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 200}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // When content grows, the reader's position stays where they left it. + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + }); + + it('resumes resize following after scrolling from a linked action to the end', async () => { + // Given the chat opens at a linked action, which must keep its initial position. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll() as ReturnType; + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 0}); + try { + renderReportActionsList(); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + + // When the reader reaches the end and the latest row grows afterward. + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the latest content remains visible even though the route still contains a linked action. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + + it.each([ + {isAtEnd: true, shouldFollowEnd: true}, + {isAtEnd: false, shouldFollowEnd: false}, + ])('follows late row growth after opening at an unread marker only when it lands at the end ($isAtEnd)', async ({isAtEnd, shouldFollowEnd}) => { + // Given the chat opens at an unread marker, which LegendList clamps to the end when the marker is near the newest action. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll() as ReturnType; + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd}); + try { + renderReportActionsList(); + + // When rows above the viewport are measured after the list is ready and change the content height. + act(() => { + getCapturedListProps()?.onContentSizeChange?.(300, 1025); + }); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + + // Then a list that landed at the end keeps the newest messages in view, and one showing older unread messages stays put. + if (shouldFollowEnd) { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } else { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd: false}); + } + }); + + it('cancels a queued end correction as soon as the reader starts dragging', async () => { + // Given removing the latest message has queued a layout correction at the end. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + try { + const listProps = getCapturedListProps(); + // When the user touches the list before the next scroll metrics arrive. + act(() => { + listProps?.onContentSizeChange?.(300, 900); + listProps?.onScrollBeginDrag?.(); + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then even a callback already dispatched by the frame scheduler respects the gesture. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it('keeps linked positioning active through automatic scroll metrics until the reader drags', () => { + // Given a measured linked target can cause automatic scroll compensation before its preview settles. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as ReturnType; + const stopLinkedPositioning = scrollHookResult.stopLinkedMessagePositioning; + const listProps = getCapturedListProps(); + + // When layout compensation moves the list without a user gesture. + act(() => { + for (const offset of [200, 100]) { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 100, width: 300}, + }, + }); + } + }); + + // Then later preview measurements can still correct the target, until the reader takes over. + expect(stopLinkedPositioning).not.toHaveBeenCalled(); + act(() => listProps?.onScrollBeginDrag?.()); + expect(stopLinkedPositioning).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate the composer spacing inside the chronological list', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(StyleSheet.flatten(getCapturedListProps()?.contentContainerStyle)?.paddingBottom).toBe(0); + }); + + it('invalidates a recycled row when the unread marker changes on a wide layout', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: '2', unreadMarkerReportActionIndex: 0}); + renderReportActionsList(); + + expect(getCapturedListProps()?.extraData).toEqual(expect.arrayContaining(['2'])); + const markerAction = getCapturedVisibleActions()?.find((action) => action.reportActionID === '2'); + if (!markerAction) { + throw new Error('Expected unread marker action fixture'); + } + expect(getRenderedReportActionsListItemProps(markerAction, 1).shouldDisplayNewMarker).toBe(true); + }); + + it('does not follow the end of a page that still has newer actions to load', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + it('remounts the list when the initial report actions finish hydrating', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + mockLegendScrollToEnd.mockClear(); + + mockHasOnceLoadedReportActions = true; + // The mocked Onyx hook does not own state, so changing its return value cannot schedule the + // rerender that the real Onyx subscription causes. Change a prop to trigger that render. + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(true); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + + it('resumes initial scroll tracking after layout and scrolling are ready', () => { + // Given a report with its action list ready to mount. + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {onLoad?: jest.Mock} | undefined; + const onLoad = scrollHookResult?.onLoad; + expect(onLoad).not.toHaveBeenCalled(); + + // When LegendList reports that layout and initial scrolling are complete. + act(() => getCapturedListProps()?.onReady?.()); + + // Then the scroll hook receives that event to finish initial positioning. + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('covers a previously hydrated report until LegendList finishes its initial layout', () => { + // Given a report that was already loaded before this list mounted + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + + // When the user opens the report again + renderReportActionsList(); + + // Then the cover hides LegendList's empty initial viewport until its actions are ready + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onLoad?.()); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the positioned list mounted when sending a message clears the linked action', async () => { + // Given a linked message has finished its initial positioning. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + + // When the live-tail jump clears the link, a new mount would have to position again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the existing list stays visible without another initial skeleton. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it.each(['2', 'another-action'])('positions a newly opened link after the previous link was cleared: %s', async (reportActionID) => { + // Given a positioned linked list is kept mounted when returning to its latest messages. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + + // When the reader opens a link, even if it targets the same action again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then fresh positioning is covered until the new list is ready. + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('releases the initial viewport cover after a terminal OpenReport failure', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial viewport covered while OpenReport is queued despite stale stored loading state', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + mockUseIsReportLoadPending.mockReturnValue(false); + view.rerender( + , + ); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial actions visible until the hydrated page is complete', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); + const view = renderReportActionsList(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(false); + + const snapshotActions = getCapturedVisibleActions(); + const oldestSnapshotAction = snapshotActions?.at(0); + if (!oldestSnapshotAction || !snapshotActions) { + throw new Error('Expected the initial action snapshot to remain visible'); + } + getRenderedReportActionsListItemProps(oldestSnapshotAction); + expect(groupingSpy).toHaveBeenLastCalledWith(snapshotActions.toReversed(), snapshotActions.length - 1, false); + groupingSpy.mockRestore(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length + 1); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); + }); + + it.each([ + {isOffline: true, isLoadingInitialReportActions: true}, + {isOffline: false, isLoadingInitialReportActions: false}, + ])('keeps actions live after a first load cannot complete: %p', async ({isOffline, isLoadingInitialReportActions}) => { + // Given cached actions with no successful OpenReport response + mockUseNetwork.mockReturnValue({isOffline}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = isLoadingInitialReportActions; + const view = renderReportActionsList(); + + // When an optimistic action is added while offline or after a terminal load failure + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [ + { + ...existingAction, + reportActionID: 'new-comment', + created: '2023-01-03', + }, + ...mockReportActions, + ], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the new action appears without waiting for hydration + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('new-comment'); + }); + + it('keeps offline additions visible when reconnecting during the initial load', async () => { + // Given a cached report whose initial request is still pending. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + const view = renderReportActionsList(); + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + const renderList = () => + view.rerender( + , + ); + + // When an action is added offline and the connection returns before hydration. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [{...existingAction, reportActionID: 'offline-comment', created: '2023-01-03'}, ...mockReportActions], + }); + renderList(); + await waitForBatchedUpdatesWithAct(); + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + mockUseNetwork.mockReturnValue({isOffline: false}); + renderList(); + await waitForBatchedUpdatesWithAct(); + + // Then the list does not revert to the earlier cached snapshot. + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + }); + + it('keeps a hydrated list mounted when its RAM-only loading entry briefly regresses', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(1500); + expect(listProps?.recycleItems).toBe(true); + expect(listProps?.experimental_hideItemsUntilMeasured).toBe(true); + }); + + it('allows visible report action menus to overflow their LegendList item containers', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const hiddenAction = mockReportActions.at(0); + const visibleAction = mockReportActions.at(1); + if (!hiddenAction || !visibleAction) { + throw new Error('Expected report action fixtures'); + } + + act(() => { + getCapturedListProps()?.onViewableItemsChanged?.({ + changed: [ + {containerId: 1, index: 1, isViewable: true, item: visibleAction, key: '1'}, + {containerId: 0, index: 0, isViewable: false, item: hiddenAction, key: '0'}, + ], + end: 1, + endBuffered: 1, + start: 0, + startBuffered: 0, + viewableItems: [], + }); + }); + + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledTimes(1); + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledWith(expect.objectContaining({scrollToEnd: mockLegendScrollToEnd}), 1); + }); + + it('groups comments by layout characteristics for measurement estimates', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const getItemType = getCapturedListProps()?.getItemType; + const comment = mockReportActions.at(1); + if (!comment) { + throw new Error('Expected comment report action fixture'); + } + + expect(getItemType?.(comment)).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-short`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'medium-comment', + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(320)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(1200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'extra-long-comment', + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1201)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'attachment', + isAttachmentOnly: true, + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-attachment`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'link-preview', + linkMetadata: [{url: 'https://example.com'}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); + }); + + it('loads older pages when LegendList reaches the start and deduplicates the scroll fallback', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + const view = renderReportActionsList(); + + const listProps = getCapturedListProps(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + act(() => { + listProps?.onStartReached?.(); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + expect(listProps?.onStartReachedThreshold).toBe(0.75); + + act(() => { + listProps?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + mockIsLoadingOlderReportActions = true; + view.rerender( + , + ); + mockIsLoadingOlderReportActions = false; + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + hasOlderActions: true, + }); + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); + }); + describe('Concierge Feedback Prompt', () => { beforeEach(() => { mockUseNetwork.mockReturnValue({isOffline: false}); @@ -850,6 +1841,66 @@ describe('ReportActionsList (body)', () => { }); describe('Open-report telemetry', () => { + beforeEach(() => mockUseIsFocused.mockReturnValue(true)); + + it('ends the warm span when the positioned content replaces its cover', () => { + // Given cached messages whose initial list layout is still covered. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}}))); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + + // When LegendList finishes positioning and the cover is removed. + act(() => getCapturedListProps()?.onReady?.()); + + // Then telemetry records visible content once, rather than the earlier hidden layout. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 400}}}))); + expect(mockMarkOpenReportEnd).toHaveBeenCalledTimes(1); + }); + + it('does not wait for a background OpenReport request when reopening a hydrated report', () => { + // Given a report loaded earlier in this app session with a refresh still in flight. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = true; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + // When the cached list finishes positioning, before that request completes. + act(() => getCapturedListProps()?.onReady?.()); + + // Then the cover clears and visible-content telemetry ends immediately. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + + it('ends a new warm span when an already-positioned report regains focus', () => { + // Given a positioned report that remains mounted while another screen has focus. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(false); + const view = renderReportActionsList(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); + + // When navigation returns without another list layout or OpenReport response. + mockUseIsFocused.mockReturnValue(true); + view.rerender( + , + ); + + // Then the visible cached content completes the new navigation span. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + it('fires markOpenReportEnd with warm:false while the initial skeleton shows', () => { mockUseNetwork.mockReturnValue({isOffline: false}); @@ -899,22 +1950,6 @@ describe('ReportActionsList (body)', () => { }); }); - describe('Pending scroll-to-bottom', () => { - it('flushes a pending scroll-to-bottom when the list content size changes', () => { - // Given the list is rendered - mockUseNetwork.mockReturnValue({isOffline: false}); - renderReportActionsList(); - - // When the list content size changes, as it does once FlashList lays out a newly sent comment. In an - // aligned-to-top report this is the only list event that follows, because onLayout does not fire. - getCapturedListProps()?.onContentSizeChange?.(); - - // Then the pending scroll-to-bottom is flushed, so the list lands on the new comment - const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {flushPendingScrollToBottom: jest.Mock}; - expect(scrollHookResult.flushPendingScrollToBottom).toHaveBeenCalledTimes(1); - }); - }); - describe('Concierge Side Panel', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; @@ -988,10 +2023,10 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.length).toBeGreaterThanOrEqual(1); - expect(passedActions?.at(0)?.reportActionID).toBe(CONST.CONCIERGE_GREETING_ACTION_ID); + expect(passedActions?.some((action) => action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); }); it('should not show welcome state when not in side panel', () => { @@ -1055,7 +2090,7 @@ describe('ReportActionsList (body)', () => { // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); // ReportActionsList should be rendered with filtered actions - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); }); }); @@ -1146,7 +2181,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions, !hasOnceLoadedReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -1172,7 +2207,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -1192,7 +2227,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -1229,7 +2264,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'new-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); @@ -1251,7 +2286,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -1269,12 +2304,228 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); + it('keeps a short Concierge session at its visible tail when Show History reveals older actions', () => { + // Given a short session at its latest action, with history filtered out. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals the older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then all actions are available while the latest session stays in view. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'old-user-msg')).toBe(true); + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it.each([false, true])('only follows a delayed Show History page while the reader stays at the end (scrolls away: %s)', (scrollsAway) => { + // Given a Concierge session with more history to load after the initially revealed actions. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: true}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + const flushAnimationFrames = () => { + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + }; + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When Show History reveals the loaded actions and an older page arrives afterward. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + flushAnimationFrames(); + mockLegendScrollToEnd.mockClear(); + + if (scrollsAway) { + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 200}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + } + + const oldUserAction = oldReportActions.at(1); + if (!oldUserAction) { + throw new Error('Expected the older Concierge action'); + } + const olderAction = {...oldUserAction, reportActionID: 'older-user-msg', created: '2022-06-15 10:00:00.000'}; + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: [...oldReportActions, olderAction], hasOlderActions: false}); + view.rerender( + , + ); + flushAnimationFrames(); + + // Then loading the page respects the reader's choice to stay at the end or read history. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'older-user-msg')).toBe(true); + if (scrollsAway) { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } else { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it('does not pull a slightly scrolled Concierge chat to the bottom when Show History is pressed', () => { + // Given the reader is above the latest message but still within the generic action-visible threshold. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + mockLegendScrollToEnd.mockClear(); + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + + // Then the reader's offset is left to LegendList's visible-content preservation. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + it('should show all actions unfiltered when user sends a message in current session', () => { setupMainDMConciergeMocks(); @@ -1302,7 +2553,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // After user sends a message, the greeting stays visible alongside session actions expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); @@ -1320,7 +2571,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // With no session, old messages should not be shown expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -1366,7 +2617,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // New user with no prior messages — onboarding messages pass through (no filtering) expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); @@ -1389,9 +2640,10 @@ describe('ReportActionsList (body)', () => { expect(mockStartSession).toHaveBeenCalled(); }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { + it('should cover cached actions until the refreshed report finishes hydrating', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. setupMainDMConciergeMocks(SESSION_START, false, false); mockUsePaginatedReportActions.mockReturnValue({ @@ -1402,8 +2654,8 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockLegendList).toHaveBeenCalled(); }); it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { diff --git a/tests/unit/ActionListContextProviderTest.tsx b/tests/unit/ActionListContextProviderTest.tsx index 37033b6ad9e5..d255306e5cdc 100644 --- a/tests/unit/ActionListContextProviderTest.tsx +++ b/tests/unit/ActionListContextProviderTest.tsx @@ -1,8 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import {ActionListContextProvider, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; /** * `ActionListContextProvider` owns a private holder and exposes `registerListRef(ref)` / `getListRef()` @@ -18,7 +17,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns the same ref object after registerListRef(ref)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); @@ -29,7 +28,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns null again after registerListRef(null) (unmount path)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); diff --git a/tests/unit/ComposerScrollEventsTest.tsx b/tests/unit/ComposerScrollEventsTest.tsx new file mode 100644 index 000000000000..cced2c8752dc --- /dev/null +++ b/tests/unit/ComposerScrollEventsTest.tsx @@ -0,0 +1,156 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import FlashList from '@components/FlashList'; +import MVCPFlatList from '@components/FlatList/FlatList'; +import type * as MVCPList from '@components/FlatList/FlatList'; +import ActiveHoverable from '@components/Hoverable/ActiveHoverable'; +import type * as KeyboardList from '@components/KeyboardDismissibleFlatList'; +import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList/index'; + +import type * as ComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; + +import CONST from '@src/CONST'; + +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps, ViewProps} from 'react-native'; +import type * as Reanimated from 'react-native-reanimated'; + +import React from 'react'; +import {DeviceEventEmitter, View} from 'react-native'; + +jest.mock('@hooks/useEmitComposerScrollEvents', () => jest.requireActual('@hooks/useEmitComposerScrollEvents/index.ts')); +jest.mock('@components/KeyboardDismissibleFlatList/index', () => jest.requireActual('@components/KeyboardDismissibleFlatList/index.tsx')); +jest.mock('@components/FlatList/FlatList', () => jest.requireActual('@components/FlatList/FlatList/index.tsx')); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@shopify/flash-list', () => ({ + FlashList: (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; + }, +})); +jest.mock('@components/AnimatedFlatListWithCellRenderer', () => (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; +}); +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useAnimatedScrollHandler: ({onScroll}: {onScroll: (event: NativeSyntheticEvent) => void}) => onScroll, + useComposedEventHandler: (handlers: Array<((event: NativeSyntheticEvent) => void) | null>) => (event: NativeSyntheticEvent) => { + for (const handler of handlers) { + handler?.(event); + } + }, +})); + +const scrollEvent = { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {width: 300, height: 1000}, + layoutMeasurement: {width: 300, height: 500}, + }, +}; + +function HoverTarget({ref, ...props}: ViewProps & {ref?: React.Ref}) { + // Supply the DOM ref used by web hover handling while rendering through the native test renderer. + React.useImperativeHandle(ref, () => document.createElement('div'), []); + return ; +} + +describe('Composer scroll events on shared lists', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s suppresses hover throughout scrolling and forwards the scroll event', (_name, List) => { + // Given an inverted list that already emits composer scroll events. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + + // When scrolling continues, hover must remain suppressed until the last event settles. + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, true); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, false); + + // Then hover resumes once, after scrolling ends. + act(() => jest.advanceTimersByTime(50)); + expect(emit.mock.calls.filter(([event]) => event === CONST.EVENTS.SCROLLING)).toEqual([ + [CONST.EVENTS.SCROLLING, true], + [CONST.EVENTS.SCROLLING, false], + ]); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s forwards non-inverted scrolling without changing global hover state', (_name, List) => { + // Given a non-inverted shared list and an unrelated hovered message. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted={false} + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + + // When that list scrolls and its scroll-end timer would have settled. + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + act(() => jest.advanceTimersByTime(250)); + + // Then its callback still runs without broadcasting a report scroll or clearing hover. + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, expect.anything()); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); +}); diff --git a/tests/unit/LegendListItemOverflowTest.ts b/tests/unit/LegendListItemOverflowTest.ts new file mode 100644 index 000000000000..d6559318af66 --- /dev/null +++ b/tests/unit/LegendListItemOverflowTest.ts @@ -0,0 +1,35 @@ +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +import createMock from '../utils/createMock'; + +describe('allowLegendListItemOverflow', () => { + it('removes paint containment from a web item container', () => { + const style = {contain: 'paint layout style'}; + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({style}), + }), + }); + + allowLegendListItemOverflow(list, 3); + expect(style.contain).toBe('layout style'); + }); + + it('does nothing for a native item container', () => { + const setNativeProps = jest.fn(); + const itemContainer = {setNativeProps}; + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => itemContainer, + }), + }); + + allowLegendListItemOverflow(list, 3); + expect(itemContainer).toEqual({setNativeProps}); + expect(setNativeProps).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index d8ce64ac8449..4408d9f440cb 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,38 +30,47 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; -type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type ScrollEvent = { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; +}; type CapturedListProps = { - maintainVisibleContentPosition?: {disabled: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`, whose -// `disabled` flag is `!(hasScrolledOverThreshold || shouldFocusToTopOnMount)`. With no deep-link the latter -// is false, so `!disabled` mirrors the boolean under test. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the -// value on the list's very first render — the property that matters, since it must be right before any effect runs. -let mockMvcpHistory: Array = []; - -// `!disabled` from the captured `maintainVisibleContentPosition`, or `undefined` before the list first renders. -function isMvcpEnabled() { - const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; -} +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('@components/FlashList/InvertedFlashList', () => { +jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); return { - __esModule: true, - default: forwardRef((props) => { + // 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; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); return null; }), }; }); +function createScrollEvent(distanceFromBottom: number): ScrollEvent { + const contentHeight = 1000; + const viewportHeight = 500; + return { + nativeEvent: { + contentOffset: {x: 0, y: contentHeight - viewportHeight - distanceFromBottom}, + contentSize: {height: contentHeight, width: 300}, + layoutMeasurement: {height: viewportHeight, width: 300}, + }, + }; +} + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -114,13 +123,14 @@ async function renderList(initialOffset: number) { , ); - await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); + await waitFor(() => expect(capturedListProps.onScroll).toBeDefined()); return utils; } beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; + mockUseUnreadMarker.mockClear(); + mockUseMarkAsRead.mockClear(); setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -145,34 +155,43 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList hasScrolledOverThreshold', () => { - it('enables maintainVisibleContentPosition on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList visible-action threshold', () => { + it('seeds unread marker and mark-as-read state from a saved offset past the threshold', async () => { + // Given a report restored away from the newest action. await renderList(THRESHOLD + 50); - // Must be true on the FIRST render, not merely after an effect settles — deferring this to an effect - // would let the mount-time mark-as-read path observe a wrong `isScrolledToEnd`. - expect(mockMvcpHistory.at(0)).toBe(true); - expect(isMvcpEnabled()).toBe(true); + // Then neither consumer treats the report as scrolled to the end before onScroll fires. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); }); - it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + it('seeds both consumers as being at the newest action when the saved offset is zero', async () => { + // Given a report restored at its newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(false); + // Then both consumers receive the bottom state immediately. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); - it('flips the flag as the user scrolls across the threshold', async () => { + it('updates both consumers when scrolling across the threshold', async () => { + // Given a report initially at the newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(false); + // When the reader scrolls away from the newest action. act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); - expect(isMvcpEnabled()).toBe(true); + // Then the unread marker sees the reader as away and mark-as-read pauses. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); + // When the reader returns to the newest action. act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(false); + // Then both consumers see the bottom state again. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); }); diff --git a/tests/unit/ThumbnailImageTest.tsx b/tests/unit/ThumbnailImageTest.tsx new file mode 100644 index 000000000000..e98a1b0284b4 --- /dev/null +++ b/tests/unit/ThumbnailImageTest.tsx @@ -0,0 +1,61 @@ +import {act, render} from '@testing-library/react-native'; + +import ThumbnailImage from '@components/ThumbnailImage'; + +import React from 'react'; + +const mockUseThumbnailDimensions = jest.fn((width: number, height: number) => ({thumbnailDimensionsStyles: {width, height}})); +let mockOnMeasure: ((dimensions: {width: number; height: number}) => void) | undefined; + +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useStyleUtils', () => () => ({})); +jest.mock('@hooks/useTheme', () => () => ({border: '#000'})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useThumbnailDimensions', () => (width: number, height: number) => mockUseThumbnailDimensions(width, height)); +jest.mock('@components/ImageWithSizeCalculation', () => ({ + __esModule: true, + default: ({onMeasure}: {onMeasure: typeof mockOnMeasure}) => { + mockOnMeasure = onMeasure; + return null; + }, +})); + +describe('ThumbnailImage', () => { + beforeEach(() => { + mockUseThumbnailDimensions.mockClear(); + mockOnMeasure = undefined; + }); + + it('uses dimensions for the current URL when a recycled row shows another image', () => { + // Given a measured thumbnail for the first image. + const view = render( + , + ); + act(() => mockOnMeasure?.({width: 400, height: 100})); + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(400, 100); + + // When the same component instance is reused for a second image. + view.rerender( + , + ); + + // Then the second image starts with its own dimensions, rather than the first image's. + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(200, 200); + + // When the first image returns, its measured dimensions are restored from the URL cache. + view.rerender( + , + ); + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(400, 100); + }); +}); diff --git a/tests/unit/focusComposerWithDelayTest.ts b/tests/unit/focusComposerWithDelayTest.ts index 9be650067e15..f720c489fc2f 100644 --- a/tests/unit/focusComposerWithDelayTest.ts +++ b/tests/unit/focusComposerWithDelayTest.ts @@ -52,6 +52,50 @@ describe('focusComposerWithDelay', () => { expect(focus).toHaveBeenCalled(); }); + + it('focuses an editor that mounts after the delayed focus request', async () => { + // Given an edit transition that requests focus before the input ref is attached. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = null; + + // When the editor mounts before the focus delay ends. + await focusComposerWithDelay(() => currentInput)(true); + currentInput = input; + jest.runAllTimers(); + + // Then the mounted editor receives focus instead of losing the request. + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not focus an editor that unmounts before the delay ends', async () => { + // Given a focus request for an editor that is still mounted. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = input; + await focusComposerWithDelay(() => currentInput)(true); + + // When navigation removes the editor before the delayed request runs. + currentInput = null; + jest.runAllTimers(); + + // Then the stale input does not take focus back. + expect(focus).not.toHaveBeenCalled(); + }); + + it('focuses the replacement editor instead of the old input', async () => { + // Given a pending focus request while the composer is changing its layout. + const original = createInput(); + const replacement = createInput(); + let currentInput = original.input; + await focusComposerWithDelay(() => currentInput)(true); + + // When the input ref changes before the focus delay ends. + currentInput = replacement.input; + jest.runAllTimers(); + + // Then only the current editor receives focus. + expect(original.focus).not.toHaveBeenCalled(); + expect(replacement.focus).toHaveBeenCalledTimes(1); + }); }); describe('focusComposerWithDelay command categories', () => { diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index e945ebf00b19..786323f255c7 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -21,7 +21,7 @@ jest.mock('@libs/actions/Report', () => { return { ...actual, editReportComment: jest.fn(), - clearReportActionDrafts: jest.fn(), + clearAllReportActionDrafts: jest.fn(), }; }); @@ -58,10 +58,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); -const mockScrollToIndex = jest.fn(); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: mockScrollToIndex}), + default: () => ({scrollToBottom: mockScrollToBottom}), })); jest.mock('@libs/ReportUtils', () => { @@ -99,6 +99,7 @@ describe('useEditMessage', () => { await Onyx.clear(); }); jest.clearAllMocks(); + jest.restoreAllMocks(); }); function renderUseEditMessage(overrides?: Partial) { @@ -144,9 +145,11 @@ describe('useEditMessage', () => { expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); - it('scrolls to index zero after deleting the newest message draft without a list-specific callback', () => { + it('scrolls to the bottom after deleting the newest message draft without a list-specific callback', () => { + // Given the newest message uses the list registered with the scroll manager. const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + // When deleting the draft closes its editor. act(() => { hook.result.current.publishDraft(' '); }); @@ -154,13 +157,16 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(0); + // Then the list receives the request immediately and owns its layout scheduling. + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('uses the list-specific scroll after deleting the newest message draft', () => { + // Given the list supplies its own edit-scroll request handler. const scrollToLastMessage = jest.fn(); const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true, scrollToLastMessage}); + // When deleting the draft closes its editor. act(() => { hook.result.current.publishDraft(' '); }); @@ -168,8 +174,21 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); + // Then scheduling belongs to that list instead of a callback on the unmounting editor. expect(scrollToLastMessage).toHaveBeenCalledTimes(1); - expect(mockScrollToIndex).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); + + it('requests the newest action after saving without scheduling an editor frame', () => { + // Given a draft on the newest action. + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + // When the edit is submitted and the editor closes. + act(() => hook.result.current.publishDraft('Updated message')); + expect(mockEditReportComment).toHaveBeenCalledTimes(1); + + // Then the list already owns the scroll request before the editor can unmount. + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('does not scroll after deleting a non-newest message draft', () => { @@ -181,6 +200,6 @@ describe('useEditMessage', () => { }); expect(scrollToLastMessage).not.toHaveBeenCalled(); - expect(mockScrollToIndex).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts index 38e5c20713bb..4263e110fcf5 100644 --- a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts +++ b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts @@ -20,6 +20,7 @@ let mockCurrentPermission: NotificationPermissionStatus = 'default'; jest.mock('@libs/Notification/notificationPermission', () => ({ __esModule: true, default: { + getStatusSync: () => mockCurrentPermission, getStatus: () => Promise.resolve(mockCurrentPermission), request: () => Promise.resolve(mockCurrentPermission), }, @@ -69,4 +70,15 @@ describe('useShouldShowEnableNotificationsBanner', () => { const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); await waitFor(() => expect(result.current).toBe(true)); }); + + it('shows the Concierge banner on the initial render so the chat opens with its final viewport height', () => { + // Given notification permission has not been decided and the Concierge report is already known. + mockCurrentPermission = 'default'; + + // When Concierge opens, the list and footer measure their initial layout together. + const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); + + // Then the banner must occupy space before the list positions itself at the latest message. + expect(result.current).toBe(true); + }); }); diff --git a/tests/unit/libs/reportActionSubscribersTest.ts b/tests/unit/libs/reportActionSubscribersTest.ts index 262557d19a03..da2401146bda 100644 --- a/tests/unit/libs/reportActionSubscribersTest.ts +++ b/tests/unit/libs/reportActionSubscribersTest.ts @@ -7,7 +7,7 @@ describe('subscribeToNewActionEvent', () => { notifyNewAction('1', undefined, true); - expect(callback).toHaveBeenCalledWith(true, undefined); + expect(callback).toHaveBeenCalledWith(true, undefined, 'local'); unsubscribe(); }); @@ -56,4 +56,17 @@ describe('subscribeToNewActionEvent', () => { expect(callback).not.toHaveBeenCalled(); }); + + test('identifies realtime notifications separately from local actions', () => { + // Given a subscriber listening to the current report + const callback = jest.fn(); + const unsubscribe = subscribeToNewActionEvent('1', callback); + + // When a realtime echo arrives without an action payload + notifyNewAction('1', undefined, true, 'realtime'); + + // Then the subscriber can distinguish it from a local money request + expect(callback).toHaveBeenCalledWith(true, undefined, 'realtime'); + unsubscribe(); + }); }); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index eb84959a583f..1f159dd81367 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -3,17 +3,17 @@ import shouldFollowActionBadgeTarget from '@pages/inbox/report/shouldFollowActio const BASE_PARAMS = { actionTargetReportActionID: '200', prevActionTargetReportActionID: '100', - actionBadgeTargetIndex: 2, + actionBadgeTargetIndex: 7, prevActionBadgeTargetIndex: 5, }; describe('shouldFollowActionBadgeTarget', () => { - it('follows the target when it advances to a newer (lower-index) preview', () => { + it('follows the target when it advances to a newer (higher-index) preview', () => { expect(shouldFollowActionBadgeTarget(BASE_PARAMS)).toBe(true); }); - it('does not follow when the target moves to an older (higher-index) preview, e.g. while paginating', () => { - expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 7})).toBe(false); + it('does not follow when the target moves to an older (lower-index) preview, e.g. while paginating', () => { + expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 2})).toBe(false); }); it('does not follow when the target index is unchanged', () => { diff --git a/tests/unit/useMoneyRequestReportScrollTest.tsx b/tests/unit/useMoneyRequestReportScrollTest.tsx index 8c2e0270e4e3..95cad9fd3073 100644 --- a/tests/unit/useMoneyRequestReportScrollTest.tsx +++ b/tests/unit/useMoneyRequestReportScrollTest.tsx @@ -31,13 +31,12 @@ const BOTTOM_OFFSET_AT_TOP = LIST_CONTENT_HEIGHT - LIST_LAYOUT_HEIGHT; const LAST_ITEM_INDEX = 4; const mockScrollToIndex = jest.fn(); -const mockScrollToEnd = jest.fn(); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ scrollToIndex: mockScrollToIndex, - scrollToEnd: mockScrollToEnd, - scrollToBottom: jest.fn(), + scrollToBottom: mockScrollToBottom, scrollToOffset: jest.fn(), }), })); @@ -559,7 +558,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the list reveals the indicator, which lives in the footer below the row a last-item jump targets - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should not scroll for the thinking indicator while the list is away from the bottom', async () => { @@ -576,7 +575,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the reading position is untouched - expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); @@ -589,7 +588,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When the thinking label changes, which re-renders the list many times per run rerender(buildParams()); @@ -600,7 +599,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the viewport is not yanked down again - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should scroll again for a new thinking-indicator run', async () => { @@ -612,7 +611,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When that run ends and another one starts mockCandidateAgentIDs = []; @@ -626,7 +625,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the new run's indicator is revealed too - expect(mockScrollToEnd).toHaveBeenCalledTimes(2); + expect(mockScrollToBottom).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 1264d0ba9eac..e9fd2cbe88ee 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -13,7 +13,7 @@ const mockNavigationSetParams = jest.fn(); const mockGlobalSetParams = jest.fn(); const mockOpenReport = jest.fn(); const mockLogAlert = jest.fn(); -let newActionHandler: ((isFromCurrentUser: boolean, action?: ReportAction) => void) | undefined; +let newActionHandler: ((isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) | undefined; jest.mock('@libs/Log', () => ({ __esModule: true, @@ -73,7 +73,7 @@ jest.mock('@libs/actions/Report', () => ({ })); jest.mock('@libs/actions/Report/reportActionSubscribers', () => ({ - subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action?: ReportAction) => void) => { + subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) => { newActionHandler = callback; return jest.fn(); }, @@ -84,7 +84,6 @@ type HookParams = Parameters[0]; const reportScrollManager = { scrollToBottom: jest.fn(), scrollToIndex: jest.fn(), - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }; @@ -103,7 +102,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -122,15 +121,113 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it('requests one post-render scroll once the sent comment is rendered', () => { + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); + + act(() => { + newActionHandler?.(true, action, 'local'); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + }); + + it('clears a pending sent-comment scroll when switching reports', () => { + // Given a sent action that has not entered the rendered page yet. + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const params = buildParams({hasNewerActions: false, hasNewestReportAction: true}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + act(() => newActionHandler?.(true, action, 'local')); + + // When the reader switches reports and returns before the timeout, with the row now loaded. + rerender({...params, reportID: '2'}); + rerender({...params, renderedVisibleReportActions: [action]}); + + // Then the old send request does not reclaim the viewport. + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + + it('does not scroll for a payload-less realtime echo', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, undefined, 'realtime'); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + it('keeps the live-tail jump for a local action without a report action payload', () => { + // Given a report showing an older page + renderHook(() => useReportActionsNewActionLiveTail(buildParams())); + + // When a local money request sends a payload-less notification + act(() => { + newActionHandler?.(true, undefined, 'local'); + }); + + // Then the report opens its latest page + expect(mockOpenReport).toHaveBeenCalledTimes(1); + }); + + it('expires a sent-comment scroll request when the action never enters the list', () => { + // Given a sent action that is filtered out or fails to appear + jest.useFakeTimers(); + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); + act(() => { + newActionHandler?.(true, action, 'local'); + }); + + // When the action finally appears well after the send attempt + act(() => { + jest.advanceTimersByTime(10000); + }); + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + // Then the stale request does not pull the reader to the bottom + expect(result.current.isScrollToBottomEnabled).toBe(false); + jest.useRealTimers(); + }); + + it('does not queue a bottom scroll that would compete with a report-preview target', () => { + const preview = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW}); + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [preview]}))); + + act(() => { + newActionHandler?.(true, preview, 'local'); + }); + + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('shows the cached newest window when sending offline from a linked action', () => { - // Given an older linked action while offline + // Given an older linked action while offline. const params = buildParams({isOffline: true, linkedReportActionID: '2'}); - const {result} = renderHook(() => useReportActionsNewActionLiveTail(params)); - // When the current user sends a comment - act(() => newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}))); - // Then reveal the cached live tail and release the override after scrolling + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When the current user sends a comment, release the old pagination anchor without a request. + act(() => newActionHandler?.(true, action, 'local')); expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(true); expect(mockNavigationSetParams.mock.calls).toEqual([[{reportActionID: ''}]]); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + // Then scroll only after the chronological list contains the sent comment, and release the override afterwards. + rerender({...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true, renderedVisibleReportActions: [action]}); + expect(result.current.isScrollToBottomEnabled).toBe(true); act(() => result.current.completeLiveTailPruneAfterScrollToBottom()); expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(false); }); @@ -140,7 +237,7 @@ describe('useReportActionsNewActionLiveTail', () => { renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({conciergeChat})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({conciergeChat})); @@ -150,7 +247,7 @@ describe('useReportActionsNewActionLiveTail', () => { renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({isSelfTourViewed: true, hasCompletedGuidedSetupFlow: false})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); // The onboarding flags are threaded straight through so guided-setup optimistic data is derived from real Onyx @@ -162,7 +259,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams()}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(mockOpenReport).toHaveBeenCalledTimes(1); @@ -185,7 +282,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(() => @@ -207,7 +304,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); rerender( @@ -226,19 +323,21 @@ describe('useReportActionsNewActionLiveTail', () => { it('scrolls straight to the bottom when an unread marker is set but the newest actions are already loaded', () => { // Given a report scrolled up with an unread marker from another user's comment, and the newest actions already loaded const setIsFloatingMessageCounterVisible = jest.fn(); - const {result} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, setIsFloatingMessageCounterVisible}), }); // When the current user sends a comment act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, action, 'local'); }); - // Then the list scrolls to the bottom directly, without an openReport round trip + // Then the caller receives a scroll request once the optimistic row renders, without an openReport round trip. expect(mockOpenReport).not.toHaveBeenCalled(); expect(setIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + rerender(buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); expect(result.current.isScrollToBottomEnabled).toBe(true); }); @@ -250,7 +349,7 @@ describe('useReportActionsNewActionLiveTail', () => { // When the current user sends a comment act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); // Then openReport fetches the newest actions before the list scrolls diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index af87b65e8e5a..cbca996a25c5 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -13,8 +13,10 @@ import type {ReportAction} from '@src/types/onyx'; import type {ReactNode} from 'react'; import React from 'react'; +import {Platform} from 'react-native'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -33,12 +35,13 @@ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameR // --- useReportScrollManager --- const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); +let mockLinkedActionPosition = 40; +let mockListScroll: number | undefined; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ scrollToBottom: mockScrollToBottom, scrollToIndex: mockScrollToIndex, - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }), })); @@ -48,6 +51,7 @@ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockSetIsActionBadgeAboveViewport = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; let mockShouldDisablePillTracking: boolean | undefined; @@ -62,6 +66,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ setIsActionBadgeAboveViewport: mockSetIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }; }, })); @@ -79,12 +84,6 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ }), })); -// --- useScrollToEndOnNewMessageReceived --- -jest.mock('@hooks/useScrollToEndOnNewMessageReceived', () => ({ - __esModule: true, - default: jest.fn(), -})); - // --- TransitionTracker --- const mockTransitionCallbacks: Array<() => void> = []; jest.mock('@libs/Navigation/TransitionTracker', () => ({ @@ -92,7 +91,15 @@ jest.mock('@libs/Navigation/TransitionTracker', () => ({ default: { runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { mockTransitionCallbacks.push(callback); - return {cancel: jest.fn()}; + return { + cancel: jest.fn(() => { + const index = mockTransitionCallbacks.indexOf(callback); + if (index < 0) { + return; + } + mockTransitionCallbacks.splice(index, 1); + }), + }; }), }, })); @@ -125,11 +132,13 @@ jest.mock('@userActions/Report', () => ({ // --- react-navigation route --- let mockRouteParams: {reportActionID?: string; backTo?: string; shouldScrollToLatest?: string} = {}; +let mockIsFocused = true; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useRoute: () => ({params: mockRouteParams}), + useIsFocused: () => mockIsFocused, }; }); @@ -181,6 +190,7 @@ function makeAction(reportActionID: string, overrides: Partial = { function buildParams(overrides: Partial = {}): ScrollParams { return { + listID: REPORT_ID, reportID: REPORT_ID, conciergeChat: undefined, report: createMockReport({reportID: REPORT_ID}), @@ -189,13 +199,11 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -206,7 +214,19 @@ function buildParams(overrides: Partial = {}): ScrollParams { // Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. function buildActionListContextValue() { - return {scrollOffsetRef: mockScrollOffsetRef, getScrollOffset: () => mockScrollOffsetRef.current, registerListRef: () => {}, getListRef: () => null}; + return { + scrollOffsetRef: mockScrollOffsetRef, + getScrollOffset: () => mockScrollOffsetRef.current, + registerListRef: () => {}, + getListRef: () => ({ + current: { + scrollToIndex: mockScrollToIndex, + scrollToEnd: mockScrollToBottom, + scrollToOffset: jest.fn(), + getState: () => ({positionByKey: () => mockLinkedActionPosition, scroll: mockListScroll, contentLength: 5000, scrollLength: 600}), + }, + }), + }; } function wrapper({children}: {children: ReactNode}) { @@ -221,16 +241,12 @@ async function renderScroll(overrides: Partial = {}) { function flushTransitions() { act(() => { - for (const callback of mockTransitionCallbacks) { + for (const callback of mockTransitionCallbacks.splice(0)) { callback(); } }); } -function setReportLoadingState(value: {isLoadingInitialReportActions?: boolean; hasOnceLoadedReportActions?: boolean}) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, value); -} - describe('useReportActionsScroll', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -242,11 +258,17 @@ describe('useReportActionsScroll', () => { await waitForBatchedUpdates(); mockTransitionCallbacks.length = 0; mockRouteParams = {}; + mockLinkedActionPosition = 40; + mockListScroll = undefined; + mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockShouldDisablePillTracking = undefined; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -266,9 +288,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldFocusToTopOnMount).toBe(false); - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -277,8 +296,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(true); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); }); it('is aligned to top for a money request report', async () => { @@ -297,22 +314,42 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('bottom-aligns a linked report action when it is the final item', async () => { + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1}); + }); + + it('top-aligns a linked report action when later actions follow it', async () => { + // Given a linked action followed by another action in chronological order. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + // When the list chooses its initial position. + const {result} = await renderScroll({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + // Then the linked action is aligned below the header. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); - it('falls back to the unread marker action as the initial scroll key', async () => { + it('positions an unread marker at chronological index zero', async () => { + // Given the newest action is unread and can be taller than the viewport. + const unreadAction = makeAction(UNREAD_ACTION_ID); + // When opening the report at its unread boundary. const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, - sortedVisibleReportActions: [makeAction(UNREAD_ACTION_ID)], + sortedVisibleReportActions: [unreadAction], + renderedVisibleReportActions: [unreadAction], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + // Then the start of the action and its New marker remain visible. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -323,9 +360,8 @@ describe('useReportActionsScroll', () => { sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID, {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED})], }); - expect(result.current.initialScrollKey).toBeUndefined(); - // No key + aligned-to-top → focus to top. - expect(result.current.shouldFocusToTopOnMount).toBe(true); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); it('does not focus to top for a single-expense money request report opened from the X Replies link', async () => { @@ -336,7 +372,6 @@ describe('useReportActionsScroll', () => { // Still aligned to top so short reports keep their layout, but the mount position is the latest message. expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); expect(result.current.initialScrollIndex).toBeUndefined(); }); @@ -347,7 +382,321 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + }); + + it('does not clear the latest route parameter when it was never set', async () => { + // Given an ordinary report open without the X Replies flag. + mockRouteParams = {}; + + // When its scroll hook mounts. + await renderScroll(); + + // Then it leaves unrelated route parameters alone. + expect(mockSetParams).not.toHaveBeenCalledWith({shouldScrollToLatest: undefined}); + }); + }); + + describe('linked message positioning', () => { + it('lets Android preserve an already aligned linked message when a preceding row changes height', async () => { + // Given Android has already positioned the link and is preserving it during measurement. + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 2690; + mockListScroll = 2650; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When LegendList updates both the measured position and its pending native scroll adjustment. + mockLinkedActionPosition = 2460; + mockListScroll = 2420; + act(() => result.current.onItemSizeChanged({index: 0, previous: 500, size: 270})); + flushTransitions(); + + // Then another imperative scroll does not apply the same correction a second time. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // A later scroll event that really moves the link still receives a correction. + mockListScroll = 2190; + act(() => + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 2190}, contentSize: {width: 300, height: 5000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ), + ); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + platform.restore(); + }); + + 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}; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When a native layout adjustment moves its measured position beyond the viewport. + mockLinkedActionPosition = 1500; + const scrollEvent = createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 0}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }); + act(() => result.current.trackLinkedMessageScroll(scrollEvent)); + flushTransitions(); + + // Then the linked position is restored even without another row size notification. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => { + result.current.stopLinkedMessagePositioning(); + result.current.trackLinkedMessageScroll(scrollEvent); + }); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it.each([false, true])('preserves the previous viewport after returning from child screens, reader scrolled away: %s', async (didScrollAway) => { + // Given a linked parent stays mounted after its initial positioning, with or without a later manual scroll. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; + const params = buildParams({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalled(); + if (didScrollAway) { + act(() => result.current.stopLinkedMessagePositioning()); + } + mockScrollToIndex.mockClear(); + + // When the reader opens a thread, returns, then opens an attachment and returns again. + for (let visit = 0; visit < 2; visit++) { + mockIsFocused = false; + rerender(params); + mockIsFocused = true; + rerender(params); + flushTransitions(); + act(() => { + result.current.onItemSizeChanged({index: 0, previous: 80, size: 350}); + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 900}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ); + }); + flushTransitions(); + + // Then neither focus nor late measurements reclaim the viewport from the reader. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + } + }); + + it('positions a new link when an initially unfocused report first becomes active', async () => { + // Given a linked report has loaded behind another screen without ever taking focus. + mockIsFocused = false; + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // When the report becomes visible for the first time. + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the initial link still receives its first positioning pass. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, { + animated: false, + viewPosition: 0, + viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, + }); + }); + + it('positions a different link opened while the report is behind a child screen', async () => { + // Given a previously positioned linked report has lost focus to a child screen. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction(UNREAD_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockIsFocused = false; + rerender(params); + mockScrollToIndex.mockClear(); + + // When another message link targets the same report before it regains focus. + mockRouteParams = {reportActionID: UNREAD_ACTION_ID}; + rerender(params); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the new navigation request still positions its own highlighted message. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('repositions a linked PDF after its preview or preceding rows change height', async () => { + // Given the linked PDF is initially positioned using estimated row heights. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: actions})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When the preview above it finishes measuring, then the PDF itself expands. + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the measured target is positioned again, keeping its highlight in view. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('lets the reader scroll away before a pending preview correction runs', async () => { + // Given a preview resize has scheduled a linked-message correction. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + + // When the reader takes over scrolling before the navigation/layout callback runs. + act(() => result.current.stopLinkedMessagePositioning()); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 350, size: 500})); + rerender(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + flushTransitions(); + + // Then later measurements and ordinary rerenders do not reclaim the linked position. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it.each(['latest', 'badge'] as const)('stops linked corrections after explicitly jumping to the %s target', async (destination) => { + // Given a linked message still has late preview measurements to process. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')], actionBadgeTargetIndex: 1})); + act(() => result.current.onLoad()); + flushTransitions(); + + // When the reader selects another scroll destination. + act(() => { + if (destination === 'latest') { + result.current.scrollToBottomAndMarkReportAsRead(); + return; + } + result.current.scrollToActionBadgeTarget(); + }); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then a later measurement does not pull the list back to the old linked message. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not reposition for measurements below the linked message', async () => { + // Given the linked message is already positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When only a later message changes height. + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the reader's viewport is left in place. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('leaves following a new reply to the list after positioning a linked final action', async () => { + // Given the linked final action has been positioned at the bottom. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [linkedAction]})); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + mockScrollToIndex.mockClear(); + + // When a new reply arrives while normal end-following is active. + rerender(buildParams({renderedVisibleReportActions: [linkedAction, makeAction('999')]})); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then the linked action does not reclaim the viewport from the new reply. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('cancels pending positioning when the report loses focus', async () => { + // Given a linked action has a correction waiting for navigation to finish. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + + // When another report takes focus before the callback runs. + mockIsFocused = false; + rerender(params); + flushTransitions(); + + // Then the background list is not scrolled by a stale navigation callback. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('waits for the replacement list to be ready after hydration', async () => { + // Given the cached list is ready and its linked message has been positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When hydration replaces that list with a newly mounted instance. + rerender({...params, listID: `${REPORT_ID}:hydrated`}); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + act(() => result.current.onLoad()); + flushTransitions(); + + // Then the correction uses the replacement list only after it is ready. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + }); + }); + + describe('new-message following', () => { + it.each([ + {distance: 50, shouldFollow: true}, + {distance: 250, shouldFollow: false}, + {distance: 300, shouldFollow: false}, + ])('preserves the pixel threshold at $distance pixels from the newest action', async ({distance, shouldFollow}) => { + // Given a loaded latest page and the reader's current bottom-relative offset. + const {rerender} = await renderScroll(); + mockScrollOffsetRef.current = distance; + mockScrollToBottom.mockClear(); + const newestAction = makeAction('2'); + + // When another user's message is appended to the chronological list. + rerender(buildParams({sortedVisibleReportActions: [newestAction, makeAction('1')], renderedVisibleReportActions: [makeAction('1'), newestAction]})); + await waitForBatchedUpdatesWithAct(); + + // Then it follows only inside the same 250-pixel range used before the migration. + expect(mockScrollToBottom).toHaveBeenCalledTimes(shouldFollow ? 1 : 0); }); }); @@ -410,7 +759,7 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('leaves the pills to viewability tracking when the composer is not expanded', async () => { @@ -440,11 +789,28 @@ describe('useReportActionsScroll', () => { // Then both pills are hidden directly because the target can never become viewable to hide them, and the list still scrolls expect(mockSetIsActionBadgeAboveViewport).toHaveBeenCalledWith(false); expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); describe('pill tracking', () => { + it('refreshes pill visibility after the expanded composer collapses', async () => { + // Given pill tracking is suspended while the expanded composer covers the report. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, true); + await renderScroll(); + mockUpdatePillVisibility.mockClear(); + + // When the composer collapses and the report becomes visible again. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, false); + }); + await waitForBatchedUpdatesWithAct(); + + // Then tracking resumes and the pills reflect the current scroll position. + expect(mockShouldDisablePillTracking).toBe(false); + expect(mockUpdatePillVisibility).toHaveBeenCalledTimes(1); + }); + it('keeps scroll-based pill tracking enabled when the composer is not expanded', async () => { // Given the composer at its normal size and no linked message to position @@ -467,127 +833,40 @@ describe('useReportActionsScroll', () => { }); }); - describe('flushPendingScrollToBottom', () => { + describe('pending live-tail requests', () => { it('does nothing when scroll-to-bottom is not enabled', async () => { mockIsScrollToBottomEnabled = false; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + await renderScroll(); expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); }); - it('scrolls, disables itself and prunes when scroll-to-bottom is enabled', async () => { + it('consumes the request after render without waiting for a future viewport layout', async () => { mockIsScrollToBottomEnabled = true; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + const {rerender} = await renderScroll(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); - }); - }); - - describe('onLoad', () => { - it('does nothing when the list is not configured to focus to top on mount', async () => { - const {result} = await renderScroll(); - act(() => { - result.current.onLoad(); - }); - - // Stays disabled with no autoscroll threshold for a regular chat. - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); - }); - it('waits for the report actions to have loaded before disabling autoscroll-to-top', async () => { - mockIsTransactionThread = true; - // No loading state → onLoad bails. - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }); - - it('disables autoscroll-to-top after a frame once report actions have loaded', async () => { - mockIsTransactionThread = true; - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - - it('disables autoscroll-to-top when report actions finish loading after the list has mounted', async () => { - mockIsTransactionThread = true; - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Load completes after mount → companion effect turns autoscroll off. - await act(async () => { - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - await waitForBatchedUpdates(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); }); describe('effects', () => { - it('schedules an initial scroll-to-bottom on mount for a regular chat report', async () => { - await renderScroll(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - flushTransitions(); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to bottom on mount when there is an initial scroll key', async () => { - mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - - await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); - - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); + it('follows incoming messages through the shared pixel-based policy', async () => { + mockScrollOffsetRef.current = 0; - it('scrolls to bottom on mount for a single-expense money request report opened from the X Replies link', async () => { - mockIsMoneyRequestReport = true; - mockRouteParams = {shouldScrollToLatest: 'true'}; + const {rerender} = await renderScroll(); - await renderScroll(); - flushTransitions(); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); @@ -601,32 +880,15 @@ describe('useReportActionsScroll', () => { expect(mockSetParams).toHaveBeenCalledWith({shouldScrollToLatest: undefined}); }); - it('does not clear the X Replies flag when it was never set', async () => { - mockIsMoneyRequestReport = true; - - await renderScroll(); - - expect(mockSetParams).not.toHaveBeenCalled(); - }); - - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('does not schedule a competing scroll when a streamed draft grows', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); - - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); - }); - - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; - - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const draft = makeAction('2', {message: [{type: 'COMMENT', text: 'Hello', html: '

Hello

'}]}); + const {rerender} = await renderScroll({renderedVisibleReportActions: [makeAction('1'), draft]}); mockScrollToBottom.mockClear(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const updatedDraft: ReportAction = {...draft, message: [{type: 'COMMENT', text: 'Hello, here is the rest of the reply.', html: '

Hello, here is the rest of the reply.

'}]}; + rerender(buildParams({renderedVisibleReportActions: [makeAction('1'), updatedDraft]})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx index 6fcd7cb5c64a..5d9bdf09132b 100644 --- a/tests/unit/useReportScrollManagerTest.tsx +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -1,10 +1,9 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import useReportScrollManager from '@hooks/useReportScrollManager'; import {ActionListContext, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import type {ReactNode} from 'react'; @@ -21,20 +20,20 @@ function buildMockListRef() { scrollToIndex: jest.fn(), scrollToOffset: jest.fn(), scrollToEnd: jest.fn(), - getNativeScrollRef: jest.fn(() => undefined), + getNativeScrollRef: jest.fn(() => ({scrollToEnd: jest.fn()})), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const ref = {current: methods} as unknown as FlatListRefType; + const ref = {current: methods} as unknown as ActionListRefType; return {ref, methods}; } // Context value backed by a closure holder so registering a ref is visible to the manager's getListRef(). function buildContextValue() { - let held: FlatListRefType = null; + let held: ActionListRefType = null; return { scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, - registerListRef: (ref: FlatListRefType) => { + registerListRef: (ref: ActionListRefType) => { held = ref; }, getListRef: () => held, @@ -58,28 +57,32 @@ function renderManager() { describe('useReportScrollManager', () => { it('resolves the list ref at call time, not at init (register AFTER the manager is created)', () => { + // Given a manager created before its list ref is registered. const {result} = renderManager(); const {ref, methods} = buildMockListRef(); - // Register only after the manager hook has already run once. + // When the list registers and a scroll is requested afterward. act(() => { result.current.registerListRef(ref); }); act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); }); - // The ref was resolved lazily — the method on the later-registered ref fired. - expect(methods.scrollToEnd).toHaveBeenCalled(); + // Then the list handles the scroll so its virtualization and footer positioning stay active. + expect(methods.scrollToEnd).toHaveBeenCalledWith({animated: false}); + expect(methods.getNativeScrollRef).not.toHaveBeenCalled(); }); it('no-ops when no ref is registered', () => { + // Given a manager whose list has not mounted yet. const {result} = renderManager(); + // When any scroll command runs, then it tolerates the absent list. expect(() => { act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); result.current.manager.scrollToIndex(3); result.current.manager.scrollToOffset(100); result.current.manager.scrollToIndex(1, {animated: false}); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index e1cc995901d3..bce63d7038f5 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -227,6 +227,32 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(onUnreadActionVisibleLocalMockFn).toHaveBeenCalledTimes(1); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + + it('tracks an unread marker in a chronological list', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + unreadMarkerReportActionIndex: 5, + isInverted: false, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'reportActions_5', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisibleMockFn).toHaveBeenCalled(); + }); }); describe('latest messages pill in a read chat', () => { @@ -374,6 +400,28 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + it('returns isActionBadgeAboveViewport as true for a lower index above a chronological viewport', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + actionBadgeTargetIndex: 1, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + + expect(result.current.isActionBadgeAboveViewport).toBe(true); + }); + it('returns isActionBadgeAboveViewport as false when action badge target is visible in viewport', () => { const offsetRef = {current: 0}; const {result} = renderHook(() =>