From 02fa27017178bc278dab524a5eaaca563546a048 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 May 2026 20:33:06 +0100 Subject: [PATCH 1/4] Reapply "Open reports at first unread action" This reverts commit d944ae03cafbdc33d847af1f4bebb99ee04a4ab0. --- jest/setup.ts | 3 +- .../FlashList/InvertedFlashList/index.tsx | 23 +- .../FlashList/useFlashListScrollKey.ts | 15 +- .../BaseFlatListWithScrollKey.tsx | 8 +- src/components/FlatList/RenderTaskQueue.tsx | 1 + .../MoneyRequestReportActionsList.tsx | 8 +- src/hooks/usePaginatedReportActions.ts | 41 +- .../useScrollToEndOnNewMessageReceived.ts | 16 +- src/libs/API/index.ts | 4 +- src/libs/Middleware/Pagination.ts | 11 +- src/libs/NumberUtils.ts | 10 +- src/libs/PaginationUtils.ts | 226 +++++++++- src/libs/actions/Report/index.ts | 15 + src/pages/inbox/ReportActionsList.tsx | 6 +- src/pages/inbox/ReportScreen.tsx | 7 +- src/pages/inbox/report/ReportActionsList.tsx | 395 ++++++++++-------- src/pages/inbox/report/ReportActionsView.tsx | 238 ++++------- .../getReportActionsListInitialNumToRender.ts | 10 +- .../inbox/report/getReportActionsToDisplay.ts | 70 ++++ .../useReportActionsNewActionLiveTail.ts | 211 ++++++++++ .../useReportUnreadMessageScrollTracking.ts | 7 +- src/pages/inbox/types.ts | 9 + .../perf-test/ReportActionsList.perf-test.tsx | 9 +- tests/ui/PaginationTest.tsx | 17 +- tests/unit/PaginationUtilsTest.ts | 140 ++++++- ...ReportActionsListInitialNumToRenderTest.ts | 2 +- ...seReportUnreadMessageScrollTrackingTest.ts | 27 ++ 27 files changed, 1119 insertions(+), 410 deletions(-) create mode 100644 src/pages/inbox/report/getReportActionsToDisplay.ts create mode 100644 src/pages/inbox/report/useReportActionsNewActionLiveTail.ts create mode 100644 src/pages/inbox/types.ts diff --git a/jest/setup.ts b/jest/setup.ts index dd72333af2ce..084ee67fd608 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -11,6 +11,7 @@ import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; import {TextDecoder, TextEncoder} from 'util'; +import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@src/polyfills/PromiseWithResolvers'; import '@src/polyfills/requestIdleCallback'; import mockFSLibrary from './setupMockFullstoryLib'; @@ -265,7 +266,7 @@ jest.mock( class SyncRenderTaskQueue { private handler: (info: unknown) => void = () => {}; - add(info: unknown) { + add(info: RenderInfo) { this.handler(info); } diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 7d591d2650cb..10910efb9c8c 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -22,8 +22,20 @@ type InvertedFlashListProps = FlashListProps & { shouldMaintainVisibleContentPosition?: boolean; }; -function InvertedFlashList({data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, shouldMaintainVisibleContentPosition, ...restProps}: InvertedFlashListProps) { - const {displayedData, onStartReached, maintainVisibleContentPosition} = useFlashListScrollKey({ +function InvertedFlashList({ + data, + keyExtractor, + initialScrollKey, + onStartReached: onStartReachedProp, + maintainVisibleContentPosition: maintainVisibleContentPositionProp, + shouldMaintainVisibleContentPosition, + ...restProps +}: InvertedFlashListProps) { + const { + displayedData, + onStartReached, + maintainVisibleContentPosition: maintainVisibleContentPositionForScrollKey, + } = useFlashListScrollKey({ data, keyExtractor, initialScrollKey, @@ -31,6 +43,13 @@ function InvertedFlashList({data, keyExtractor, initialScrollKey, onStartReac shouldMaintainVisibleContentPosition, }); + const maintainVisibleContentPosition = maintainVisibleContentPositionProp + ? { + ...maintainVisibleContentPositionForScrollKey, + ...maintainVisibleContentPositionProp, + } + : maintainVisibleContentPositionForScrollKey; + return ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index 1ab9d3f92e25..6dd30127bbc7 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -19,7 +19,7 @@ type FlashListScrollKeyProps = { }; export default function useFlashListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, shouldMaintainVisibleContentPosition}: FlashListScrollKeyProps) { - const [isInitialRender, setIsInitialRender] = useState(true); + const [isInitialRender, setIsInitialRender] = useState(!!initialScrollKey); const [hasLinkingSettled, setHasLinkingSettled] = useState(!initialScrollKey); // Two-frame handoff for deep-link: @@ -27,9 +27,20 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr // linked item through the data swap. // RAF 2: pinning has happened, disable MVCP so it doesn't cause later jumps. useEffect(() => { - if (!isInitialRender || !initialScrollKey) { + if (!isInitialRender) { return; } + + // Without an anchor on this frame, we are not doing the deep-link slice handoff; clear the flag so a key that + // appears later (e.g. marking a message unread) cannot reuse the "first paint" slice path. + if (!initialScrollKey) { + // If the initial scroll key gets unset, we need to disable the initial render flag, + // otherwise the list will not render.. + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsInitialRender(false); + return; + } + requestAnimationFrame(() => { setIsInitialRender(false); requestAnimationFrame(() => setHasLinkingSettled(true)); diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx index 70bfc9060901..98c43ea4bbfb 100644 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -19,7 +19,7 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey onScrollBeginDrag, onWheel, onTouchStartCapture, - ...rest + ...restProps } = props; const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ data, @@ -52,7 +52,7 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey ({ref, ...props}: BaseFlatListWithScrollKey // Since ListHeaderComponent is always prioritized for rendering before the data, // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. - ListHeaderComponent={!isInitialData ? rest.ListHeaderComponent : undefined} - contentContainerStyle={!isInitialData ? rest.contentContainerStyle : undefined} + ListHeaderComponent={!isInitialData ? restProps.ListHeaderComponent : undefined} + contentContainerStyle={!isInitialData ? restProps.contentContainerStyle : undefined} onContentSizeChange={(width, height) => onContentSizeChange?.(width, height, isInitialData)} onViewableItemsChanged={(info) => { onViewableItemsChanged?.(info); diff --git a/src/components/FlatList/RenderTaskQueue.tsx b/src/components/FlatList/RenderTaskQueue.tsx index 9346bbe53b31..4f0d791cad29 100644 --- a/src/components/FlatList/RenderTaskQueue.tsx +++ b/src/components/FlatList/RenderTaskQueue.tsx @@ -58,3 +58,4 @@ class RenderTaskQueue { } export default RenderTaskQueue; +export type {RenderInfo}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b2d3bc97489a..bcb0f3b24aa1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -434,6 +434,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) readActionSkippedRef: readActionSkipped, unreadMarkerReportActionIndex, isInverted: false, + hasNewerActions, onTrackScrolling: (event: NativeSyntheticEvent) => { const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; const fullContentHeight = contentSize.height; @@ -460,6 +461,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) hasNewestReportAction, setIsFloatingMessageCounterVisible, scrollToEnd: reportScrollManager.scrollToEnd, + resetKey: report.reportID, }); /** @@ -608,15 +610,15 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) setIsFloatingMessageCounterVisible(false); if (!hasNewestReportAction) { - openReport({reportID: report?.reportID, introSelected, betas}); + openReport({reportID, introSelected, betas}); reportScrollManager.scrollToEnd(); return; } reportScrollManager.scrollToEnd(); readActionSkipped.current = false; - readNewestAction(report?.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report?.reportID, reportLoadingState?.hasOnceLoadedReportActions, introSelected, betas]); + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, reportLoadingState?.hasOnceLoadedReportActions, introSelected, betas]); const scrollToNewTransaction = useCallback( (pageY: number) => { diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 6f5fa79cd144..0184ef932afc 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -12,13 +12,16 @@ import useReportIsArchived from './useReportIsArchived'; type UsePaginatedReportActionsOptions = { /** Whether to link to the oldest unread report action, if no other report action id is provided. */ shouldLinkToOldestUnreadReportAction?: boolean; + + /** When true, pagination anchors to the newest window only (ignores route and unread-derived anchors). */ + treatAsNoPaginationAnchor?: boolean; }; /** * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. */ function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) { - const {shouldLinkToOldestUnreadReportAction = false} = options ?? {}; + const {shouldLinkToOldestUnreadReportAction = false, treatAsNoPaginationAnchor = false} = options ?? {}; const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`); @@ -44,6 +47,11 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const initialReportLastReadTime = useRef(report?.lastReadTime); const id = useMemo(() => { + /* eslint-disable react-hooks/refs -- initialReportLastReadTime snapshots lastRead at first render for stable unread deep-link anchor */ + if (treatAsNoPaginationAnchor) { + return undefined; + } + if (reportActionID) { return reportActionID; } @@ -52,14 +60,14 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return undefined; } - return sortedAllReportActions?.findLast((reportAction) => { - if (!initialReportLastReadTime.current) { - return false; - } + const initialLastReadTime = initialReportLastReadTime.current; + if (!initialLastReadTime || !sortedAllReportActions?.length) { + return undefined; + } - return reportAction.created > initialReportLastReadTime.current; - })?.reportActionID; - }, [reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); + return sortedAllReportActions.findLast((reportAction) => reportAction.created > initialLastReadTime)?.reportActionID; + /* eslint-enable react-hooks/refs */ + }, [treatAsNoPaginationAnchor, reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); const { data: reportActions, @@ -74,14 +82,27 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, id); }, [id, reportActionPages, sortedAllReportActions]); - const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem?.item, reportActionID]); + // When `treatAsNoPaginationAnchor` is set, we intentionally ignore `reportActionID` for pagination + // (same as `id` above), so we must not surface a "linked" action from that id either. + const linkedAction = useMemo(() => { + if (treatAsNoPaginationAnchor) { + return undefined; + } + if (!reportActionID) { + return undefined; + } + return resourceItem?.item; + }, [resourceItem?.item, reportActionID, treatAsNoPaginationAnchor]); const oldestUnreadReportAction = useMemo(() => { + if (treatAsNoPaginationAnchor) { + return undefined; + } if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { return resourceItem.item; } return undefined; - }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); + }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID, treatAsNoPaginationAnchor]); return { reportActions, diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index cf910921d9a5..688db5c8f78f 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useLayoutEffect, useRef} from 'react'; import type React from 'react'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; import usePrevious from './usePrevious'; @@ -43,8 +43,22 @@ function useScrollToEndOnNewMessageReceived({ }: UseScrollToEndOnPaginationMergeParams) { const previousLastIndex = useRef(lastActionID); const reportActionSize = useRef(visibleActionsLength); + const previousResetKeyRef = useRef(undefined); const prevHasNewestReportAction = usePrevious(hasNewestReportAction); + // When the hook is used across report navigations, baselines from the previous report must not drive scroll logic. + useLayoutEffect(() => { + if (resetKey === undefined) { + return; + } + if (previousResetKeyRef.current === resetKey) { + return; + } + previousResetKeyRef.current = resetKey; + previousLastIndex.current = lastActionID; + reportActionSize.current = visibleActionsLength; + }, [resetKey, lastActionID, visibleActionsLength]); + useEffect(() => { const didListSizeChange = sizeChangeType === 'grewFromReportActions' ? reportActionSize.current > (reportActionsLength ?? 0) : reportActionSize.current !== visibleActionsLength; diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 0905cf1c4922..00578ec0f553 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -291,9 +291,7 @@ function paginate = { ...prepareRequest(command, type, apiCommandParameters, onyxData, conflictResolver), ...config, - ...{ - isPaginated: true, - }, + isPaginated: true, }; switch (type) { diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index bc5a65b7fbb2..2687e2325309 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -3,11 +3,12 @@ import type {OnyxCollection, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ApiCommand} from '@libs/API/types'; import Log from '@libs/Log'; -import PaginationUtils from '@libs/PaginationUtils'; +import {mergeAndSortContinuousPages, mergePagesByIDOverlap} from '@libs/PaginationUtils'; import CONST from '@src/CONST'; import type {OnyxCollectionKey, OnyxPagesKey, OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; +import type Pages from '@src/types/onyx/Pages'; import type {AnyOnyxUpdate, PaginatedRequest} from '@src/types/onyx/Request'; import type Middleware from './types'; @@ -124,14 +125,18 @@ const Pagination: Middleware = (requestResponse, request) => { const sortedAllItems = sortItems(allItems, resourceID); const pagesCollections = pages.get(pageCollectionKey) ?? {}; - const existingPages = pagesCollections[pageKey] ?? []; + const existingPages: Pages = pagesCollections[pageKey] ?? []; + + const isMiddleInitialSlice = type === 'initial' && !cursorID && response.hasNewerActions === true && response.hasOlderActions === true; // Only strip PAGINATION_START_ID from cached pages when the server explicitly confirms newer actions exist. // Some commands (e.g. GetOlderActions) don't return hasNewerActions at all — in that case, preserve the existing boundary. const shouldStripStartMarker = response.hasNewerActions === true; const sanitizedExistingPages = shouldStripStartMarker ? existingPages.map((page) => page.filter((id) => id !== CONST.PAGINATION_START_ID)) : existingPages; - const mergedPages = PaginationUtils.mergeAndSortContinuousPages(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID); + const mergedPages: Pages = isMiddleInitialSlice + ? mergePagesByIDOverlap(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID) + : mergeAndSortContinuousPages(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID); (response.onyxData as AnyOnyxUpdate[]).push({ key: pageKey, diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index 637d2e74de94..62aeb2dada29 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -69,12 +69,4 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -function generateNewRandomInt(old: number, min: number, max: number): number { - let newNum = old; - while (newNum === old) { - newNum = generateRandomInt(min, max); - } - return newNum; -} - -export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp, generateNewRandomInt}; +export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp}; diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index dfd5eb8d782b..7ad93de3e7b0 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -1,6 +1,20 @@ import CONST from '@src/CONST'; import type Pages from '@src/types/onyx/Pages'; +function isPaginationMarker(id: string): boolean { + return id === CONST.PAGINATION_START_ID || id === CONST.PAGINATION_END_ID; +} + +function buildIDToIndexMap(sortedItems: TResource[], getID: (item: TResource) => string): Map { + const map = new Map(); + let index = 0; + for (const item of sortedItems) { + map.set(getID(item), index); + index++; + } + return map; +} + type PageWithIndex = { /** The IDs we store in Onyx and which make up the page. */ ids: string[]; @@ -41,13 +55,13 @@ type ContinuousPageChainResult = { /** * Finds the id and index in sortedItems of the first item in the given page that's present in sortedItems. */ -function findFirstItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null { +function findFirstItem(page: string[], idToIndex: Map): ItemWithIndex | null { for (const id of page) { if (id === CONST.PAGINATION_START_ID) { return {id, index: 0}; } - const index = sortedItems.findIndex((item) => getID(item) === id); - if (index !== -1) { + const index = idToIndex.get(id); + if (index !== undefined) { return {id, index}; } } @@ -57,14 +71,17 @@ function findFirstItem(sortedItems: TResource[], page: string[], getI /** * Finds the id and index in sortedItems of the last item in the given page that's present in sortedItems. */ -function findLastItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null { +function findLastItem(page: string[], idToIndex: Map, lastSortedItemIndex: number): ItemWithIndex | null { for (let i = page.length - 1; i >= 0; i--) { const id = page.at(i); if (id === CONST.PAGINATION_END_ID) { - return {id, index: sortedItems.length - 1}; + return {id, index: lastSortedItemIndex}; + } + if (!id) { + continue; } - const index = sortedItems.findIndex((item) => getID(item) === id); - if (index !== -1 && id) { + const index = idToIndex.get(id); + if (index !== undefined) { return {id, index}; } } @@ -74,11 +91,14 @@ function findLastItem(sortedItems: TResource[], page: string[], getID /** * Finds the index and id of the first and last items of each page in `sortedItems`. */ -function getPagesWithIndexes(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string): PageWithIndex[] { +function getPagesWithIndexes(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, idToIndex?: Map): PageWithIndex[] { + const idToIndexMap = idToIndex ?? buildIDToIndexMap(sortedItems, getID); + const lastSortedItemIndex = sortedItems.length - 1; + return pages .map((page) => { - let firstItem = findFirstItem(sortedItems, page, getID); - let lastItem = findLastItem(sortedItems, page, getID); + let firstItem = findFirstItem(page, idToIndexMap); + let lastItem = findLastItem(page, idToIndexMap, lastSortedItemIndex); // If all actions in the page are not found it will be removed. if (firstItem === null || lastItem === null) { @@ -169,6 +189,173 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: return result.map((page) => page?.ids ?? []); } +type PageWithSortKey = { + ids: string[]; + firstIndex: number; + lastIndex: number; +}; + +function getFirstAndLastIndexForPage(page: string[], idToIndex: Map, lastIndexInSortedItems: number): {firstIndex: number; lastIndex: number} | null { + let firstIndex: number | undefined; + let lastIndex: number | undefined; + + for (const id of page) { + if (id === CONST.PAGINATION_START_ID) { + firstIndex = 0; + continue; + } + + const index = idToIndex.get(id); + if (index === undefined) { + continue; + } + + if (firstIndex === undefined || index < firstIndex) { + firstIndex = index; + } + if (lastIndex === undefined || index > lastIndex) { + lastIndex = index; + } + } + + if (page.at(-1) === CONST.PAGINATION_END_ID) { + lastIndex = lastIndexInSortedItems; + } + + if (firstIndex === undefined || lastIndex === undefined) { + return null; + } + + return {firstIndex, lastIndex}; +} + +function pagesShareAnyNonMarkerID(pageA: string[], pageB: string[]): boolean { + const a = pageA.filter((id) => !isPaginationMarker(id)); + const b = pageB.filter((id) => !isPaginationMarker(id)); + + if (a.length === 0 || b.length === 0) { + return false; + } + + const [smaller, larger] = a.length <= b.length ? [a, b] : [b, a]; + const set = new Set(smaller); + for (const id of larger) { + if (set.has(id)) { + return true; + } + } + return false; +} + +function mergeTwoPagesByUnionAndSort(idToIndex: Map, pageA: string[], pageB: string[]): string[] { + const hasStart = pageA.at(0) === CONST.PAGINATION_START_ID || pageB.at(0) === CONST.PAGINATION_START_ID; + const hasEnd = pageA.at(-1) === CONST.PAGINATION_END_ID || pageB.at(-1) === CONST.PAGINATION_END_ID; + + const uniqueIDs = new Set(); + for (const id of [...pageA, ...pageB]) { + if (isPaginationMarker(id)) { + continue; + } + if (!idToIndex.has(id)) { + continue; + } + uniqueIDs.add(id); + } + + const sortedIDs = [...uniqueIDs].sort((a, b) => (idToIndex.get(a) ?? 0) - (idToIndex.get(b) ?? 0)); + if (hasStart) { + sortedIDs.unshift(CONST.PAGINATION_START_ID); + } + if (hasEnd) { + sortedIDs.push(CONST.PAGINATION_END_ID); + } + return sortedIDs; +} + +/** + * Merge pages only when they have clear ID-overlap evidence. + * + * This intentionally does NOT use index overlap between pages to infer continuity, because when we + * open a report in the middle of the chat (e.g. last-unread), the locally available action set may + * not contain the actions in the gap, making disjoint pages appear overlapping. + */ +function mergePagesByIDOverlap(sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string): Pages { + if (pages.length === 0) { + return []; + } + + const idToIndex = buildIDToIndexMap(sortedItems, getItemID); + const lastIndexInSortedItems = Math.max(0, sortedItems.length - 1); + + const pagesWithKeys: PageWithSortKey[] = []; + for (const page of pages) { + const indexes = getFirstAndLastIndexForPage(page, idToIndex, lastIndexInSortedItems); + if (!indexes) { + continue; + } + + // Remove any IDs we don't currently have so stored pages don't imply we have the gap contents. + const filteredIDs = page.filter((id) => isPaginationMarker(id) || idToIndex.has(id)); + pagesWithKeys.push({...indexes, ids: filteredIDs}); + } + + if (pagesWithKeys.length === 0) { + return []; + } + + pagesWithKeys.sort((a, b) => a.firstIndex - b.firstIndex); + + const result: string[][] = [pagesWithKeys.at(0)?.ids ?? []]; + for (let i = 1; i < pagesWithKeys.length; i++) { + const current = pagesWithKeys.at(i)?.ids ?? []; + const previous = result.at(-1) ?? []; + + const shouldMerge = current.at(0) === previous.at(-1) || pagesShareAnyNonMarkerID(previous, current); + if (!shouldMerge) { + result.push(current); + continue; + } + + result[result.length - 1] = mergeTwoPagesByUnionAndSort(idToIndex, previous, current); + } + + return result; +} + +/** + * Picks the chronologically newest page: prefers the slice marked with PAGINATION_START_ID (synced to present), + * otherwise the page whose span starts at the smallest index in descending-sorted items. + */ +function selectNewestPageWithIndex(pagesWithIndexes: PageWithIndex[]): PageWithIndex | undefined { + if (pagesWithIndexes.length === 0) { + return undefined; + } + + const pageWithStartMarker = pagesWithIndexes.find((pageWithIndex) => pageWithIndex.firstID === CONST.PAGINATION_START_ID); + if (pageWithStartMarker) { + return pageWithStartMarker; + } + + return pagesWithIndexes.reduce((newest, candidate) => (candidate.firstIndex < newest.firstIndex ? candidate : newest)); +} + +/** + * Collapses pagination to a single page row for the newest window. Used after jumping to the live tail of a chat. + */ +function prunePagesToNewestWindow(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string): Pages { + if (pages.length <= 1) { + return pages; + } + + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); + const newestPage = selectNewestPageWithIndex(pagesWithIndexes); + if (!newestPage) { + return pages; + } + + return [newestPage.ids]; +} + /** * Returns the page of items that contains the item with the given ID, or the first page if null. * Also returns whether next / previous pages can be fetched. @@ -177,11 +364,14 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: * Note: sortedItems should be sorted in descending order. */ function getContinuousChain(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, id?: string): ContinuousPageChainResult { + const shouldBuildIdToIndex = !!id || pages.length > 0; + const idToIndex = shouldBuildIdToIndex ? buildIDToIndexMap(sortedItems, getID) : new Map(); + // If an id is provided, find the index of the item with that id let index = -1; if (id) { - index = sortedItems.findIndex((item) => getID(item) === id); + index = idToIndex.get(id) ?? -1; } const didFindItem = index !== -1; @@ -202,7 +392,7 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g return {data: !!id && !didFindItem ? [] : sortedItems, hasNextPage: false, hasPreviousPage: false, resourceItem}; } - const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID, idToIndex); let page: PageWithIndex = { ids: [], @@ -231,10 +421,10 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g page = linkedPage; } } else { - // If we did not find an item with the resource id, we want to link to the first page - const pageAtIndex0 = pagesWithIndexes.at(0); - if (pageAtIndex0) { - page = pageAtIndex0; + // If we did not find an item with the resource id, show the newest page (not rely on arbitrary Onyx page order). + const newestPage = selectNewestPageWithIndex(pagesWithIndexes); + if (newestPage) { + page = newestPage; } } @@ -250,4 +440,6 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g }; } -export default {mergeAndSortContinuousPages, getContinuousChain}; +export {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain, prunePagesToNewestWindow, selectNewestPageWithIndex}; + +export default {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain, prunePagesToNewestWindow, selectNewestPageWithIndex}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 142a9a3c61d8..3500f0882720 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -89,6 +89,7 @@ import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import LocalNotification from '@libs/Notification/LocalNotification'; import {rand64} from '@libs/NumberUtils'; import capturePageHTML from '@libs/PageHTMLCapture'; +import PaginationUtils from '@libs/PaginationUtils'; import Parser from '@libs/Parser'; import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -212,6 +213,7 @@ import type { Onboarding, OnboardingPurpose, OnboardingRHPVariant, + Pages, PersonalDetailsList, Policy, PolicyEmployee, @@ -1782,6 +1784,18 @@ function openReport(params: OpenReportActionParams) { } } +/** + * Drops stale mid-chat pagination rows after the list shows the live tail and scroll completed. + */ +function pruneReportActionPagesToNewestWindow(reportID: string | undefined, sortedReportActions: ReportAction[], pages: Pages | undefined) { + if (!reportID || !pages?.length || pages.length <= 1) { + return; + } + + const pruned = PaginationUtils.prunePagesToNewestWindow(sortedReportActions, pages, (action) => action.reportActionID); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportID}`, pruned); +} + /** * Create a group chat report. Simplified version specifically for group chats without unnecessary logic. * @@ -7790,6 +7804,7 @@ export { optimisticReportLastData, setOptimisticTransactionThread, prepareOnyxDataForCleanUpOptimisticParticipants, + pruneReportActionPagesToNewestWindow, getGuidedSetupDataForOpenReport, getReportChannelName, }; diff --git a/src/pages/inbox/ReportActionsList.tsx b/src/pages/inbox/ReportActionsList.tsx index 38b9741ec5fd..f94f73f20402 100644 --- a/src/pages/inbox/ReportActionsList.tsx +++ b/src/pages/inbox/ReportActionsList.tsx @@ -11,6 +11,7 @@ import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitFo import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportActionsView from './report/ReportActionsView'; +import type ReportScreenNavigationProps from './types'; const defaultReportLoadingState = { hasOnceLoadedReportActions: false, @@ -27,9 +28,8 @@ const defaultReportLoadingState = { * conditions need — heavy data derivation is pushed into each child. */ function ReportActionsList() { - const route = useRoute(); - const routeParams = route.params as {reportID?: string} | undefined; - const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID); + const route = useRoute(); + const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const {isOffline} = useNetwork(); diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index acf78a3dbb45..bd1df7d91dc1 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -16,8 +16,6 @@ import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import {removeFailedReport} from '@libs/actions/Report'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -40,12 +38,9 @@ import ReportNavigateAwayHandler from './ReportNavigateAwayHandler'; import ReportNotFoundGuard from './ReportNotFoundGuard'; import ReportRouteParamHandler from './ReportRouteParamHandler'; import {ActionListContext} from './ReportScreenContext'; +import type ReportScreenNavigationProps from './types'; import WideRHPReceiptPanel from './WideRHPReceiptPanel'; -type ReportScreenNavigationProps = - | PlatformStackScreenProps - | PlatformStackScreenProps; - type ReportScreenProps = ReportScreenNavigationProps; function ReportScreen({route, navigation}: ReportScreenProps) { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 7062af6b5cc5..c61a42f42d2b 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -23,12 +23,12 @@ import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessag import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {openReport, readNewestAction} from '@libs/actions/Report'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; import FS from '@libs/Fullstory'; import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -65,7 +65,6 @@ import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; -import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -79,6 +78,7 @@ import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import StaticReportActionsPreview from './StaticReportActionsPreview'; +import useReportActionsNewActionLiveTail from './useReportActionsNewActionLiveTail'; import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; type ReportActionsListProps = { @@ -112,11 +112,28 @@ type ReportActionsListProps = { /** Function to load newer chats */ loadNewerChats: (force?: boolean) => void; + /** Whether the report has newer actions to load */ + hasNewerActions: boolean; + + /** The oldest unread report action */ + oldestUnreadReportAction?: OnyxEntry | undefined; + + /** Full sorted report actions for collapsing stale pagination after a live-tail jump */ + sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; + + /** Current report action pages from Onyx */ + reportActionPages: OnyxTypes.Pages | undefined; + + /** When true, the paginated hook ignores deep-link / unread anchors */ + treatAsNoPaginationAnchor: boolean; + + setTreatAsNoPaginationAnchor: (value: boolean) => void; + /** Whether the composer is in full size */ isComposerFullSize?: boolean; - /** ID of the list */ - listID: number; + /** Stable key to remount the list when the deep-linked action or unread anchor (or report) changes */ + listID: string; /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; @@ -131,12 +148,6 @@ type ReportActionsListProps = { onShowPreviousMessages?: () => void; }; -// In the component we are subscribing to the arrival of new actions. -// As there is the possibility that there are multiple instances of a ReportScreen -// for the same report, we only ever want one subscription to be active, as -// the subscriptions could otherwise be conflicting. -const newActionUnsubscribeMap: Record void> = {}; - // Seems that there is an architecture issue that prevents us from using the reportID with useRef // the useRef value gets reset when the reportID changes, so we use a global variable to keep track let prevReportID: string | null = null; @@ -159,6 +170,12 @@ function ReportActionsList({ onScroll, loadNewerChats, loadOlderChats, + hasNewerActions, + oldestUnreadReportAction, + sortedAllReportActionsForPagination, + reportActionPages, + treatAsNoPaginationAnchor, + setTreatAsNoPaginationAnchor, onLayout, isComposerFullSize, listID, @@ -189,6 +206,7 @@ function ReportActionsList({ const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); + const isAnonymousUser = useIsAnonymousUser(); const isReportArchived = useReportIsArchived(report?.reportID); const [reportActionsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); @@ -196,14 +214,113 @@ function ReportActionsList({ const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`); + const prevIsLoadingInitialReportActions = usePrevious(reportLoadingState?.isLoadingInitialReportActions); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; + useEffect(() => { + const unsubscribe = Visibility.onVisibilityChange(() => { + setIsVisible(Visibility.isVisible()); + }); + + return unsubscribe; + }, []); + + const readActionSkipped = useRef(false); + const hasHeaderRendered = useRef(false); + + const lastAction = sortedVisibleReportActions.at(0); + const [shouldMaintainVisibleContentPosition, setShouldMaintainVisibleContentPosition] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( + () => + sortedVisibleReportActions.reduce((actions, action) => { + Object.assign(actions, {[action.reportActionID]: action}); + return actions; + }, {}), + [sortedVisibleReportActions], + ); + const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); + + const reportLastReadTime = report.lastReadTime ?? ''; + + /** + * The index of the earliest message that was received while offline + */ + const earliestReceivedOfflineMessageIndex = useMemo(() => { + // Create a list of (sorted) indices of message that were received while offline + const receivedOfflineMessages = sortedReportActions.reduce((acc, message, index) => { + if (wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { + acc[index] = index; + } + + return acc; + }, []); + + // The last index in the list is the earliest message that was received while offline + return receivedOfflineMessages.at(-1); + }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); + + // Index must be in the same domain as FlatList `data` (sortedVisibleReportActions), not the paginated full chain. + const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>(() => { + if (!oldestUnreadReportAction || reportLoadingState?.hasOnceLoadedReportActions) { + return undefined; + } + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (visibleIndex < 0) { + return undefined; + } + return [oldestUnreadReportAction.reportActionID, visibleIndex]; + }, [oldestUnreadReportAction, reportLoadingState?.hasOnceLoadedReportActions, sortedVisibleReportActions]); + + /** + * The reportActionID the unread marker should display above + */ + const prevUnreadMarkerReportActionID = useRef(null); + const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo(() => { + // eslint-disable-next-line react-hooks/refs + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset: scrollOffsetRef.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isOffline, + isReversed: false, + isAnonymousUser, + }); + if (oldestUnreadReportActionMarker) { + const [oldestAnchorActionID] = oldestUnreadReportActionMarker; + // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user + // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. + if (scanned[0] !== null && scanned[0] !== oldestAnchorActionID) { + return scanned; + } + } + return oldestUnreadReportActionMarker ?? scanned; + }, [ + currentUserAccountID, + earliestReceivedOfflineMessageIndex, + isAnonymousUser, + isOffline, + oldestUnreadReportActionMarker, + prevSortedVisibleReportActionsObjects, + scrollOffsetRef, + sortedVisibleReportActions, + unreadMarkerTime, + ]); + prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + + const initialScrollKey = useMemo(() => { + return linkedReportActionID ?? unreadMarkerReportActionID ?? undefined; + }, [linkedReportActionID, unreadMarkerReportActionID]); + const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); @@ -233,34 +350,8 @@ function ReportActionsList({ const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); const topReportAction = renderedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); + const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !initialScrollKey); const scrollEndTimerRef = useRef | undefined>(undefined); - const isAnonymousUser = useIsAnonymousUser(); - - useEffect(() => { - const unsubscribe = Visibility.onVisibilityChange(() => { - setIsVisible(Visibility.isVisible()); - }); - - return unsubscribe; - }, []); - - const readActionSkipped = useRef(false); - const hasHeaderRendered = useRef(false); - - const lastAction = sortedVisibleReportActions.at(0); - const [shouldMaintainVisibleContentPosition, setShouldMaintainVisibleContentPosition] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( - () => - sortedVisibleReportActions.reduce((actions, action) => { - Object.assign(actions, {[action.reportActionID]: action}); - return actions; - }, {}), - [sortedVisibleReportActions], - ); - const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - - const reportLastReadTime = report.lastReadTime ?? ''; /** * The timestamp for the unread marker. @@ -270,7 +361,6 @@ function ReportActionsList({ * - marks a message as read/unread * - reads a new message as it is received */ - const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); useEffect(() => { setUnreadMarkerTime(reportLastReadTime); @@ -296,42 +386,6 @@ function ReportActionsList({ clearDraft(); }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); - const prevUnreadMarkerReportActionID = useRef(null); - - /** - * The index of the earliest message that was received while offline - */ - const earliestReceivedOfflineMessageIndex = useMemo(() => { - // Create a list of (sorted) indices of message that were received while offline - const receivedOfflineMessages = sortedReportActions.reduce((acc, message, index) => { - if (wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { - acc[index] = index; - } - - return acc; - }, []); - - // The last index in the list is the earliest message that was received while offline - return receivedOfflineMessages.at(-1); - }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); - - /** - * The reportActionID the unread marker should display above - */ - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollOffsetRef.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, - isOffline, - isReversed: false, - isAnonymousUser, - }); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; - /** * Subscribe to read/unread events and update our unreadMarkerTime */ @@ -377,14 +431,12 @@ function ReportActionsList({ const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); - const hasNewestReportActionRef = useRef(hasNewestReportAction); - hasNewestReportActionRef.current = hasNewestReportAction; - const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollOffsetRef, readActionSkippedRef: readActionSkipped, + hasNewerActions, unreadMarkerReportActionIndex, isInverted: true, onTrackScrolling: (event: NativeSyntheticEvent) => { @@ -404,6 +456,27 @@ function ReportActionsList({ hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, }); + const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ + reportID: report.reportID, + introSelected, + betas, + isOffline, + reportScrollManager, + setIsFloatingMessageCounterVisible, + setActionIdToHighlight, + unreadMarkerReportActionID, + hasNewerActions, + linkedReportActionID, + hasNewestReportAction, + sortedVisibleReportActions, + sortedAllReportActionsForPagination, + reportActionPages, + setTreatAsNoPaginationAnchor, + treatAsNoPaginationAnchor, + prevIsLoadingInitialReportActions, + reportLoadingState, + }); + useEffect(() => () => clearTimeout(scrollEndTimerRef.current), []); useScrollToEndOnNewMessageReceived({ @@ -414,7 +487,8 @@ function ReportActionsList({ hasNewestReportAction, setIsFloatingMessageCounterVisible, scrollToEnd: reportScrollManager.scrollToBottom, - resetKey: linkedReportActionID, + // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. + resetKey: `${report.reportID}:${linkedReportActionID}`, }); useEffect(() => { @@ -422,7 +496,7 @@ function ReportActionsList({ return; } - if (scrollOffsetRef.current >= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportActionRef.current) { + if (scrollOffsetRef.current >= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { return; } @@ -430,7 +504,7 @@ function ReportActionsList({ requestAnimationFrame(() => { reportScrollManager.scrollToBottom(); }); - }, [draftAutoScrollKey, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); + }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); useEffect(() => { const shouldTriggerScroll = shouldFocusToTopOnMount && prevHasCreatedActionAdded && !hasCreatedActionAdded; @@ -445,26 +519,57 @@ function ReportActionsList({ prevReportID = report.reportID; }, [report.reportID]); + // Same-screen report switches reuse this instance; per-report one-shot flags must not leak across reports. + useEffect(() => { + hasHeaderRendered.current = false; + }, [report.reportID]); + + const isReportUnread = useMemo( + () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), + [report, transactionThreadReport, isReportArchived, lastAction], + ); + + // Mark the report as read when the user initially opens the report and there are unread messages + const didMarkReportAsReadInitially = useRef(false); + + useEffect(() => { + didMarkReportAsReadInitially.current = false; + }, [report.reportID]); + + useEffect(() => { + if (!isReportUnread || didMarkReportAsReadInitially.current) { + didMarkReportAsReadInitially.current = true; + return; + } + + didMarkReportAsReadInitially.current = true; + readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + }, [isReportUnread, report.reportID, reportLoadingState?.hasOnceLoadedReportActions]); + const handleReportChangeMarkAsRead = useCallback(() => { if (report.reportID !== prevReportID) { return; } - if (isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions))) { - // On desktop, when the notification center is displayed, isVisible will return false. - // Currently, there's no programmatic way to dismiss the notification center panel. - // To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification. - const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; - if ((isVisible || isFromNotification) && scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - return true; + const isLastActionUnread = lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); + if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { + return; + } + // On desktop, when the notification center is displayed, isVisible will return false. + // Currently, there's no programmatic way to dismiss the notification center panel. + // To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification. + const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; + const isScrolledToEnd = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { + readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); } - - readActionSkipped.current = true; + return true; } + + readActionSkipped.current = true; // eslint-disable-next-line react-hooks/exhaustive-deps }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, reportLoadingState?.hasOnceLoadedReportActions]); @@ -527,7 +632,7 @@ function ReportActionsList({ }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); useEffect(() => { - if (linkedReportActionID) { + if (initialScrollKey) { return; } @@ -555,51 +660,6 @@ function ReportActionsList({ } }, [lastAction?.reportActionID, lastAction?.actionName, prevSortedVisibleReportActionsObjects, reportScrollManager]); - useEffect(() => { - sortedVisibleReportActionsRef.current = sortedVisibleReportActions; - }, [sortedVisibleReportActions]); - - const scrollToBottomForCurrentUserAction = useCallback( - (isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { - InteractionManager.runAfterInteractions(() => { - // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where - // they are now in the list. - if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { - return; - } - if (!hasNewestReportActionRef.current && !isFromCurrentUser) { - if (Navigation.getReportRHPActiveRoute()) { - return; - } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); - }); - return; - } - const index = sortedVisibleReportActionsRef.current.findIndex((item) => keyExtractor(item) === action?.reportActionID); - if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { - setTimeout(() => { - reportScrollManager.scrollToIndex(index); - }, 100); - } else { - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - } - if (action?.reportActionID) { - setActionIdToHighlight(action.reportActionID); - } - } else { - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - } - - setIsScrollToBottomEnabled(true); - }); - }, - [report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], - ); - // Clear the highlighted report action after scrolling and highlighting useEffect(() => { if (actionIdToHighlight === '') { @@ -612,37 +672,6 @@ function ReportActionsList({ return () => clearTimeout(timer); }, [actionIdToHighlight]); - useEffect(() => { - // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? - // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, - // meaning that the cleanup might not get called. When we then open a report we had open already previously, a new - // ReportScreen will get created. Thus, we have to cancel the earlier subscription of the previous screen, - // because the two subscriptions could conflict! - // In case we return to the previous screen (e.g. by web back navigation) the useEffect for that screen would - // fire again, as the focus has changed and will set up the subscription correctly again. - const previousSubUnsubscribe = newActionUnsubscribeMap[report.reportID]; - if (previousSubUnsubscribe) { - previousSubUnsubscribe(); - } - - // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain - // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. - const unsubscribe = subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); - - const cleanup = () => { - if (!unsubscribe) { - return; - } - unsubscribe(); - }; - - newActionUnsubscribeMap[report.reportID] = cleanup; - - return cleanup; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.reportID]); - const reportActionsListFSClass = FS.getChatFSClass(report); const lastIOUActionWithError = sortedVisibleReportActions.find((action) => action.errors); const prevLastIOUActionWithError = usePrevious(lastIOUActionWithError); @@ -677,13 +706,13 @@ function ReportActionsList({ * 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 initialNumToRender = useMemo((): number => { const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); return getReportActionsListInitialNumToRender({ numToRender, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, sortedVisibleReportActionsLength: renderedVisibleReportActions.length, @@ -694,7 +723,7 @@ function ReportActionsList({ styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, renderedVisibleReportActions.length, @@ -828,6 +857,7 @@ function ReportActionsList({ if (isScrollToBottomEnabled) { reportScrollManager.scrollToBottom(); setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); } if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { requestAnimationFrame(() => { @@ -835,7 +865,16 @@ function ReportActionsList({ }); } }, - [isOffline, isScrollToBottomEnabled, onLayout, reportScrollManager, hasCreatedActionAdded, shouldScrollToEndAfterLayout], + [ + isOffline, + isScrollToBottomEnabled, + onLayout, + reportScrollManager, + hasCreatedActionAdded, + shouldScrollToEndAfterLayout, + completeLiveTailPruneAfterScrollToBottom, + setIsScrollToBottomEnabled, + ], ); const retryLoadNewerChatsError = useCallback(() => { @@ -888,7 +927,7 @@ function ReportActionsList({ ); }, [actionIndexMap, hideComposer, initialNumToRender, renderItem, shouldShowReportRecipientLocalTime, renderedVisibleReportActions, styles]); - const onStartReached = useCallback(() => { + const handleStartReached = useCallback(() => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); return; @@ -931,7 +970,7 @@ function ReportActionsList({ showsVerticalScrollIndicator={!shouldScrollToEndAfterLayout} onEndReached={onEndReached} onEndReachedThreshold={0.75} - onStartReached={onStartReached} + onStartReached={handleStartReached} onStartReachedThreshold={0.75} ListHeaderComponent={listHeaderComponent} ListFooterComponent={listFooterComponent} @@ -944,7 +983,7 @@ function ReportActionsList({ overrideProps={{isInvertedVirtualizedList: true}} getItemType={(item) => item.actionName} shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} - initialScrollKey={linkedReportActionID} + initialScrollKey={initialScrollKey} onContentSizeChange={() => { trackVerticalScrolling(undefined); }} diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 0d9005acf714..734643b9b5fb 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSidePanelReportActions from '@hooks/useConciergeSidePanelReportActions'; @@ -13,46 +13,39 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; -import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useSidePanelState from '@hooks/useSidePanelState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; import {updateLoadingInitialReportAction} from '@libs/actions/Report'; -import DateUtils from '@libs/DateUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; import { getCombinedReportActions, getFilteredReportActionsForReportView, getOneTransactionThreadReportID, - getOriginalMessage, getSortedReportActionsForDisplay, isCreatedAction, isDeletedParentAction, isIOUActionMatchingTransactionList, - isMoneyRequestAction, isReportActionVisible, } from '@libs/ReportActionsUtils'; import { - buildOptimisticCreatedReportAction, - buildOptimisticIOUReportAction, canUserPerformWriteAction, isConciergeChatReport, isInvoiceReport, isMoneyRequestReport, isReportTransactionThread as isReportTransactionThreadUtil, + isUnread, } from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; +import type ReportScreenNavigationProps from '@pages/inbox/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import getReportActionsToDisplay from './getReportActionsToDisplay'; import ReportActionsList from './ReportActionsList'; import UserTypingEventListener from './UserTypingEventListener'; @@ -64,21 +57,32 @@ type ReportActionsViewProps = { onLayout?: (event: LayoutChangeEvent) => void; }; -let listOldID = Math.round(Math.random() * 100); - function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { + const route = useRoute(); + const reportActionIDFromRoute = route?.params?.reportActionID; + useCopySelectionHelper(); const {translate} = useLocalize(); usePendingConciergeResponse(reportID); - const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const reportActionID = route?.params?.reportActionID; - const {reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionID); - const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); + const [treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor] = useState(false); + const nonEmptyReportIDForPages = getNonEmptyStringOnyxID(reportID); + const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyReportIDForPages}`); + const { + reportActions: unfilteredReportActions, + hasNewerActions, + hasOlderActions, + sortedAllReportActions, + oldestUnreadReportAction, + } = usePaginatedReportActions(reportID, reportActionIDFromRoute, { + shouldLinkToOldestUnreadReportAction: true, + treatAsNoPaginationAnchor, + }); + const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const parentReportAction = useParentReportAction(report); @@ -92,60 +96,42 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const {sessionStartTime} = useSidePanelState(); - const hasUserSentMessage = useMemo(() => { - if (!isConciergeSidePanel || !sessionStartTime) { - return false; - } - return allReportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); - }, [isConciergeSidePanel, allReportActions, currentUserAccountID, sessionStartTime]); + let hasUserSentMessage = false; + if (isConciergeSidePanel && sessionStartTime) { + hasUserSentMessage = allReportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); + } const isReportTransactionThread = isReportTransactionThreadUtil(report); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactionsForThreadID = useMemo( - () => getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true), - [allReportTransactions, allReportActions, isOffline], - ); - const visibleTransactionsForThreadID = useMemo( - () => reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [reportTransactionsForThreadID, isOffline], - ); - const reportTransactionIDsForThread = useMemo(() => visibleTransactionsForThreadID?.map((t) => t.transactionID), [visibleTransactionsForThreadID]); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread), - [report, chatReport, allReportActions, isOffline, reportTransactionIDsForThread], - ); + const reportTransactionsForThreadID = getAllNonDeletedTransactions(allReportTransactions, allReportActions, isOffline, true); + const visibleTransactionsForThreadID = reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDsForThread = visibleTransactionsForThreadID?.map((t) => t.transactionID); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread); const isReportArchived = useReportIsArchived(reportID); - const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); - - const getTransactionThreadReportActions = useCallback( - (reportActions: OnyxTypes.ReportActions | undefined): OnyxTypes.ReportAction[] => { - return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); - }, - [canPerformWriteAction, transactionThreadReportID], - ); + const canPerformWriteAction = !!canUserPerformWriteAction(report, isReportArchived); const [transactionThreadReportActions] = useOnyx( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { - selector: getTransactionThreadReportActions, + selector: (reportActions) => getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined), }, - [getTransactionThreadReportActions], + [canPerformWriteAction, transactionThreadReportID], ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const prevReportActionID = usePrevious(reportActionID); - const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); + const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); const didLayout = useRef(false); + useEffect(() => { + didLayout.current = false; + }, [reportID]); + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(reportID); - const reportTransactionIDs = useMemo( - () => getAllNonDeletedTransactions(reportTransactions, allReportActions ?? []).map((transaction) => transaction.transactionID), - [reportTransactions, allReportActions], - ); + const reportTransactionIDs = getAllNonDeletedTransactions(reportTransactions, allReportActions).map((transaction) => transaction.transactionID); const lastAction = allReportActions?.at(-1); const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); @@ -154,125 +140,55 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists - if (!reportActionID || !isOffline) { + if (!reportActionIDFromRoute || !isOffline) { return; } updateLoadingInitialReportAction(report?.reportID ?? reportID); - }, [isOffline, report?.reportID, reportID, reportActionID]); + }, [isOffline, report?.reportID, reportID, reportActionIDFromRoute]); - // Change the list ID only for comment linking to get the positioning right - const listID = useMemo(() => { - if (!reportActionID && !prevReportActionID) { - // Keep the old list ID since we're not in the Comment Linking flow - return listOldID; - } - const newID = generateNewRandomInt(listOldID, 1, Number.MAX_SAFE_INTEGER); - listOldID = newID; - - return newID; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route, reportActionID]); + // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. + const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses // to display at least one expense action to match the total data. - const reportActionsToDisplay = useMemo(() => { - const actions = [...(allReportActions ?? [])]; - - if (shouldAddCreatedAction) { - const createdTime = lastAction?.created && DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1); - const optimisticCreatedAction = buildOptimisticCreatedReportAction({ - emailCreatingAction: String(report?.ownerAccountID), - created: createdTime, - }); - optimisticCreatedAction.pendingAction = null; - actions.push(optimisticCreatedAction); - } - - if (!isMoneyRequestReport(report) || !allReportActions?.length) { - return actions; - } - - const moneyRequestActions = allReportActions.filter((action) => { - const originalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; - return ( - isMoneyRequestAction(action) && - originalMessage && - (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || - !!(originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails) || - originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) - ); - }); - - if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(transactionThreadReport)) { - const optimisticIOUAction = buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: 0, - currency: CONST.CURRENCY.USD, - comment: '', - participants: [], - transactionID: rand64(), - iouReportID: report?.reportID, - created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), - }) as OnyxTypes.ReportAction; - moneyRequestActions.push(optimisticIOUAction); - actions.splice(actions.length - 1, 0, optimisticIOUAction); - } - - // Update pending action of created action if we have some requests that are pending - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const createdAction = actions.pop()!; - if (moneyRequestActions.filter((action) => !!action.pendingAction).length > 0) { - createdAction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; - } - - return [...actions, createdAction]; - }, [allReportActions, shouldAddCreatedAction, report, reportPreviewAction?.childMoneyRequestCount, transactionThreadReport, lastAction?.created]); + const reportActionsToDisplay = getReportActionsToDisplay(allReportActions, lastAction, report, reportPreviewAction, transactionThreadReport, shouldAddCreatedAction); // Get a sorted array of reportActions for both the current report and the transaction thread report associated with this report (if there is one) // so that we display transaction-level and report-level report actions in order in the one-transaction view - const reportActions = useMemo( - () => (reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, transactionThreadReportID ?? null, transactionThreadReportActions ?? []) : []), - [reportActionsToDisplay, transactionThreadReportActions, transactionThreadReportID], - ); + const reportActions = reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, transactionThreadReportID ?? null, transactionThreadReportActions ?? []) : []; - const parentReportActionForTransactionThread = useMemo( - () => (isEmptyObject(transactionThreadReportActions) ? undefined : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID)), - [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID], - ); + const parentReportActionForTransactionThread = isEmptyObject(transactionThreadReportActions) + ? undefined + : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID); - const visibleReportActions = useMemo( - () => - reportActions.filter((reportAction) => { - const passesOfflineCheck = - isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; + const visibleReportActions = reportActions.filter((reportAction) => { + const passesOfflineCheck = isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; - if (!passesOfflineCheck) { - return false; - } + if (!passesOfflineCheck) { + return false; + } - const actionReportID = reportAction.reportID ?? reportID; - if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { - return false; - } + const actionReportID = reportAction.reportID ?? reportID; + if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { + return false; + } - if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { - return false; - } + if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { + return false; + } - return true; - }), - [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], - ); + return true; + }); const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; const isMissingTransactionThreadReportID = !transactionThreadReport?.reportID; const isReportDataIncomplete = isSingleExpenseReport && isMissingTransactionThreadReportID; const isMissingReportActions = visibleReportActions.length === 0; - const allReportActionIDs = useMemo(() => allReportActions?.map((action) => action.reportActionID) ?? [], [allReportActions]); + const allReportActionIDs = allReportActions?.map((action) => action.reportActionID) ?? []; const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, @@ -320,7 +236,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { }; // Show skeleton while loading initial report actions when data is incomplete/missing and online - const shouldShowSkeletonForInitialLoad = isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; + const shouldShowSkeletonForInitialLoad = !!isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; // Show skeleton while the app is loading and we're online const shouldShowSkeletonForAppLoad = isLoadingApp && !isOffline; @@ -331,7 +247,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // onboarding messages. The skeleton avoids flashing wrong content. const shouldShowSkeletonForConciergePanel = isConciergeSidePanel && !hasOnceLoadedReportActions && !isOffline; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldShowSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad; useEffect(() => { @@ -341,11 +256,22 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { markOpenReportEnd(report, {warm: false}); }, [report, shouldShowSkeleton]); - if (isLoadingOnyxValue(reportResult) || !report) { - return ; - } + const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); + + // When opening an unread report, it is very likely that the message we will open to is not the latest, + // which is the only one we will have in cache. + const isInitiallyLoadingReport = isReportUnread && !!reportLoadingState?.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + + // Same for unread messages, we need to wait for the results from the OpenReport API call + // if the oldest unread report action is not available yet. Only applies during the *first* load + // for this report: after `hasOnceLoadedReportActions` is set, a later "mark as unread" must not + // bring back this loading gate (we are not re-opening the report from a cold cache). + const isUnreadMessagePageLoadingInitially = !reportActionIDFromRoute && isReportUnread && !oldestUnreadReportAction && !hasOnceLoadedReportActions; + + // Once all the above conditions are met, we can consider the report ready. + const isReportReady = !isInitiallyLoadingReport && !isUnreadMessagePageLoadingInitially; - if (shouldShowSkeleton) { + if (isLoadingOnyxValue(reportResult) || !report || !isReportReady || shouldShowSkeleton) { return ; } @@ -366,6 +292,12 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { sortedVisibleReportActions={conciergeSidePanelFilteredVisibleActions} loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} + hasNewerActions={hasNewerActions} + oldestUnreadReportAction={oldestUnreadReportAction} + sortedAllReportActionsForPagination={sortedAllReportActions ?? []} + reportActionPages={reportActionPages} + treatAsNoPaginationAnchor={treatAsNoPaginationAnchor} + setTreatAsNoPaginationAnchor={setTreatAsNoPaginationAnchor} listID={listID} hasCreatedActionAdded={shouldAddCreatedAction} showHiddenHistory={!showFullHistory} diff --git a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts b/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts index a204ddb14faa..af5901a359ee 100644 --- a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts +++ b/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts @@ -1,6 +1,6 @@ type GetReportActionsListInitialNumToRenderParams = { numToRender: number; - linkedReportActionID?: string; + initialScrollKey?: string; shouldScrollToEndAfterLayout: boolean; hasCreatedActionAdded?: boolean; sortedVisibleReportActionsLength: number; @@ -10,20 +10,20 @@ type GetReportActionsListInitialNumToRenderParams = { export default function getReportActionsListInitialNumToRender({ numToRender, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, sortedVisibleReportActionsLength, isOffline, getInitialNumToRender, -}: GetReportActionsListInitialNumToRenderParams): number | undefined { +}: GetReportActionsListInitialNumToRenderParams): number { if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { return sortedVisibleReportActionsLength; } - if (linkedReportActionID) { + if (initialScrollKey) { return getInitialNumToRender(numToRender); } - return numToRender || undefined; + return numToRender; } diff --git a/src/pages/inbox/report/getReportActionsToDisplay.ts b/src/pages/inbox/report/getReportActionsToDisplay.ts new file mode 100644 index 000000000000..ab833cfa2934 --- /dev/null +++ b/src/pages/inbox/report/getReportActionsToDisplay.ts @@ -0,0 +1,70 @@ +import type {OnyxInputValue} from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; +import {rand64} from '@libs/NumberUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {buildOptimisticCreatedReportAction, buildOptimisticIOUReportAction, isMoneyRequestReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +function getReportActionsToDisplay( + allReportActions: OnyxTypes.ReportAction[], + lastAction: OnyxTypes.ReportAction | undefined, + report: OnyxTypes.Report | undefined, + reportPreviewAction: OnyxInputValue> | undefined, + transactionThreadReport: OnyxTypes.Report | undefined, + shouldAddCreatedAction: boolean, +) { + const actions = [...(allReportActions ?? [])]; + + if (shouldAddCreatedAction) { + const createdTime = lastAction?.created && DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1); + const optimisticCreatedAction = buildOptimisticCreatedReportAction({ + emailCreatingAction: String(report?.ownerAccountID), + created: createdTime, + }); + optimisticCreatedAction.pendingAction = null; + actions.push(optimisticCreatedAction); + } + + if (!isMoneyRequestReport(report) || !allReportActions?.length) { + return actions; + } + + const moneyRequestActions = allReportActions.filter((action) => { + const originalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; + return ( + isMoneyRequestAction(action) && + originalMessage && + (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + !!(originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails) || + originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) + ); + }); + + if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(transactionThreadReport)) { + const optimisticIOUAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 0, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [], + transactionID: rand64(), + iouReportID: report?.reportID, + created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), + }) as OnyxTypes.ReportAction; + moneyRequestActions.push(optimisticIOUAction); + actions.splice(actions.length - 1, 0, optimisticIOUAction); + } + + // Update pending action of created action if we have some requests that are pending + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const createdAction = actions.pop()!; + if (moneyRequestActions.filter((action) => !!action.pendingAction).length > 0) { + createdAction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + } + + return [...actions, createdAction]; +} + +export default getReportActionsToDisplay; diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts new file mode 100644 index 000000000000..b684c66485e5 --- /dev/null +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -0,0 +1,211 @@ +import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +// eslint-disable-next-line no-restricted-imports +import {InteractionManager} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type useReportScrollManager from '@hooks/useReportScrollManager'; +import type {OpenReportActionParams} from '@libs/actions/Report'; +import {openReport, pruneReportActionPagesToNewestWindow, subscribeToNewActionEvent} from '@libs/actions/Report'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; + +// In the component we are subscribing to the arrival of new actions. +// As there is the possibility that there are multiple instances of a ReportScreen +// for the same report, we only ever want one subscription to be active, as +// the subscriptions could otherwise be conflicting. +const newActionUnsubscribeMap: Record void> = {}; + +type ReportScrollManager = ReturnType; + +type RAMOnlyReportLoadingState = OnyxEntry<{ + isLoadingInitialReportActions?: boolean; +}>; + +type UseReportActionsNewActionLiveTailParams = { + reportID: string; + introSelected: OpenReportActionParams['introSelected']; + betas: OpenReportActionParams['betas']; + isOffline: boolean; + reportScrollManager: ReportScrollManager; + setIsFloatingMessageCounterVisible: (visible: boolean) => void; + setActionIdToHighlight: (actionID: string) => void; + unreadMarkerReportActionID: string | null; + hasNewerActions: boolean; + linkedReportActionID: string | undefined; + hasNewestReportAction: boolean; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; + reportActionPages: OnyxTypes.Pages | undefined; + setTreatAsNoPaginationAnchor: (value: boolean) => void; + treatAsNoPaginationAnchor: boolean; + prevIsLoadingInitialReportActions: boolean | undefined; + reportLoadingState: RAMOnlyReportLoadingState; +}; + +type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune'; + +/** + * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the + * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke + * it from list `onLayout` outside this hook. + */ +function useReportActionsNewActionLiveTail({ + reportID, + introSelected, + betas, + isOffline, + reportScrollManager, + setIsFloatingMessageCounterVisible, + setActionIdToHighlight, + unreadMarkerReportActionID, + hasNewerActions, + linkedReportActionID, + hasNewestReportAction, + sortedVisibleReportActions, + sortedAllReportActionsForPagination, + reportActionPages, + setTreatAsNoPaginationAnchor, + treatAsNoPaginationAnchor, + prevIsLoadingInitialReportActions, + reportLoadingState, +}: UseReportActionsNewActionLiveTailParams) { + const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); + const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + + const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { + InteractionManager.runAfterInteractions(() => { + // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where + // they are now in the list. + if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { + return; + } + if (!hasNewestReportAction && !isFromCurrentUser) { + if (Navigation.getReportRHPActiveRoute()) { + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + }); + return; + } + + const shouldJumpToLiveTail = + !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActions || !!linkedReportActionID || unreadMarkerReportActionID); + + if (shouldJumpToLiveTail) { + if (liveTailJumpRef.current.stage === 'idle') { + liveTailJumpRef.current = {stage: 'open_report'}; + openReport({ + reportID, + introSelected, + betas, + }); + } + return; + } + + const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + if (index > 0) { + setTimeout(() => { + reportScrollManager.scrollToIndex(index); + }, 100); + } else { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + } + if (action?.reportActionID) { + setActionIdToHighlight(action.reportActionID); + } + } else { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + } + + setIsScrollToBottomEnabled(true); + }); + }); + + const completeLiveTailPruneAfterScrollToBottom = useCallback(() => { + if (liveTailJumpRef.current.stage !== 'await_prune') { + return; + } + pruneReportActionPagesToNewestWindow(reportID, sortedAllReportActionsForPagination, reportActionPages); + setTreatAsNoPaginationAnchor(false); + liveTailJumpRef.current = {stage: 'idle'}; + }, [reportID, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor]); + + useEffect(() => { + liveTailJumpRef.current = {stage: 'idle'}; + }, [reportID]); + + useEffect(() => { + if (liveTailJumpRef.current.stage !== 'open_report') { + return; + } + + const finishedInitialLoad = prevIsLoadingInitialReportActions === true && reportLoadingState?.isLoadingInitialReportActions === false; + + if (!finishedInitialLoad) { + return; + } + + setTreatAsNoPaginationAnchor(true); + Navigation.setParams({reportActionID: ''}); + liveTailJumpRef.current = {stage: 'await_scroll'}; + }, [prevIsLoadingInitialReportActions, reportLoadingState?.isLoadingInitialReportActions, setTreatAsNoPaginationAnchor]); + + useEffect(() => { + if (liveTailJumpRef.current.stage !== 'await_scroll') { + return; + } + if (!hasNewestReportAction) { + return; + } + + liveTailJumpRef.current = {stage: 'await_prune'}; + setIsFloatingMessageCounterVisible(false); + // Defer so this effect does not synchronously chain a second render from setState (eslint react-hooks/set-state-in-effect). + queueMicrotask(() => { + setIsScrollToBottomEnabled(true); + }); + }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible]); + + useEffect(() => { + // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? + // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, + // meaning that the cleanup might not get called. When we then open a report we had open already previously, a new + // ReportScreen will get created. Thus, we have to cancel the earlier subscription of the previous screen, + // because the two subscriptions could conflict! + // In case we return to the previous screen (e.g. by web back navigation) the useEffect for that screen would + // fire again, as the focus has changed and will set up the subscription correctly again. + const previousSubUnsubscribe = newActionUnsubscribeMap[reportID]; + if (previousSubUnsubscribe) { + previousSubUnsubscribe(); + } + + const unsubscribe = subscribeToNewActionEvent(reportID, scrollToBottomForCurrentUserAction); + + const cleanup = () => { + if (!unsubscribe) { + return; + } + unsubscribe(); + }; + + newActionUnsubscribeMap[reportID] = cleanup; + + return cleanup; + }, [reportID]); + + return { + isScrollToBottomEnabled, + setIsScrollToBottomEnabled, + completeLiveTailPruneAfterScrollToBottom, + }; +} + +export default useReportActionsNewActionLiveTail; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 5d9948649ba9..6c9e7387cf80 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -21,6 +21,9 @@ type Args = { /** The index of the unread report action */ unreadMarkerReportActionIndex: number; + /** Whether the report has newer actions to load */ + hasNewerActions: boolean; + /** Callback to call on every scroll event */ onTrackScrolling: (event: NativeSyntheticEvent) => void; @@ -31,6 +34,7 @@ type Args = { export default function useReportUnreadMessageScrollTracking({ reportID, currentVerticalScrollingOffsetRef, + hasNewerActions, readActionSkippedRef, onTrackScrolling, unreadMarkerReportActionIndex, @@ -85,7 +89,8 @@ export default function useReportUnreadMessageScrollTracking({ if ( currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible && - !hasUnreadMarkerReportAction + !hasUnreadMarkerReportAction && + !hasNewerActions ) { setIsFloatingMessageCounterVisible(false); } diff --git a/src/pages/inbox/types.ts b/src/pages/inbox/types.ts new file mode 100644 index 000000000000..780859c95a13 --- /dev/null +++ b/src/pages/inbox/types.ts @@ -0,0 +1,9 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; +import type SCREENS from '@src/SCREENS'; + +type ReportScreenNavigationProps = + | PlatformStackScreenProps + | PlatformStackScreenProps; + +export default ReportScreenNavigationProps; diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 276b0c3446b3..4e9118e1c201 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -22,6 +22,8 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +const REPORT_ACTIONS_LIST_ID = 'perf-test-list'; + type LazyLoadLHNTestUtils = { fakePersonalDetails: PersonalDetailsList; }; @@ -116,9 +118,14 @@ function ReportActionsListWrapper() { report={report} onLayout={mockOnLayout} onScroll={mockOnScroll} - listID={1} + listID={REPORT_ACTIONS_LIST_ID} loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} + hasNewerActions={false} + sortedAllReportActionsForPagination={reportActions} + reportActionPages={undefined} + treatAsNoPaginationAnchor={false} + setTreatAsNoPaginationAnchor={() => {}} transactionThreadReport={report} /> diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 76d5276ab5b3..3ded325d1b2c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -4,9 +4,9 @@ import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing import {addSeconds, format, subMinutes} from 'date-fns'; import React from 'react'; import Onyx from 'react-native-onyx'; +import {setSidebarLoaded} from '@libs/actions/App'; +import {subscribeToUserEvents} from '@libs/actions/User'; import {waitForIdle} from '@libs/Network/SequentialQueue'; -import {setSidebarLoaded} from '@userActions/App'; -import {subscribeToUserEvents} from '@userActions/User'; import App from '@src/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -237,6 +237,7 @@ async function signInAndGetApp(): Promise { reportID: REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', + lastReadTime: format(new Date(), CONST.DATE.FNS_DB_FORMAT_STRING), participants: { [USER_B_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, @@ -253,6 +254,7 @@ async function signInAndGetApp(): Promise { reportID: COMMENT_LINKING_REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', + lastReadTime: format(new Date(), CONST.DATE.FNS_DB_FORMAT_STRING), participants: {[USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, lastActorAccountID: USER_A_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, @@ -381,6 +383,9 @@ describe('Pagination', () => { mockGetNewerActions(0); // There is 1 extra call here because of the comment linking report. + + // Simulate the backend returning no new messages to simulate reaching the start of the chat. + mockGetNewerActions(0); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); @@ -392,13 +397,13 @@ describe('Pagination', () => { scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. + expect(getReportActions()).toHaveLength(10); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. - expect(getReportActions()).toHaveLength(10); - scrollToOffset(500); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); @@ -409,7 +414,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - // We still have 15 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. + // We still have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); }); }); diff --git a/tests/unit/PaginationUtilsTest.ts b/tests/unit/PaginationUtilsTest.ts index 28e754ce5ff7..b67e307bbe58 100644 --- a/tests/unit/PaginationUtilsTest.ts +++ b/tests/unit/PaginationUtilsTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import type {Pages} from '@src/types/onyx'; -import PaginationUtils from '../../src/libs/PaginationUtils'; +import PaginationUtils, {selectNewestPageWithIndex} from '../../src/libs/PaginationUtils'; type Item = { id: string; @@ -604,4 +604,142 @@ describe('PaginationUtils', () => { expect(result).toStrictEqual(expectedResult); }); }); + + describe('mergePagesByIDOverlap', () => { + it('merges pages that share a non-marker id (overlap) without requiring index adjacency in the same way as mergeAndSort', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['4', '3', '2'], + ['3', '2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['4', '3', '2', '1']]); + }); + + it('merges when the first id of a page matches the last id of the previous page (chain)', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['5', '4', '3'], + ['3', '2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['5', '4', '3', '2', '1']]); + }); + + it('does not merge disjoint windows with no shared ids (middle-of-chat / sparse local set)', () => { + const sortedItems = createItems(['5', '4', '2', '1']); + const pages: Pages = [ + ['5', '4'], + ['2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([ + ['5', '4'], + ['2', '1'], + ]); + }); + + it('returns an empty array when input pages is empty', () => { + expect(PaginationUtils.mergePagesByIDOverlap(createItems(['1']), [], getID)).toStrictEqual([]); + }); + + it('strips ids not present in sortedItems from stored pages', () => { + const sortedItems = createItems(['4', '3']); + const pages: Pages = [['6', '5', '4', '3', '2', '1']]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['4', '3']]); + }); + + it('applies start/end markers when merging', () => { + const sortedItems = createItems(['1', '2', '3']); + const pages: Pages = [ + [CONST.PAGINATION_START_ID, '1', '2', '3'], + ['2', '3', CONST.PAGINATION_END_ID], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([[CONST.PAGINATION_START_ID, '1', '2', '3', CONST.PAGINATION_END_ID]]); + }); + }); + + describe('selectNewestPageWithIndex', () => { + it('returns undefined for an empty list', () => { + expect(selectNewestPageWithIndex([])).toBeUndefined(); + }); + + it('returns the only page when there is a single page', () => { + const only = { + ids: ['3', '2', '1'], + firstID: '3', + firstIndex: 0, + lastID: '1', + lastIndex: 2, + }; + expect(selectNewestPageWithIndex([only])).toBe(only); + }); + + it('prefers the page whose firstID is the pagination start marker', () => { + const withStart = { + ids: [CONST.PAGINATION_START_ID, '2', '1'], + firstID: CONST.PAGINATION_START_ID, + firstIndex: 2, + lastID: '1', + lastIndex: 4, + }; + const newerByIndex = { + ids: ['5', '4'], + firstID: '5', + firstIndex: 0, + lastID: '4', + lastIndex: 1, + }; + expect(selectNewestPageWithIndex([newerByIndex, withStart])).toBe(withStart); + expect(selectNewestPageWithIndex([withStart, newerByIndex])).toBe(withStart); + }); + + it('when no start marker, picks the page with the smallest firstIndex (chronologically newest in descending-sorted data)', () => { + const olderWindow = { + ids: ['2', '1'], + firstID: '2', + firstIndex: 3, + lastID: '1', + lastIndex: 4, + }; + const newerWindow = { + ids: ['5', '4'], + firstID: '5', + firstIndex: 0, + lastID: '4', + lastIndex: 1, + }; + expect(selectNewestPageWithIndex([olderWindow, newerWindow])).toBe(newerWindow); + }); + }); + + describe('prunePagesToNewestWindow', () => { + it('returns pages unchanged when there is at most one page', () => { + const sortedItems = createItems(['1', '2', '3']); + expect(PaginationUtils.prunePagesToNewestWindow(sortedItems, [], getID)).toStrictEqual([]); + expect(PaginationUtils.prunePagesToNewestWindow(sortedItems, [['1', '2']], getID)).toStrictEqual([['1', '2']]); + }); + + it('collapses to the newest window by firstIndex when no start marker is present', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['2', '1'], + ['5', '4'], + ]; + const result = PaginationUtils.prunePagesToNewestWindow(sortedItems, pages, getID); + expect(result).toStrictEqual([['5', '4']]); + }); + + it('keeps the page that includes the start marker (ids are expanded the same way as in getPagesWithIndexes)', () => { + const sortedItems = createItems(['3', '2', '1']); + const pages: Pages = [ + ['3', '2'], + [CONST.PAGINATION_START_ID, '1'], + ]; + const result = PaginationUtils.prunePagesToNewestWindow(sortedItems, pages, getID); + expect(result).toStrictEqual([[CONST.PAGINATION_START_ID, '3', '2', '1']]); + }); + }); }); diff --git a/tests/unit/getReportActionsListInitialNumToRenderTest.ts b/tests/unit/getReportActionsListInitialNumToRenderTest.ts index 02afc164272c..95be72e1c8eb 100644 --- a/tests/unit/getReportActionsListInitialNumToRenderTest.ts +++ b/tests/unit/getReportActionsListInitialNumToRenderTest.ts @@ -18,7 +18,7 @@ describe('getReportActionsListInitialNumToRender', () => { it('returns the platform-adjusted value for linked report actions', () => { const result = getReportActionsListInitialNumToRender({ numToRender: 10, - linkedReportActionID: '123', + initialScrollKey: '123', shouldScrollToEndAfterLayout: false, hasCreatedActionAdded: true, sortedVisibleReportActionsLength: 500, diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index beda62ec194c..898362bafcbf 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -29,6 +29,27 @@ describe('useReportUnreadMessageScrollTracking', () => { describe('on init and without any scrolling', () => { const onTrackScrollingMockFn = jest.fn(); + it('returns initial floatingMessage visibility and sets no state', () => { + // Given + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: readActionRefFalse, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + hasOnceLoadedReportActions: true, + isInverted: true, + }), + ); + + // Then + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onTrackScrollingMockFn).not.toHaveBeenCalled(); + }); + it('returns floatingMessage visibility that was set to a new value', () => { // Given const offsetRef = {current: 0}; @@ -39,6 +60,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: readActionRefFalse, unreadMarkerReportActionIndex: -1, isInverted: true, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, }), @@ -71,6 +93,7 @@ describe('useReportUnreadMessageScrollTracking', () => { unreadMarkerReportActionIndex: -1, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, + hasNewerActions: false, }), ); @@ -98,6 +121,7 @@ describe('useReportUnreadMessageScrollTracking', () => { unreadMarkerReportActionIndex: 1, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, + hasNewerActions: false, }), ); @@ -128,6 +152,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: readActionRefFalse, unreadMarkerReportActionIndex: -1, isInverted: true, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, }), @@ -156,6 +181,7 @@ describe('useReportUnreadMessageScrollTracking', () => { isInverted: true, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, + hasNewerActions: false, }), ); @@ -186,6 +212,7 @@ describe('useReportUnreadMessageScrollTracking', () => { isInverted: true, onTrackScrolling: onTrackScrollingMockFn, hasOnceLoadedReportActions: true, + hasNewerActions: false, }), ); From 85eb726d2b2d6c24f957a7fcbaa7555fca8768a1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 May 2026 20:52:12 +0100 Subject: [PATCH 2/4] chore: fix `ComposerWIthSuggestions` path in `eslint.seatbelt.tsv` --- config/eslint/eslint.seatbelt.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b86de7918ede..6a8bda43afe6 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -511,9 +511,9 @@ "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/refs" 2 "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 "../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/report/ReportActionItemMessage.tsx" "@typescript-eslint/no-deprecated/getReportName" 1 From 51c6b5cbaada9ea5cbe424535920cb2250e8b9cb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 May 2026 21:11:54 +0100 Subject: [PATCH 3/4] fix: revert removal of manual memoization --- src/pages/inbox/report/ReportActionsView.tsx | 100 +++++++++++++------ 1 file changed, 67 insertions(+), 33 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 734643b9b5fb..424198bd605f 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSidePanelReportActions from '@hooks/useConciergeSidePanelReportActions'; @@ -43,6 +43,7 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type ReportScreenNavigationProps from '@pages/inbox/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import getReportActionsToDisplay from './getReportActionsToDisplay'; @@ -82,7 +83,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { shouldLinkToOldestUnreadReportAction: true, treatAsNoPaginationAnchor, }); - const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const parentReportAction = useParentReportAction(report); @@ -96,34 +97,52 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const {sessionStartTime} = useSidePanelState(); - let hasUserSentMessage = false; - if (isConciergeSidePanel && sessionStartTime) { - hasUserSentMessage = allReportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); - } + const hasUserSentMessage = useMemo(() => { + if (!isConciergeSidePanel || !sessionStartTime) { + return false; + } + return allReportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); + }, [isConciergeSidePanel, allReportActions, currentUserAccountID, sessionStartTime]); const isReportTransactionThread = isReportTransactionThreadUtil(report); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactionsForThreadID = getAllNonDeletedTransactions(allReportTransactions, allReportActions, isOffline, true); - const visibleTransactionsForThreadID = reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const reportTransactionIDsForThread = visibleTransactionsForThreadID?.map((t) => t.transactionID); - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread); + const reportTransactionsForThreadID = useMemo( + () => getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true), + [allReportTransactions, allReportActions, isOffline], + ); + const visibleTransactionsForThreadID = useMemo( + () => reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), + [reportTransactionsForThreadID, isOffline], + ); + const reportTransactionIDsForThread = useMemo(() => visibleTransactionsForThreadID?.map((t) => t.transactionID), [visibleTransactionsForThreadID]); + const transactionThreadReportID = useMemo( + () => getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread), + [report, chatReport, allReportActions, isOffline, reportTransactionIDsForThread], + ); const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = !!canUserPerformWriteAction(report, isReportArchived); + const getTransactionThreadReportActions = useCallback( + (reportActions: OnyxTypes.ReportActions | undefined): OnyxTypes.ReportAction[] => { + return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); + }, + [canPerformWriteAction, transactionThreadReportID], + ); + const [transactionThreadReportActions] = useOnyx( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { - selector: (reportActions) => getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined), + selector: getTransactionThreadReportActions, }, [canPerformWriteAction, transactionThreadReportID], ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); + const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); const didLayout = useRef(false); useEffect(() => { @@ -131,7 +150,10 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { }, [reportID]); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(reportID); - const reportTransactionIDs = getAllNonDeletedTransactions(reportTransactions, allReportActions).map((transaction) => transaction.transactionID); + const reportTransactionIDs = useMemo( + () => getAllNonDeletedTransactions(reportTransactions, allReportActions ?? []).map((transaction) => transaction.transactionID), + [reportTransactions, allReportActions], + ); const lastAction = allReportActions?.at(-1); const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); @@ -154,41 +176,53 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses // to display at least one expense action to match the total data. - const reportActionsToDisplay = getReportActionsToDisplay(allReportActions, lastAction, report, reportPreviewAction, transactionThreadReport, shouldAddCreatedAction); + const reportActionsToDisplay = useMemo( + () => getReportActionsToDisplay(allReportActions, lastAction, report, reportPreviewAction, transactionThreadReport, shouldAddCreatedAction), + [allReportActions, lastAction, report, reportPreviewAction, shouldAddCreatedAction, transactionThreadReport], + ); // Get a sorted array of reportActions for both the current report and the transaction thread report associated with this report (if there is one) // so that we display transaction-level and report-level report actions in order in the one-transaction view - const reportActions = reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, transactionThreadReportID ?? null, transactionThreadReportActions ?? []) : []; + const reportActions = useMemo( + () => (reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, transactionThreadReportID ?? null, transactionThreadReportActions ?? []) : []), + [reportActionsToDisplay, transactionThreadReportActions, transactionThreadReportID], + ); - const parentReportActionForTransactionThread = isEmptyObject(transactionThreadReportActions) - ? undefined - : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID); + const parentReportActionForTransactionThread = useMemo( + () => (isEmptyObject(transactionThreadReportActions) ? undefined : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID)), + [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID], + ); - const visibleReportActions = reportActions.filter((reportAction) => { - const passesOfflineCheck = isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; + const visibleReportActions = useMemo( + () => + reportActions.filter((reportAction) => { + const passesOfflineCheck = + isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; - if (!passesOfflineCheck) { - return false; - } + if (!passesOfflineCheck) { + return false; + } - const actionReportID = reportAction.reportID ?? reportID; - if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { - return false; - } + const actionReportID = reportAction.reportID ?? reportID; + if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { + return false; + } - if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { - return false; - } + if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { + return false; + } - return true; - }); + return true; + }), + [canPerformWriteAction, isOffline, reportActions, reportID, reportTransactionIDs, visibleReportActionsData], + ); const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; const isMissingTransactionThreadReportID = !transactionThreadReport?.reportID; const isReportDataIncomplete = isSingleExpenseReport && isMissingTransactionThreadReportID; const isMissingReportActions = visibleReportActions.length === 0; - const allReportActionIDs = allReportActions?.map((action) => action.reportActionID) ?? []; + const allReportActionIDs = useMemo(() => allReportActions?.map((action) => action.reportActionID) ?? [], [allReportActions]); const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, From 8a0e80f6887710d3624dd72961b84cb90ba42600 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 May 2026 14:59:17 +0100 Subject: [PATCH 4/4] fix: report not loading when offline --- src/pages/inbox/report/ReportActionsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 424198bd605f..af76e27751d7 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -294,7 +294,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = isReportUnread && !!reportLoadingState?.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + const isInitiallyLoadingReport = isReportUnread && !!reportLoadingState?.isLoadingInitialReportActions && reportActions.length <= 1; // Same for unread messages, we need to wait for the results from the OpenReport API call // if the oldest unread report action is not available yet. Only applies during the *first* load