diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 743556d67834..be386f394144 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1839,6 +1839,7 @@ const CONST = { THREAD_DISABLED: ['CREATED'], LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD: 2000, ACTION_VISIBLE_THRESHOLD: 250, + AUTOSCROLL_TO_TOP_THRESHOLD: 250, LINKED_MESSAGE_OFFSET: 40, MAX_GROUPING_TIME: 300000, }, diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx deleted file mode 100644 index 110f04fd3c35..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import FlatList from '@components/FlatList/FlatList'; -import useFlatListScrollKey from '@components/FlatList/hooks/useFlatListScrollKey'; -import type {BaseFlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKeyProps) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - onViewableItemsChanged, - onContentSizeChange, - onScrollBeginDrag, - onWheel, - onTouchStartCapture, - ...restProps - } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: false, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - }); - - const isLoadingData = useRef(true); - const isInitialDataRef = useRef(isInitialData); - // Determine whether the user has interacted with the FlatList, - // ensuring that handleStartReached is only triggered within onViewableItemsChanged after user interaction. - const hasUserInteractedRef = useRef(false); - - useEffect(() => { - isInitialDataRef.current = isInitialData; - - if (!isLoadingData.current || data.length > displayedData.length) { - return; - } - - isLoadingData.current = false; - }, [data.length, displayedData.length, isInitialData]); - - return ( - onContentSizeChange?.(width, height, isInitialData)} - onViewableItemsChanged={(info) => { - onViewableItemsChanged?.(info); - - if (!hasUserInteractedRef.current || isInitialDataRef.current || !isLoadingData.current || info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0) { - return; - } - handleStartReached({distanceFromStart: 0}); - }} - onScrollBeginDrag={(e) => { - onScrollBeginDrag?.(e); - hasUserInteractedRef.current = true; - }} - onWheel={(e) => { - onWheel?.(e); - hasUserInteractedRef.current = true; - }} - onTouchStartCapture={(e) => { - onTouchStartCapture?.(e); - hasUserInteractedRef.current = true; - }} - /> - ); -} - -export default BaseFlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx deleted file mode 100644 index e912917994b8..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, {useCallback, useRef} from 'react'; -import type {LayoutChangeEvent, FlatList as RNFlatList} from 'react-native'; -import mergeRefs from '@libs/mergeRefs'; -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; -import type {FlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props; - - const flatListHeight = useRef(0); - const shouldScrollToEndRef = useRef(false); - const listRef = useRef(null); - - const onLayoutInner = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - - flatListHeight.current = event.nativeEvent.layout.height; - }, - [onLayout], - ); - - const onContentSizeChangeInner = useCallback( - (w: number, h: number, isInitialData?: boolean) => { - onContentSizeChange?.(w, h); - - if (!initialScrollKey) { - return; - } - // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. - // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. - // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, - // it means there is a gap at the bottom. - // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. - if (shouldScrollToEndRef.current) { - requestAnimationFrame(() => { - listRef.current?.scrollToEnd(); - }); - shouldScrollToEndRef.current = false; - } - if (h < flatListHeight.current && isInitialData) { - shouldScrollToEndRef.current = true; - } - }, - [onContentSizeChange, initialScrollKey], - ); - - return ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx deleted file mode 100644 index e487843c3897..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; -import type {FlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - return ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts deleted file mode 100644 index 5a8145be74c3..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; - -type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void; - ref: ForwardedRef; -}; - -type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; - -export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/components/FlatList/getInitialPaginationSize/index.native.ts b/src/components/FlatList/getInitialPaginationSize/index.native.ts deleted file mode 100644 index 195448f7e450..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/components/FlatList/getInitialPaginationSize/index.ts b/src/components/FlatList/getInitialPaginationSize/index.ts deleted file mode 100644 index 87ec6856aa20..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.WEB_PAGINATION_SIZE; diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts deleted file mode 100644 index fac0b5cf2f11..000000000000 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ /dev/null @@ -1,183 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {ForwardedRef} from 'react'; -import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; -import {View} from 'react-native'; -import getInitialPaginationSize from '@components/FlatList/getInitialPaginationSize'; -import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; -import type {FlatListInnerRefType} from '@components/FlatList/types'; -import type {ScrollViewProps} from '@components/ScrollView'; -import usePrevious from '@hooks/usePrevious'; -import getPlatform from '@libs/getPlatform'; -import CONST from '@src/CONST'; -import useFlatListHandle from './useFlatListHandle'; - -type FlatListScrollKeyProps = { - ref?: ForwardedRef>; - data: T[]; - keyExtractor: (item: T, index: number) => string; - initialScrollKey: string | null | undefined; - inverted: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - remainingItemsToDisplay?: number; - onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; -}; - -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - -export default function useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - onStartReached, - inverted, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - remainingItemsToDisplay, - onScrollToIndexFailed, -}: FlatListScrollKeyProps) { - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); - const [isQueueRendering, setIsQueueRendering] = useState(false); - - // On the web platform, when data.length === 1, `maintainVisibleContentPosition` does not work. - // Therefore, we need to duplicate the data to ensure data.length >= 2 - const shouldDuplicateData = useMemo(() => !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB, [data.length, inverted, isInitialData]); - - const displayedData = useMemo(() => { - if (shouldDuplicateData) { - return [{...data.at(0), reportActionID: '0'} as T, ...data]; - } - if (currentDataIndex <= 0) { - return data; - } - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. - // So we will need to return at least the last 2 elements in this case. - const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; - // We always render the list from the highlighted item to the end of the list because: - // - With an inverted FlatList, items are rendered from bottom to top, - // so the highlighted item stays at the bottom and within the visible viewport. - // - With a non-inverted (base) FlatList, items are rendered from top to bottom, - // making the highlighted item appear at the top of the list. - // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place - // as the rest of the items are appended. - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - renderQueue.setHandler((info) => { - if (!isLoadingData) { - onStartReached?.(info); - } - setIsInitialData(false); - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); - }); - - const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }, - [renderQueue], - ); - - useEffect(() => { - // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. - // We'll manually invoke it in this scenario. - if (inverted || data.length > 0) { - return; - } - handleStartReached({distanceFromStart: 0}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - const maintainVisibleContentPosition = useMemo(() => { - if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { - return undefined; - } - - const config: ScrollViewProps['maintainVisibleContentPosition'] = { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, - }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - if (shouldDuplicateData && index === 1) { - return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); - } - - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [shouldDuplicateData, renderItem, dataIndexDifference], - ); - - useEffect(() => { - if (inverted || isInitialData || isQueueRendering) { - return; - } - - // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, - // which causes the initial value of `minIndexForVisible` to be 0. - // When data.length increases and `minIndexForVisible` updates accordingly, - // it can lead to a crash due to inconsistent rendering behavior. - // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift - // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - requestAnimationFrame(() => { - setShouldPreserveVisibleContentPosition(false); - }); - }, [inverted, isInitialData, isQueueRendering]); - - const listRef = useRef | null>(null); - useFlatListHandle({ - ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, - }); - - return { - handleStartReached, - setCurrentDataId, - displayedData, - maintainVisibleContentPosition, - isInitialData, - handleRenderItem, - listRef, - }; -} - -export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 3cc3062dac33..65c137cd9b25 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -3,9 +3,8 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, View} from 'react-native'; -import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsReportActionsLoaded from '@hooks/useIsReportActionsLoaded'; @@ -23,7 +22,6 @@ import useReportTransactionsCollection from '@hooks/useReportTransactionsCollect import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -51,7 +49,6 @@ import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; @@ -67,7 +64,6 @@ import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; -import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import SelectionToolbar from './SelectionToolbar'; @@ -93,6 +89,24 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const {translate, getLocalDateFromDatetime} = useLocalize(); const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const reportScrollManager = useReportScrollManager(); + // The unified list writes its last item index here (see lastItemIndexRef prop). We jump to the bottom via + // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large + // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex + // targets the last item directly and renders around it, so the landing is not blank. + const lastItemIndexRef = useRef(0); + const updateLastItemIndex = useCallback((index: number) => { + lastItemIndexRef.current = index; + }, []); + + const scrollToBottom = useCallback(() => { + if (lastItemIndexRef.current < 0) { + return; + } + + const listRef = reportScrollManager.ref; + listRef?.current?.scrollToIndex({index: lastItemIndexRef.current, animated: false}); + }, [reportScrollManager.ref]); + const lastMessageTime = useRef(null); const didLayout = useRef(false); const [isVisible, setIsVisible] = useState(Visibility.isVisible); @@ -205,6 +219,10 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const scrollingVerticalTopOffset = useRef(0); const wrapperViewRef = useRef(null); const readActionSkipped = useRef(false); + const stickToBottomRef = useRef(false); + const stickToBottomTimeoutRef = useRef(null); + // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. + const pendingMarkAsReadRef = useRef(false); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; const userActiveSince = useRef(DateUtils.getDBTime()); @@ -458,6 +476,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) // We additionally track the top offset to be able to scroll to the new transaction when it's added scrollingVerticalTopOffset.current = contentOffset.y; + + // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by + // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. + if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + pendingMarkAsReadRef.current = false; + readActionSkipped.current = false; + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + } }, }); @@ -566,15 +592,15 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) } }, [visibleReportActions, lastActionEventId, enableScrollToEnd, reportScrollManager]); - const renderItem = useCallback( - ({item: reportAction, index}: ListRenderItemInfo) => { + const renderReportAction = useCallback( + (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { const displayAsGroup = - !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - hasNextActionMadeBySameActor(visibleReportActions, index, isOffline); + !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, indexWithinReportActions, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; return ( - + { + const scrollToLatestMessages = useCallback(() => { setIsFloatingMessageCounterVisible(false); + stickToBottomRef.current = true; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + } + // Safety net: stop pinning after deferred content has had time to settle, so a much later + // unrelated layout change doesn't yank the user back down. + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, 2000); + if (!hasNewestReportAction) { openReport({reportID, introSelected, betas}); - reportScrollManager.scrollToEnd(); + scrollToBottom(); return; } - reportScrollManager.scrollToEnd(); - readActionSkipped.current = false; - readNewestAction(reportID, true); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, introSelected, betas]); + // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). + pendingMarkAsReadRef.current = true; + scrollToBottom(); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, scrollToBottom, reportID, introSelected, betas]); + + useEffect(() => { + return () => { + if (!stickToBottomTimeoutRef.current) { + return; + } + clearTimeout(stickToBottomTimeoutRef.current); + }; + }, []); + + const onListContentSizeChange = () => { + if (!stickToBottomRef.current) { + return; + } + scrollToBottom(); + }; + + const onListScrollBeginDrag = () => { + stickToBottomRef.current = false; + // The user scrolled away before reaching the bottom, so cancel the pending read. + pendingMarkAsReadRef.current = false; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + stickToBottomTimeoutRef.current = null; + } + }; const scrollToNewTransaction = useCallback( (pageY: number) => { @@ -649,28 +711,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) markOpenReportEnd(report, {warm: !shouldShowOpenReportLoadingSkeleton}); }, [report, shouldShowOpenReportLoadingSkeleton]); - // Wrapped into useCallback to stabilize children re-renders - const keyExtractor = useCallback((item: OnyxTypes.ReportAction) => item.reportActionID, []); - - const {windowHeight} = useWindowDimensions(); - /** - * Calculates the ideal number of report actions to render in the first render, based on the screen height and on - * the height of the smallest report action possible. - */ - const initialNumToRender = useMemo((): number | undefined => { - const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; - const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - // windowHeight can be smaller than the header+footer during transient mount/transition states - // (e.g. Wide RHP overlay animating in), which would make numToRender negative and crash - // VirtualizedList with "Invalid cells around viewport". Clamping to 0 lets the `|| undefined` - // fallback below kick in so FlatList uses its default. - const numToRender = Math.max(0, Math.ceil(availableHeight / minimumReportActionHeight)); - if (linkedReportActionID) { - return getInitialNumToRender(numToRender); - } - return numToRender || undefined; - }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); - const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState; const showEmptyState = isReportEmpty; @@ -698,12 +738,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) {/* Exactly one of these two branches is active at a time: 1. showEmptyState — genuinely empty report - 2. !isReportEmpty — report has data, render the FlatList */} + 2. !isReportEmpty — report has data, render the FlashList */} {showEmptyState && ( )} - {!isReportEmpty && ( - 0} + isLoadingInitialReportActions={showReportActionsLoadingState} + visibleReportActions={visibleReportActions} + renderReportAction={renderReportAction} + linkedReportActionID={linkedReportActionID} + listRef={reportScrollManager.ref} + onLastItemIndexChange={updateLastItemIndex} accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} - testID="money-request-report-actions-list" - style={styles.overscrollBehaviorContain} - data={visibleReportActions} - renderItem={renderItem} - extraData={draftReportActionID} + onListLayout={recordTimeToMeasureItemLayout} + onScroll={trackVerticalScrolling} + onScrollBeginDrag={onListScrollBeginDrag} + onContentSizeChange={onListContentSizeChange} onViewableItemsChanged={onViewableItemsChanged} - keyExtractor={keyExtractor} - onLayout={recordTimeToMeasureItemLayout} onEndReached={onEndReached} - onEndReachedThreshold={0.75} onStartReached={onStartReached} - onStartReachedThreshold={0.75} - ListHeaderComponent={ - <> - - {!!reportStable && ( - 0} - isLoadingInitialReportActions={showReportActionsLoadingState} - /> - )} - - } - keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} - contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={reportScrollManager.ref} - ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState - removeClippedSubviews={false} - initialScrollKey={linkedReportActionID} + contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} + isLoadingInitialActions={!!showReportActionsLoadingState} + skeletonReasonAttributes={skeletonReasonAttributes} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx new file mode 100644 index 000000000000..065c6393447f --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx @@ -0,0 +1,52 @@ +import React, {useCallback, useLayoutEffect, useRef} from 'react'; +// ScrollView component needed for horizontal table scroll on wide layouts; vertical scroll happens in the parent FlashList. +// eslint-disable-next-line no-restricted-imports +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; +import ScrollView from '@components/ScrollView'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type MoneyRequestReportHorizontalScrollWrapperProps = { + shouldScroll: boolean; + contentWidth: number; + /** When this value changes, the wrapper restores the previous horizontal scroll offset synchronously before paint. */ + restorationKey: unknown; + children: React.ReactElement; +}; + +function MoneyRequestReportHorizontalScrollWrapper({shouldScroll, contentWidth, restorationKey, children}: MoneyRequestReportHorizontalScrollWrapperProps) { + const styles = useThemeStyles(); + const scrollRef = useRef(null); + const offsetRef = useRef(0); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + offsetRef.current = event.nativeEvent.contentOffset.x; + }, []); + + useLayoutEffect(() => { + if (!shouldScroll || offsetRef.current <= 0) { + return; + } + scrollRef.current?.scrollTo({x: offsetRef.current, animated: false}); + }, [restorationKey, shouldScroll]); + + if (!shouldScroll) { + return children; + } + + return ( + + {children} + + ); +} + +export default MoneyRequestReportHorizontalScrollWrapper; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index b389523f5a21..692e1b0d372b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -96,6 +96,9 @@ type MoneyRequestReportTransactionItemBodyProps = MoneyRequestReportTransactionI /** Highlight animation style, computed by the parent so its state survives the narrow↔wide swap on resize. */ animatedHighlightStyle: ReturnType; + + /** Whether to skip deferring the RBR content. */ + shouldSkipDeferRBR?: boolean; }; function MoneyRequestReportTransactionItemBody({ @@ -122,6 +125,7 @@ function MoneyRequestReportTransactionItemBody({ transactionThreadReportID, inlineEdit, animatedHighlightStyle, + shouldSkipDeferRBR = false, }: MoneyRequestReportTransactionItemBodyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -201,7 +205,6 @@ function MoneyRequestReportTransactionItemBody({ report={report} policy={policy} policyCategories={policyCategories} - transactionThreadReportID={transactionThreadReportID} isSelected={isSelected} dateColumnSize={dateColumnSize} amountColumnSize={amountColumnSize} @@ -232,6 +235,8 @@ function MoneyRequestReportTransactionItemBody({ onEditCategory={inlineEdit?.onEditCategory} onEditAmount={inlineEdit?.onEditAmount} onEditTag={inlineEdit?.onEditTag} + shouldSkipDeferRBR={shouldSkipDeferRBR} + transactionThreadReportID={transactionThreadReportID} /> )} @@ -274,6 +279,7 @@ function MoneyRequestReportTransactionItem(props: MoneyRequestReportTransactionI ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 8b035f29a8df..6495402cf40a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,18 +1,13 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import isEmpty from 'lodash/isEmpty'; -import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -// ScrollView type is needed for the horizontal scroll ref; the project ScrollView component is used for rendering. -// eslint-disable-next-line no-restricted-imports -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; -import MenuItem from '@components/MenuItem'; -import Modal from '@components/Modal'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScrollView from '@components/ScrollView'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds, SortOrder} from '@components/Search/types'; @@ -37,7 +32,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {getReportLayoutGroupBy, getReportLayoutSelection, setReportLayout} from '@libs/actions/ReportLayout'; import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {resolveTransactionCardFields} from '@libs/CardUtils'; @@ -62,11 +56,13 @@ import { import type {SortableColumnName} from '@libs/ReportUtils'; import {compareValues, getColumnsToShow, getTableMinWidth, hasFlexColumn, isTransactionAmountTooLong, isTransactionTaxAmountTooLong} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {transactionHasRBR} from '@libs/TransactionPreviewUtils'; import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete, shouldShowExpenseBreakdown} from '@libs/TransactionUtils'; import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear'; import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; import Navigation from '@navigation/Navigation'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -81,10 +77,52 @@ import MoneyRequestReportGroupHeader from './MoneyRequestReportGroupHeader'; import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader'; import MoneyRequestReportTotalSpend from './MoneyRequestReportTotalSpend'; import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionItem'; +import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; +import type {MoneyRequestReportTransactionLongPressModalHandle} from './MoneyRequestReportTransactionLongPressModal'; +import MoneyRequestReportUnifiedList from './MoneyRequestReportUnifiedList'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; +type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { + /** Whether the transaction should be highlighted, when it is added to the report */ + shouldBeHighlighted?: boolean; +}; + +type TransactionListItemData = {type: 'section-header'; groupKey: string; group: OnyxTypes.GroupedTransactions} | {type: 'transaction'; transaction: TransactionWithOptionalHighlight}; + +/** + * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. + * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent + * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the + * same locals across multiple call sites without earning an abstraction. + */ +type MoneyRequestReportTransactionListController = { + /** Chrome rendered above the transaction items (group-by dropdown, columns button, table header, or empty state). */ + beforeListContent: React.ReactElement; + + /** Flat array of items to render between beforeListContent and afterListContent. */ + transactionListItems: TransactionListItemData[]; + + /** Render a single transaction-list item. */ + renderTransactionListItem: (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ + afterListContent: React.ReactElement | null; + + /** True when the rendered table is wider than the viewport; the parent should wrap the list in `MoneyRequestReportHorizontalScrollWrapper`. */ + shouldScrollHorizontally: boolean; + + /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ + tableMinWidth: number; + + /** Token that changes when the rendered list content changes; the horizontal scroll wrapper uses it to restore the previous offset. */ + horizontalScrollRestorationKey: unknown; + + /** True when this report has no transactions; the parent should still render report actions but skip the transactions section. */ + isEmptyTransactions: boolean; +}; + const EMPTY_VIOLATIONS: OnyxTypes.TransactionViolations = []; /** @@ -141,11 +179,54 @@ type MoneyRequestReportTransactionListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; -}; -type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { - /** Whether the transaction should be highlighted, when it is added to the report */ - shouldBeHighlighted?: boolean; + /** Reversed list of report actions to render below the transactions section in the unified list. */ + visibleReportActions: OnyxTypes.ReportAction[]; + + /** Renders a single report action row in the unified list. */ + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + + /** Report action ID the unified list should initially scroll to, when deep-linked. */ + linkedReportActionID: string | undefined; + + /** Ref forwarded to the underlying FlashList. */ + listRef: FlatListRefType; + + /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ + onLastItemIndexChange?: (index: number) => void; + + /** Accessibility label for the unified list. */ + accessibilityLabel: string; + + /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ + onListLayout: () => void; + + /** FlashList onScroll callback. */ + onScroll: (event: NativeSyntheticEvent) => void; + + /** FlashList onScrollBeginDrag callback. */ + onScrollBeginDrag: () => void; + + /** FlashList onContentSizeChange callback. */ + onContentSizeChange: () => void; + + /** FlashList onViewableItemsChanged callback. */ + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + + /** FlashList onEndReached callback. */ + onEndReached: () => void; + + /** FlashList onStartReached callback. */ + onStartReached: () => void; + + /** FlashList contentContainerStyle. */ + contentContainerStyle: StyleProp; + + /** Whether the initial report actions are still loading. */ + isLoadingInitialActions: boolean; + + /** Reason attributes forwarded to the loading skeleton span. */ + skeletonReasonAttributes: SkeletonSpanReasonAttributes; }; type SortedTransactions = { @@ -164,20 +245,35 @@ function MoneyRequestReportTransactionList({ hasComments, onLayout, isLoadingInitialReportActions = false, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + onLastItemIndexChange, + accessibilityLabel, + onListLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isLoadingInitialActions, + skeletonReasonAttributes, }: MoneyRequestReportTransactionListProps) { useCopySelectionHelper(); const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'CheckSquare', 'ReceiptPlus', 'Columns', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Columns', 'Plus']); const {translate, localeCompare} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); const navigateToTransactionThread = useNavigateToTransactionThread(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [selectedTransactionID, setSelectedTransactionID] = useState(''); + const longPressModalRef = useRef(null); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); const {isOffline} = useNetwork(); @@ -425,20 +521,6 @@ function MoneyRequestReportTransactionList({ const {windowWidth, windowHeight} = useWindowDimensions(); const minTableWidth = getTableMinWidth(columnsToShow); const shouldScrollHorizontally = !shouldUseNarrowLayout && minTableWidth > windowWidth; - const horizontalScrollViewRef = useRef(null); - const horizontalScrollOffsetRef = useRef(0); - - const handleHorizontalScroll = useCallback((event: NativeSyntheticEvent) => { - horizontalScrollOffsetRef.current = event.nativeEvent.contentOffset.x; - }, []); - - // Restore horizontal scroll position synchronously before paint when transactions change - useLayoutEffect(() => { - if (!shouldScrollHorizontally || horizontalScrollOffsetRef.current <= 0) { - return; - } - horizontalScrollViewRef.current?.scrollTo({x: horizontalScrollOffsetRef.current, animated: false}); - }, [sortedTransactions, shouldScrollHorizontally]); // Latch the user's most recent selection so the popover label and grouping mode never flick through the // (layoutOption=null, groupByOption=null) → CATEGORY default while the two NVPs settle in separate render passes. @@ -587,10 +669,9 @@ function MoneyRequestReportTransactionList({ toggleTransaction(transactionID); return; } - setSelectedTransactionID(transactionID); - setIsModalVisible(true); + longPressModalRef.current?.show(transactionID); }, - [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction, setSelectedTransactionID, setIsModalVisible], + [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction], ); const handleOnPress = useCallback( @@ -688,6 +769,20 @@ function MoneyRequestReportTransactionList({ return visibleTransactions.at(-1)?.transactionID; }, [shouldGroupTransactions, groupedTransactions, resolvedTransactions, isOffline]); + const listItems: TransactionListItemData[] = []; + if (shouldGroupTransactions) { + for (const group of groupedTransactions) { + listItems.push({type: 'section-header', groupKey: group.groupKey, group}); + for (const transaction of group.transactions) { + listItems.push({type: 'transaction', transaction}); + } + } + } else { + for (const transaction of resolvedTransactions) { + listItems.push({type: 'transaction', transaction}); + } + } + const violationsByTransactionID = useMemo(() => { const map = new Map(); const email = currentUserDetails.email ?? ''; @@ -699,82 +794,69 @@ function MoneyRequestReportTransactionList({ return map; }, [resolvedTransactions, allTransactionViolations, currentUserDetails.email, currentUserDetails.accountID, report, policy]); - const renderTransactionItem = (transaction: TransactionWithOptionalHighlight) => ( - - ); + const renderTransactionListItem = (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { + const narrowSectionWrapperStyle = shouldUseNarrowLayout + ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] + : undefined; - const transactionItems = shouldGroupTransactions - ? groupedTransactions.map((group) => { - const selectionState = groupSelectionState.get(group.groupKey) ?? { - isSelected: false, - isIndeterminate: false, - isDisabled: false, - pendingAction: undefined, - }; - return ( - - - {group.transactions.map((transaction) => renderTransactionItem(transaction))} - - ); - }) - : resolvedTransactions.map((transaction) => renderTransactionItem(transaction)); - - const narrowListWrapper = shouldUseNarrowLayout ? [styles.highlightBG, styles.tableTopRadius, styles.tableBottomRadius, styles.overflowHidden] : undefined; - - const transactionListContent = ( - - {narrowListWrapper ? {transactionItems} : transactionItems} - {showPendingExpensePlaceholder && ( - - )} - - ); + if (item.type === 'section-header') { + const selectionState = groupSelectionState.get(item.groupKey) ?? { + isSelected: false, + isIndeterminate: false, + isDisabled: false, + pendingAction: undefined, + }; + return ( + + + + + + ); + } + const transaction = item.transaction; + return ( + + + + + + ); + }; const tableHeaderContent = ( @@ -838,28 +920,24 @@ function MoneyRequestReportTransactionList({ ); - if (isEmptyTransactions) { - return ( - <> - - - - ); - } - - return ( + const beforeListContent = isEmptyTransactions ? ( <> + + + + ) : ( + {shouldShowGroupedTransactions && ( )} - {!shouldUseNarrowLayout && !shouldScrollHorizontally && tableHeaderContent} - {shouldScrollHorizontally ? ( - - - {tableHeaderContent} - {transactionListContent} - - - ) : ( - transactionListContent + {!shouldUseNarrowLayout && tableHeaderContent} + + ); + + const afterListContent = isEmptyTransactions ? null : ( + + {showPendingExpensePlaceholder && ( + + + )} - setIsModalVisible(false)} - shouldPreventScrollOnFocus - > - { - if (!isMobileSelectionModeEnabled) { - turnOnMobileSelectionMode(); - } - toggleTransaction(selectedTransactionID); - setIsModalVisible(false); - }} - /> - + + ); + + const controller: MoneyRequestReportTransactionListController = { + beforeListContent, + transactionListItems: isEmptyTransactions ? [] : listItems, + renderTransactionListItem, + afterListContent, + shouldScrollHorizontally, + tableMinWidth: minTableWidth, + horizontalScrollRestorationKey: sortedTransactions, + isEmptyTransactions, + }; + + return ( + <> + + ); } export default memo(MoneyRequestReportTransactionList); -export type {TransactionWithOptionalHighlight}; +export type {TransactionWithOptionalHighlight, TransactionListItemData, MoneyRequestReportTransactionListController}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx new file mode 100644 index 000000000000..e92ab4cf453b --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx @@ -0,0 +1,56 @@ +import React, {useImperativeHandle, useState} from 'react'; +import type {Ref} from 'react'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import CONST from '@src/CONST'; + +type MoneyRequestReportTransactionLongPressModalHandle = { + show: (transactionID: string) => void; +}; + +type MoneyRequestReportTransactionLongPressModalProps = { + isMobileSelectionModeEnabled: boolean; + toggleTransaction: (transactionID: string) => void; + ref: Ref; +}; + +function MoneyRequestReportTransactionLongPressModal({isMobileSelectionModeEnabled, toggleTransaction, ref}: MoneyRequestReportTransactionLongPressModalProps) { + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const [isVisible, setIsVisible] = useState(false); + const [selectedTransactionID, setSelectedTransactionID] = useState(''); + + useImperativeHandle(ref, () => ({ + show: (transactionID: string) => { + setSelectedTransactionID(transactionID); + setIsVisible(true); + }, + })); + + return ( + setIsVisible(false)} + shouldPreventScrollOnFocus + > + { + if (!isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleTransaction(selectedTransactionID); + setIsVisible(false); + }} + /> + + ); +} + +export default MoneyRequestReportTransactionLongPressModal; +export type {MoneyRequestReportTransactionLongPressModalHandle}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx new file mode 100644 index 000000000000..0b199e3839b0 --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -0,0 +1,191 @@ +import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; +import React, {memo, useEffect} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import FlashList from '@components/FlashList'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; +import type * as OnyxTypes from '@src/types/onyx'; +import MoneyRequestReportHorizontalScrollWrapper from './MoneyRequestReportHorizontalScrollWrapper'; +import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; +import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; +import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; + +/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; + +const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; + +function unifiedListKeyExtractor(item: UnifiedListItem) { + switch (item.type) { + case 'section-header': + return `group-${item.groupKey}`; + case 'transaction': + return item.transaction.transactionID; + case 'transactions-footer': + return 'transactions-footer'; + case 'report-action': + return item.action.reportActionID; + default: + return ''; + } +} + +function unifiedListItemType(item: UnifiedListItem) { + return item.type; +} + +type MoneyRequestReportFlashListProps = FlashListProps & { + /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ + ref: FlatListRefType; +}; + +/** + * 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. + */ +function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { + return ( + + // thin forwarder; spreading the props (including the ref) is the point + {...props} + /> + ); +} + +type MoneyRequestReportUnifiedListProps = { + controller: MoneyRequestReportTransactionListController; + report: OnyxTypes.Report; + policy?: OnyxTypes.Policy; + visibleReportActions: OnyxTypes.ReportAction[]; + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + linkedReportActionID: string | undefined; + listRef: FlatListRefType; + accessibilityLabel: string; + onLayout: () => void; + onScroll: (event: NativeSyntheticEvent) => void; + onScrollBeginDrag: () => void; + onContentSizeChange: () => void; + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onEndReached: () => void; + onStartReached: () => void; + contentContainerStyle: StyleProp; + isOffline: boolean; + isLoadingInitialActions: boolean; + skeletonReasonAttributes: SkeletonSpanReasonAttributes; + /** Reports the index of the last list item so callers can jump to the bottom via scrollToIndex (which renders the + * landing region, unlike scrollToEnd's estimated-offset jump that leaves the bottom blank on large lists). */ + onLastItemIndexChange?: (index: number) => void; +}; + +function MoneyRequestReportUnifiedList({ + controller, + report, + policy, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + accessibilityLabel, + onLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isOffline, + isLoadingInitialActions, + skeletonReasonAttributes, + onLastItemIndexChange, +}: MoneyRequestReportUnifiedListProps) { + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); + const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + + // Report the last index so callers can jump to the bottom via scrollToIndex. + const lastDataIndex = data.length - 1; + + useEffect(() => { + onLastItemIndexChange?.(lastDataIndex); + }, [lastDataIndex, onLastItemIndexChange]); + + const lastTransactionItemIndex = controller.transactionListItems.length - 1; + const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + + // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against + // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. + const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + if (reportActionIndexOffset === 0) { + onViewableItemsChanged(info); + return; + } + onViewableItemsChanged({ + ...info, + viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), + }); + }; + + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + switch (item.type) { + case 'section-header': + case 'transaction': + return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); + case 'transactions-footer': + return controller.afterListContent; + case 'report-action': + return renderReportAction(item.action, index - reportActionIndexOffset); + default: + return null; + } + }; + + const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; + const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; + + return ( + + + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + onScrollBeginDrag={onScrollBeginDrag} + onContentSizeChange={onContentSizeChange} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + drawDistance={1000} + /> + + ); +} + +export default memo(MoneyRequestReportUnifiedList); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 1ed2c932676d..0ee76bf7e879 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -105,7 +105,7 @@ function TableBody({contentContainerStyle, style, .. showsVerticalScrollIndicator={false} maintainVisibleContentPosition={{disabled: true}} ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent} - contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flexGrow1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle]} + contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flex1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle]} keyboardShouldPersistTaps="handled" {...restListProps} /> diff --git a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx index ec1abcd1f7b1..959c4906ea2d 100644 --- a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx @@ -1,10 +1,14 @@ import React, {useDeferredValue} from 'react'; import TransactionItemRowRBR from './TransactionItemRowRBR'; -type DeferredTransactionItemRowRBRProps = React.ComponentProps; +type DeferredTransactionItemRowRBRProps = React.ComponentProps & { + /** When false, renders RBR immediately. Use false in FlashList rows to avoid recycling/layout issues from deferred mount. */ + shouldDefer?: boolean; +}; -function DeferredTransactionItemRowRBR(props: DeferredTransactionItemRowRBRProps) { - const shouldRender = useDeferredValue(true, false); +function DeferredTransactionItemRowRBR({shouldDefer = true, ...props}: DeferredTransactionItemRowRBRProps) { + const deferredShouldRender = useDeferredValue(true, false); + const shouldRender = shouldDefer ? deferredShouldRender : true; // Skip placeholder while deferring to avoid layout shift on rows without RBR content if (!shouldRender) { diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index b9762ae3a016..44b909fcb4c0 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -16,7 +16,7 @@ import ReceiptCell from './DataCells/ReceiptCell'; import TotalCell from './DataCells/TotalCell'; import TypeCell from './DataCells/TypeCell'; import DeferredTransactionItemRowRBR from './DeferredTransactionItemRowRBR'; -import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps} from './types'; +import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps, TransactionItemRowRBRDeferControlProps} from './types'; type TransactionItemRowNarrowProps = Pick< TransactionItemRowProps, @@ -42,7 +42,8 @@ type TransactionItemRowNarrowProps = Pick< | 'shouldShowArrowRightOnNarrowLayout' | 'checkboxSentryLabel' > & - TransactionItemRowNarrowComputedData; + TransactionItemRowNarrowComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowNarrow({ transactionItem, @@ -66,6 +67,7 @@ function TransactionItemRowNarrow({ onArrowRightPress, shouldShowArrowRightOnNarrowLayout, checkboxSentryLabel, + shouldDeferRBR = true, bgActiveStyles, merchant, merchantOrDescription, @@ -173,6 +175,7 @@ function TransactionItemRowNarrow({ {shouldShowErrors && ( & - TransactionItemRowWideComputedData; +type TransactionItemRowWideProps = Omit< + TransactionItemRowProps, + 'shouldUseNarrowLayout' | 'isAttendeesEnabledForMovingPolicy' | 'isLargeScreenWidth' | 'shouldShowCheckbox' | 'shouldSkipDeferRBR' +> & + TransactionItemRowWideComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowWide({ transactionItem, @@ -81,6 +85,7 @@ function TransactionItemRowWide({ shouldStopRadioButtonMouseDownPropagation = false, radioButtonContainerStyle, shouldShowErrors = true, + shouldDeferRBR = true, isDisabled = false, violations, shouldShowBottomBorder, @@ -658,6 +663,7 @@ function TransactionItemRowWide({ {shouldShowErrors && ( ); } @@ -262,6 +265,7 @@ function TransactionItemRow({ totalPerAttendee={!attendeesCount || totalAmount === undefined ? undefined : totalAmount / attendeesCount} createdAt={createdAt} transactionThreadReportID={transactionThreadReportID} + shouldDeferRBR={shouldDeferRBR} isMarkAsDone={shouldUseMarkAsDoneCopy} /> ); diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 82e5001c6d79..70687e82572f 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -113,6 +113,14 @@ type TransactionItemRowProps = { canEditCategory?: boolean; canEditAmount?: boolean; canEditTag?: boolean; + + /** When true, RBR content renders immediately instead of via useDeferredValue. Use in FlashList contexts. */ + shouldSkipDeferRBR?: boolean; +}; + +/** Derived from shouldSkipDeferRBR; passed to layout variants for DeferredTransactionItemRowRBR. */ +type TransactionItemRowRBRDeferControlProps = { + shouldDeferRBR?: boolean; }; /** @@ -143,4 +151,4 @@ type TransactionItemRowWideComputedData = Omit= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { return; } diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index 688db5c8f78f..ba3dd2cfa224 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -1,6 +1,6 @@ import {useEffect, useLayoutEffect, useRef} from 'react'; import type React from 'react'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; +import CONST from '@src/CONST'; import usePrevious from './usePrevious'; type UseScrollToEndOnPaginationMergeParams = { @@ -63,7 +63,7 @@ function useScrollToEndOnNewMessageReceived({ const didListSizeChange = sizeChangeType === 'grewFromReportActions' ? reportActionSize.current > (reportActionsLength ?? 0) : reportActionSize.current !== visibleActionsLength; if ( - scrollOffsetRef.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollOffsetRef.current < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionID && didListSizeChange && hasNewestReportAction && diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts deleted file mode 100644 index 4d0986216e59..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts +++ /dev/null @@ -1,4 +0,0 @@ -function getInitialNumToRender(numToRender: number): number { - return numToRender; -} -export default getInitialNumToRender; diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts deleted file mode 100644 index 217d5d3789dd..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -const DEFAULT_NUM_TO_RENDER = 50; - -function getInitialNumToRender(numToRender: number): number { - // For web environment, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list. - return Math.max(numToRender, DEFAULT_NUM_TO_RENDER); -} -export default getInitialNumToRender;