diff --git a/cspell.json b/cspell.json index 0c0013f6d38f..3b6122ab5678 100644 --- a/cspell.json +++ b/cspell.json @@ -721,6 +721,7 @@ "lastiPhoneLogin", "lastname", "lefthook", + "legendapp", "libc", "Libc", "libc's", diff --git a/jest/setup.ts b/jest/setup.ts index 76b88f58d8e6..90ce693cb98a 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -30,6 +30,56 @@ if (!('GITHUB_REPOSITORY' in process.env)) { setupMockImages(); mockFSLibrary(); +// LegendList relies on native layout measurements that Jest does not produce. FlatList gives full-app tests +// a deterministic renderer while preserving the scroll callbacks used by the report list. +jest.mock('@legendapp/list/react-native', () => { + const ReactActual = jest.requireActual('react'); + const {FlatList} = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); + + type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & { + alignItemsAtEnd?: boolean; + data?: ArrayLike; + initialScrollAtEnd?: boolean; + initialScrollIndex?: number | {index: number}; + maintainScrollAtEnd?: unknown; + maintainVisibleContentPosition?: unknown; + onScroll?: (event: import('react-native').NativeSyntheticEvent) => void; + }; + + return { + ...LegendListActual, + LegendList: ReactActual.forwardRef, MockLegendListProps>( + ( + { + alignItemsAtEnd, + data = [], + initialScrollAtEnd, + initialScrollIndex, + maintainScrollAtEnd, + maintainVisibleContentPosition, + onEndReached, + onEndReachedThreshold = 0, + onScroll, + ...props + }, + ref, + ) => { + const handleScroll = (event: import('react-native').NativeSyntheticEvent) => { + onScroll?.(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y; + if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) { + onEndReached?.({distanceFromEnd}); + } + }; + + return ReactActual.createElement(FlatList, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref}); + }, + ), + }; +}); + Object.assign(global, {TextDecoder, TextEncoder}); // This mock is required as per setup instructions for react-navigation testing diff --git a/package-lock.json b/package-lock.json index b20386fcd943..fdcc29d0ff27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.3.5", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11422,6 +11423,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@legendapp/list": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.5.tgz", + "integrity": "sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", diff --git a/package.json b/package.json index 789de48dc874..38a086d660e5 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.3.5", "@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/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch b/patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch new file mode 100644 index 000000000000..52b2bb421663 --- /dev/null +++ b/patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch @@ -0,0 +1,103 @@ +diff --git a/node_modules/@fullstory/react-native/src/index.ts b/node_modules/@fullstory/react-native/src/index.ts +--- a/node_modules/@fullstory/react-native/src/index.ts ++++ b/node_modules/@fullstory/react-native/src/index.ts +@@ -118,6 +118,8 @@ type FSNativeElement = ComponentRef & { + currentProps?: Record; + }; + ++const staticRefWrappers = new WeakMap>(); ++ + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element: FSNativeElement | null) { + if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) { +@@ -185,6 +187,13 @@ export function applyFSPropertiesWithRef( + return existingRef; + } + ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } ++ + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { + return sharedRefWrapper; +@@ -207,6 +216,10 @@ export function applyFSPropertiesWithRef( + configurable: false, + }); + ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } ++ + return refWrapper; + } + +diff --git a/node_modules/@fullstory/react-native/lib/commonjs/index.js b/node_modules/@fullstory/react-native/lib/commonjs/index.js +--- a/node_modules/@fullstory/react-native/lib/commonjs/index.js ++++ b/node_modules/@fullstory/react-native/lib/commonjs/index.js +@@ -83,6 +83,7 @@ try { + getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; + } catch (e) {} + const FS_REF_SYMBOL = exports.FS_REF_SYMBOL = Symbol('fullstory.ref'); ++const staticRefWrappers = new WeakMap(); + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element) { + if (element && _fullstoryInterface.isTurboModuleEnabled && _reactNative.Platform.OS === 'ios' && !_reactNative.Platform.isTV) { +@@ -136,5 +137,11 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) { + return existingRef; + } ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } + + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { +@@ -156,6 +163,9 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) { + writable: false, + configurable: false + }); ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } + return refWrapper; + } + const FullstoryAPI = { +diff --git a/node_modules/@fullstory/react-native/lib/module/index.js b/node_modules/@fullstory/react-native/lib/module/index.js +--- a/node_modules/@fullstory/react-native/lib/module/index.js ++++ b/node_modules/@fullstory/react-native/lib/module/index.js +@@ -67,6 +67,7 @@ try { + getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; + } catch (e) {} + export const FS_REF_SYMBOL = Symbol('fullstory.ref'); ++const staticRefWrappers = new WeakMap(); + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element) { + if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) { +@@ -120,5 +121,11 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru + return existingRef; + } ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } + + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { +@@ -140,6 +147,9 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru + writable: false, + configurable: false + }); ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } + return refWrapper; + } + const FullstoryAPI = { diff --git a/patches/@fullstory/react-native/details.md b/patches/@fullstory/react-native/details.md index f7fbb0b65bcf..74d10e93be4f 100644 --- a/patches/@fullstory/react-native/details.md +++ b/patches/@fullstory/react-native/details.md @@ -26,3 +26,13 @@ - Upstream PR/issue: 🛑 TODO - E/App issue: https://github.com/Expensify/App/issues/91225 - PR introducing patch: 🛑 TODO + +### [@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch](@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch) + +- Reason: + + FullStory creates a new callback wrapper every time `applyFSPropertiesWithRef()` receives an existing ref, including when annotations are static. Components that update state from their ref callback can then enter a detach/render/attach loop because the wrapper identity changes on every render. Cache wrappers for stable refs when annotations are static, while continuing to recreate wrappers for dynamic FullStory attributes. + +- Upstream PR/issue: - +- E/App issue: - +- PR introducing patch: - 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 a1ef9a4898b7..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,31 +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 & { - /** The array of items to render in the list. */ - data: T[]; - - /** Function that extracts a unique key for each item in the list. */ - 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 index cf7718d3d148..a7de9269b522 100644 --- a/src/components/FlashList/types.ts +++ b/src/components/FlashList/types.ts @@ -1,7 +1,31 @@ import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; + +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; +}; /** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; +type FlatListRefType = RefObject | null; export default FlatListRefType; +export type {ActionListRef}; diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..7892a044825c 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -42,7 +42,7 @@ function CustomFlatList({ [onMomentumScrollEnd], ); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fb977c60a232..9c13ff4c7068 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -245,7 +245,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..3e8109d2d0e6 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 41abe12d930f..37c812e9748f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -634,7 +634,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; return ( - + (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 6e64cd2d4184..24c0014f920c 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -3,6 +3,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'; @@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -52,7 +54,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; // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. 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/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..67e39bdd688d 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -146,7 +146,11 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index f44f1d428817..f478f4147627 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -33,7 +33,6 @@ import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { /** The ID of the report currently being looked at */ @@ -51,7 +50,7 @@ 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 */ @@ -115,14 +114,8 @@ type UseReportActionsScrollResult = { /** 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}; + /** Whether the list should preserve the currently visible content when data changes */ + shouldMaintainVisibleContentPosition: boolean; /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; @@ -130,7 +123,7 @@ type UseReportActionsScrollResult = { /** 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; }; @@ -156,7 +149,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; @@ -193,17 +185,8 @@ function useReportActionsScroll({ const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey; 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, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = useReportUnreadMessageScrollTracking({ reportID, @@ -211,7 +194,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, shouldDisablePillTracking, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -232,7 +215,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -270,29 +253,6 @@ function useReportActionsScroll({ }); }, [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(); - }, []); - // Fixes Safari-specific issue where the whisper option is not highlighted correctly on hover after adding new transaction. // https://github.com/Expensify/App/issues/54520 useEffect(() => { @@ -359,7 +319,7 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; const flushPendingScrollToBottom = () => { @@ -371,52 +331,30 @@ function useReportActionsScroll({ completeLiveTailPruneAfterScrollToBottom(); }; - // Data is ready at the moment FlashList finishes its first render. + // Data is ready when LegendList 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) { + 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)); - }; - 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]); + // Wait one frame so the initial positioning can settle, then disable it. + requestAnimationFrame(() => { + setShouldDisablePillTracking(false); + updatePillVisibility(); + }); + }; // 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. + // 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) { + if (targetIndex >= 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; + initialScrollIndex = 0; } return { @@ -428,9 +366,7 @@ function useReportActionsScroll({ scrollToActionBadgeTarget, flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, + shouldMaintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 97c9a92cf1f5..c1823e3dc0a8 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -21,17 +21,14 @@ 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}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..09ce64a5687c 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,17 +18,14 @@ 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}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..1f9ab8877322 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,7 +1,7 @@ import type FlatListRefType from '@components/FlashList/types'; +import type {ActionListRef} from '@components/FlashList/types'; import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 42bba53109fd..87778f24d862 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -76,6 +76,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre (0); +type ReportActionPosition = { + index: number; + isNewest: boolean; + isRecycling?: boolean; +}; + +const ReportActionIndexContext = createContext({index: 0, isNewest: false}); + +/** + * Uses LegendList's recycling-aware state in the main report list and behaves like 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 {useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index b9f5035180d9..b755594d3a2e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -79,12 +79,13 @@ import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useRecyclingEffect} from '@legendapp/list/react-native'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -95,6 +96,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'; @@ -215,16 +217,21 @@ 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([]); + useRecyclingEffect( + useCallback(() => { + downloadedPreviews.current = []; + }, []), + ); 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(); @@ -368,7 +375,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 8a1d86d7df37..a845957584f1 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -103,6 +103,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf (undefined); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useEffect(() => { didLayout.current = false; + lastRequestedOldestActionIDRef.current = undefined; }, [reportID]); + useEffect(() => { + if (isLoadingOlderReportActions && !hasLoadingOlderReportActionsError) { + return; + } + lastRequestedOldestActionIDRef.current = undefined; + }, [isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); + useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. @@ -172,6 +229,23 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + 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(); @@ -181,7 +255,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, @@ -234,6 +308,10 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return visibleReportActionsWithDraft; })(); + // 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 = renderedVisibleReportActions.toReversed(); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; @@ -255,9 +333,10 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, persistedDraftReportAction, revealDraftFromReportAction]); - // 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 actionBadgeTargetIndex = actionBadgeTargetID ? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const unreadMarkerListIndex = unreadMarkerReportActionID ? listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; const { trackVerticalScrolling, @@ -268,10 +347,9 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent scrollToActionBadgeTarget, flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, + shouldMaintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -279,13 +357,13 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, draftAutoScrollKey, actionBadgeTargetIndex, @@ -294,18 +372,39 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }; - - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + const loadOlderChatsOnStartReached = () => { + if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; } + + lastRequestedOldestActionIDRef.current = oldestReportActionID; loadOlderChats(false); }; + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; + + 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); @@ -326,7 +425,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -354,11 +453,12 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent 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 = renderedVisibleReportActions.length - index - 1; return ( - + - { recordTimeToMeasureItemLayout(event); @@ -484,14 +583,14 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} + getItemType={getItemType} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} + alignItemsAtEnd={!shouldBeAlignedToTop} + maintainScrollAtEnd={{animated: false}} + // Keyboard avoidance can shrink the viewport by almost a full screen before LegendList evaluates end proximity. + maintainScrollAtEndThreshold={1} + maintainVisibleContentPosition={shouldMaintainVisibleContentPosition ? {data: true} : false} onLoad={onLoad} onContentSizeChange={() => { trackVerticalScrolling(undefined); diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 5d1370d01715..b25df0de4ef3 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -211,6 +211,7 @@ function ActionContentRouter({ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { 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 b15df8c3a2ff..67c952452920 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -18,10 +18,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 b43a18e04122..1d020b301369 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -39,7 +39,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; @@ -68,7 +69,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -115,14 +116,14 @@ function useReportActionsNewActionLiveTail({ return; } - 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) { diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 6a0385d271dc..a7655a4ba956 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -159,11 +159,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/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index fd23ffdab5bf..f393af39fb38 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,7 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -315,7 +316,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(); @@ -339,7 +340,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(0); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -370,8 +371,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,10 +394,10 @@ 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); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); @@ -405,10 +406,10 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 20ca6e71fcf5..91b7ccf97ea8 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -86,7 +86,9 @@ const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunc const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; + return selector === reportActionsListLoadingStateSelector + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -113,10 +115,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,11 +128,11 @@ 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)); +jest.mock('@legendapp/list/react-native', () => ({LegendList: jest.fn(() => null)})); 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', () => @@ -142,9 +146,9 @@ jest.mock('@hooks/useReportActionsScroll', () => scrollToActionBadgeTarget: jest.fn(), flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, + shouldMaintainVisibleContentPosition: false, onLoad: jest.fn(), })), ); @@ -156,18 +160,31 @@ 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 = { data?: OnyxTypes.ReportAction[]; + drawDistance?: number; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + maintainScrollAtEnd?: {animated: boolean}; + maintainScrollAtEndThreshold?: number; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + 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} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -233,6 +250,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(); @@ -329,6 +359,119 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('keeps the latest messages anchored when the keyboard changes the list layout', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(1); + }); + + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(500); + expect(listProps?.recycleItems).toBe(true); + }); + + 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(200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(600)}], + }), + ).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(1500)}], + }), + ).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('continues loading older pages from scroll events when LegendList does not report reaching the start', () => { + 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?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + act(() => { + listProps?.onStartReached?.(); + listProps?.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 Draft Context Menu', () => { const conciergeDraftReportAction: OnyxTypes.ReportAction = { reportID: mockReport.reportID, @@ -666,10 +809,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', () => { @@ -733,7 +876,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(); }); }); @@ -826,7 +969,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); @@ -844,7 +987,7 @@ 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); @@ -877,7 +1020,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); @@ -895,7 +1038,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); @@ -941,7 +1084,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); @@ -978,7 +1121,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + 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/FullstoryTest.ts b/tests/unit/FullstoryTest.ts index 4874ce9705e0..9737920cf9f2 100644 --- a/tests/unit/FullstoryTest.ts +++ b/tests/unit/FullstoryTest.ts @@ -4,10 +4,20 @@ import {shouldInitializeFullstory} from '@libs/Fullstory/common'; import CONST from '@src/CONST'; import type {Session, UserMetadata} from '@src/types/onyx'; +import type * as FullstoryReactNative from '@fullstory/react-native/src/index'; + const regularSession: Session = {authToken: 'token', accountID: 1, creationDate: Date.now()}; const supportSession: Session = {authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT, authToken: 'supportToken', accountID: 2, creationDate: Date.now()}; describe('Fullstory', () => { + test('reuses the ref wrapper for static annotations', () => { + const {applyFSPropertiesWithRef} = jest.requireActual('@fullstory/react-native/src/index'); + const ref = jest.fn(); + + expect(applyFSPropertiesWithRef(ref, false)).toBe(applyFSPropertiesWithRef(ref, false)); + expect(applyFSPropertiesWithRef(ref, true)).not.toBe(applyFSPropertiesWithRef(ref, true)); + }); + describe('shouldInitializeFullstory', () => { const productionEnv = CONST.ENVIRONMENT.PRODUCTION; diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index ba7c5238ab7f..849ef4c9a058 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,38 +30,56 @@ 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}; + maintainVisibleContentPosition?: boolean | {data: 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. +// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`. With no +// deep-link, it is enabled exactly when `hasScrolledOverThreshold` is true. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the +// Every value the maintain-visible-content-position flag has held, 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. +// Whether the captured LegendList configuration enables maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; + return config === undefined ? undefined : config !== false; } -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); + mockMvcpHistory.push(props.maintainVisibleContentPosition === undefined ? undefined : props.maintainVisibleContentPosition !== false); 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 { @@ -163,12 +181,12 @@ describe('ReportActionsList hasScrolledOverThreshold', () => { expect(isMvcpEnabled()).toBe(false); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); expect(isMvcpEnabled()).toBe(false); }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..ea9a5a836dde 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -58,9 +58,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToBottom: mockScrollToBottom}), })); jest.mock('@libs/ReportUtils', () => { @@ -142,4 +143,17 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to the bottom after deleting the newest message draft', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index e0ab679a0ce4..2f2b9328b90c 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -4,17 +4,17 @@ const BASE_PARAMS = { isProduction: false, 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/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 0dadf5afdf1a..287cd5adcd86 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -47,6 +47,7 @@ jest.mock('@hooks/useReportScrollManager', () => ({ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ @@ -57,6 +58,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }), })); @@ -216,10 +218,6 @@ function flushTransitions() { }); } -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}); @@ -254,9 +252,7 @@ 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(); + expect(result.current.shouldMaintainVisibleContentPosition).toBe(false); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -265,8 +261,7 @@ 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); + expect(result.current.shouldMaintainVisibleContentPosition).toBe(true); }); it('is aligned to top for a money request report', async () => { @@ -285,22 +280,25 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('positions a linked report action at chronological index zero', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + 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 () => { + const unreadAction = makeAction(UNREAD_ACTION_ID); 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); + expect(result.current.initialScrollIndex).toBe(0); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -311,9 +309,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(); }); }); @@ -376,7 +373,7 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); @@ -408,93 +405,7 @@ describe('useReportActionsScroll', () => { }); }); - 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); - }); - }); - 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('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { mockScrollOffsetRef.current = 0; diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index ecc1f04d688f..09c0d77e37d8 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('action badge above viewport tracking', () => { @@ -281,6 +307,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(() =>