diff --git a/src/CONST.ts b/src/CONST.ts index 750f0867b653..aece53666327 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3417,6 +3417,9 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + MOBILE_PAGINATION_SIZE: 15, + WEB_PAGINATION_SIZE: 50, + /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { HEIGHT: 220, diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js index 7fa8b364fb0f..656a0ed7f00e 100644 --- a/src/components/FlatList/MVCPFlatList.js +++ b/src/components/FlatList/MVCPFlatList.js @@ -44,15 +44,15 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont if (scrollRef.current == null) { return 0; } - return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + return horizontal ? scrollRef.current?.getScrollableNode()?.scrollLeft : scrollRef.current?.getScrollableNode()?.scrollTop; }, [horizontal]); - const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode()?.childNodes[0], []); const scrollToOffset = React.useCallback( (offset, animated) => { const behavior = animated ? 'smooth' : 'instant'; - scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + scrollRef.current?.getScrollableNode()?.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); }, [horizontal], ); @@ -68,12 +68,13 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont } const scrollOffset = getScrollOffset(); + lastScrollOffsetRef.current = scrollOffset; const contentViewLength = contentView.childNodes.length; for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { const subview = contentView.childNodes[i]; const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; - if (subviewOffset > scrollOffset || i === contentViewLength - 1) { + if (subviewOffset > scrollOffset) { prevFirstVisibleOffsetRef.current = subviewOffset; firstVisibleViewRef.current = subview; break; @@ -126,6 +127,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont } adjustForMaintainVisibleContentPosition(); + prepareForMaintainVisibleContentPosition(); }); }); mutationObserver.observe(contentView, { @@ -135,7 +137,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont }); mutationObserverRef.current = mutationObserver; - }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + }, [adjustForMaintainVisibleContentPosition, prepareForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); React.useEffect(() => { if (!isListRenderedRef.current) { @@ -172,13 +174,11 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont const onScrollInternal = React.useCallback( (ev) => { - lastScrollOffsetRef.current = getScrollOffset(); - prepareForMaintainVisibleContentPosition(); onScroll?.(ev); }, - [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + [prepareForMaintainVisibleContentPosition, onScroll], ); return ( diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx index 0549e19c2eb4..9ee465369be1 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx @@ -1,23 +1,35 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef} from 'react'; -import type {FlatListProps} from 'react-native'; +import React, {forwardRef, useMemo} from 'react'; +import type {FlatListProps, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; -const WINDOW_SIZE = 15; +type BaseInvertedFlatListProps = FlatListProps & { + shouldEnableAutoScrollToTopThreshold?: boolean; +}; + const AUTOSCROLL_TO_TOP_THRESHOLD = 128; -const maintainVisibleContentPosition = { - minIndexForVisible: 0, - autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD, -}; +function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { + const {shouldEnableAutoScrollToTopThreshold, ...rest} = props; + + const maintainVisibleContentPosition = useMemo(() => { + const config: ScrollViewProps['maintainVisibleContentPosition'] = { + // This needs to be 1 to avoid using loading views as anchors. + minIndexForVisible: 1, + }; + + if (shouldEnableAutoScrollToTopThreshold) { + config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; + } + + return config; + }, [shouldEnableAutoScrollToTopThreshold]); -function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) { return ( @@ -27,3 +39,5 @@ function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef = FlatListProps & { + shouldEnableAutoScrollToTopThreshold?: boolean; +}; + // This is adapted from https://codesandbox.io/s/react-native-dsyse // It's a HACK alert since FlatList has inverted scrolling on web -function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: FlatListProps, ref: ForwardedRef) { +function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: InvertedFlatListProps, ref: ForwardedRef) { const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); const updateInProgress = useRef(false); diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts index 477a002516de..8eaed6bc0fde 100644 --- a/src/libs/API/parameters/OpenReportParams.ts +++ b/src/libs/API/parameters/OpenReportParams.ts @@ -1,5 +1,6 @@ type OpenReportParams = { reportID: string; + reportActionID?: string; emailList?: string; accountIDList?: string; parentReportActionID?: string; diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index 62d6fa00906a..2dfc1e722c58 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -92,4 +92,12 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp}; +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, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp, generateNewRandomInt}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 206c3ecd75a6..1e88e78ab02c 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -51,6 +51,7 @@ type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMe const policyChangeActionsSet = new Set(Object.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG)); const allReports: OnyxCollection = {}; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, callback: (report, key) => { @@ -219,7 +220,7 @@ function isTransactionThread(parentReportAction: OnyxEntry | Empty * This gives us a stable order even in the case of multiple reportActions created on the same millisecond * */ -function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false, shouldMarkTheFirstItemAsNewest = false): ReportAction[] { +function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false): ReportAction[] { if (!Array.isArray(reportActions)) { throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`); } @@ -247,15 +248,58 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier; }); - // If shouldMarkTheFirstItemAsNewest is true, label the first reportAction as isNewestReportAction - if (shouldMarkTheFirstItemAsNewest && sortedActions?.length > 0) { - sortedActions[0] = { - ...sortedActions[0], - isNewestReportAction: true, - }; + return sortedActions; +} + +/** + * Returns the largest gapless range of reportActions including a the provided reportActionID, where a "gap" is defined as a reportAction's `previousReportActionID` not matching the previous reportAction in the sortedReportActions array. + * See unit tests for example of inputs and expected outputs. + * Note: sortedReportActions sorted in descending order + */ +function getContinuousReportActionChain(sortedReportActions: ReportAction[], id?: string): ReportAction[] { + let index; + + if (id) { + index = sortedReportActions.findIndex((obj) => obj.reportActionID === id); + } else { + index = sortedReportActions.findIndex((obj) => obj.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); } - return sortedActions; + if (index === -1) { + return []; + } + + let startIndex = index; + let endIndex = index; + + // Iterate forwards through the array, starting from endIndex. This loop checks the continuity of actions by: + // 1. Comparing the current item's previousReportActionID with the next item's reportActionID. + // This ensures that we are moving in a sequence of related actions from newer to older. + while ( + (endIndex < sortedReportActions.length - 1 && sortedReportActions[endIndex].previousReportActionID === sortedReportActions[endIndex + 1].reportActionID) || + !!sortedReportActions[endIndex + 1]?.whisperedToAccountIDs?.length || + !!sortedReportActions[endIndex]?.whisperedToAccountIDs?.length || + sortedReportActions[endIndex]?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || + sortedReportActions[endIndex + 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || + sortedReportActions[endIndex + 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED + ) { + endIndex++; + } + + // Iterate backwards through the sortedReportActions, starting from startIndex. This loop has two main checks: + // 1. It compares the current item's reportActionID with the previous item's previousReportActionID. + // This is to ensure continuity in a sequence of actions. + // 2. If the first condition fails, it then checks if the previous item has a pendingAction of 'add'. + // This additional check is to include recently sent messages that might not yet be part of the established sequence. + while ( + (startIndex > 0 && sortedReportActions[startIndex].reportActionID === sortedReportActions[startIndex - 1].previousReportActionID) || + sortedReportActions[startIndex - 1]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || + sortedReportActions[startIndex - 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM + ) { + startIndex--; + } + + return sortedReportActions.slice(startIndex, endIndex + 1); } /** @@ -525,12 +569,22 @@ function filterOutDeprecatedReportActions(reportActions: ReportActions | null): * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. */ -function getSortedReportActionsForDisplay(reportActions: ReportActions | ReportAction[] | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] { - const filteredReportActions = Object.entries(reportActions ?? {}) - .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key)) - .map((entry) => entry[1]); +function getSortedReportActionsForDisplay(reportActions: ReportActions | null | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { + let filteredReportActions: ReportAction[] = []; + if (!reportActions) { + return []; + } + + if (shouldIncludeInvisibleActions) { + filteredReportActions = Object.values(reportActions); + } else { + filteredReportActions = Object.entries(reportActions) + .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key)) + .map(([, reportAction]) => reportAction); + } + const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction)); - return getSortedReportActions(baseURLAdjustedReportActions, true, shouldMarkTheFirstItemAsNewest); + return getSortedReportActions(baseURLAdjustedReportActions, true); } /** @@ -1012,6 +1066,7 @@ export { shouldReportActionBeVisible, shouldHideNewMarker, shouldReportActionBeVisibleAsLastAction, + getContinuousReportActionChain, hasRequestFromCurrentAccount, getFirstVisibleReportActionID, isMemberChangeAction, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index b4fe8b3e585f..efd627e7ef93 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -866,13 +866,14 @@ function removeMembers(accountIDs: number[], policyID: string) { }, }); }); - optimisticClosedReportActions.forEach((reportAction, index) => { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, - value: {[reportAction.reportActionID]: reportAction as ReportAction}, - }); - }); + // comment out for time this issue would be resolved https://github.com/Expensify/App/issues/35952 + // optimisticClosedReportActions.forEach((reportAction, index) => { + // optimisticData.push({ + // onyxMethod: Onyx.METHOD.MERGE, + // key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, + // value: {[reportAction.reportActionID]: reportAction as ReportAction}, + // }); + // }); // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins. // If we delete all these logins then we should clear the informative messages since they are no longer relevant. diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 5f4cc20cf15f..199250dcebc6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -116,28 +116,14 @@ Onyx.connect({ // map of reportID to all reportActions for that report const allReportActions: OnyxCollection = {}; -// map of reportID to the ID of the oldest reportAction for that report -const oldestReportActions: Record = {}; - -// map of report to the ID of the newest action for that report -const newestReportActions: Record = {}; - Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - callback: (actions, key) => { - if (!key || !actions) { + callback: (action, key) => { + if (!key || !action) { return; } const reportID = CollectionUtils.extractCollectionItemID(key); - allReportActions[reportID] = actions; - const sortedActions = ReportActionsUtils.getSortedReportActions(Object.values(actions)); - - if (sortedActions.length === 0) { - return; - } - - oldestReportActions[reportID] = sortedActions[0].reportActionID; - newestReportActions[reportID] = sortedActions[sortedActions.length - 1].reportActionID; + allReportActions[reportID] = action; }, }); @@ -546,6 +532,8 @@ function reportActionsExist(reportID: string): boolean { * Gets the latest page of report actions and updates the last read message * If a chat with the passed reportID is not found, we will create a chat based on the passed participantList * + * @param reportID The ID of the report to open + * @param reportActionID The ID used to fetch a specific range of report actions related to the current reportActionID when opening a chat. * @param participantLoginList The list of users that are included in a new chat, not including the user creating it * @param newReportObject The optimistic report object created when making a new chat, saved as optimistic data * @param parentReportActionID The parent report action that a thread was created from (only passed for new threads) @@ -554,6 +542,7 @@ function reportActionsExist(reportID: string): boolean { */ function openReport( reportID: string, + reportActionID?: string, participantLoginList: string[] = [], newReportObject: Partial = {}, parentReportActionID = '0', @@ -610,6 +599,7 @@ function openReport( const parameters: OpenReportParams = { reportID, + reportActionID, emailList: participantLoginList ? participantLoginList.join(',') : '', accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '', parentReportActionID, @@ -760,7 +750,7 @@ function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true const report = chat ?? newChat; // We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server - openReport(report.reportID, userLogins, newChat); + openReport(report.reportID, '', userLogins, newChat); if (shouldDismissModal) { Navigation.dismissModalWithReport(report); } else { @@ -783,7 +773,7 @@ function navigateToAndOpenReportWithAccountIDs(participantAccountIDs: number[]) const report = chat ?? newChat; // We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server - openReport(report.reportID, [], newChat, '0', false, participantAccountIDs); + openReport(report.reportID, '', [], newChat, '0', false, participantAccountIDs); Navigation.dismissModalWithReport(report); } @@ -817,7 +807,7 @@ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction: P ); const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(newChat?.participantAccountIDs ?? []); - openReport(newChat.reportID, participantLogins, newChat, parentReportAction.reportActionID); + openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newChat.reportID)); } } @@ -874,7 +864,7 @@ function reconnect(reportID: string) { * Gets the older actions that have not been read yet. * Normally happens when you scroll up on a chat, and the actions have not been read yet. */ -function getOlderActions(reportID: string) { +function getOlderActions(reportID: string, reportActionID: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -907,7 +897,7 @@ function getOlderActions(reportID: string) { const parameters: GetOlderActionsParams = { reportID, - reportActionID: oldestReportActions[reportID], + reportActionID, }; API.read(READ_COMMANDS.GET_OLDER_ACTIONS, parameters, {optimisticData, successData, failureData}); @@ -917,7 +907,7 @@ function getOlderActions(reportID: string) { * Gets the newer actions that have not been read yet. * Normally happens when you are not located at the bottom of the list and scroll down on a chat. */ -function getNewerActions(reportID: string) { +function getNewerActions(reportID: string, reportActionID: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -950,7 +940,7 @@ function getNewerActions(reportID: string) { const parameters: GetNewerActionsParams = { reportID, - reportActionID: newestReportActions[reportID], + reportActionID, }; API.read(READ_COMMANDS.GET_NEWER_ACTIONS, parameters, {optimisticData, successData, failureData}); @@ -1529,7 +1519,7 @@ function toggleSubscribeToChildReport(childReportID = '0', parentReportAction: P ); const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(participantAccountIDs); - openReport(newChat.reportID, participantLogins, newChat, parentReportAction.reportActionID); + openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID); const notificationPreference = prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; updateNotificationPreference(newChat.reportID, prevNotificationPreference, notificationPreference, false, parentReportID, parentReportAction?.reportActionID); @@ -2215,7 +2205,7 @@ function openReportFromDeepLink(url: string) { if (reportID && !Session.hasAuthToken()) { // Call the OpenReport command to check in the server if it's a public room. If so, we'll open it as an anonymous user - openReport(reportID, [], {}, '0', true); + openReport(reportID, '', [], {}, '0', true); // Show the sign-in page if the app is offline if (isNetworkOffline) { diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 5ce899cdd316..e8c0b2bf3e10 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -1,4 +1,5 @@ import Log from './Log'; +import CheckForPreviousReportActionID from './migrations/CheckForPreviousReportActionID'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; import NVPMigration from './migrations/NVPMigration'; import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts'; @@ -11,7 +12,14 @@ export default function (): Promise { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts, NVPMigration]; + const migrationPromises = [ + CheckForPreviousReportActionID, + RenameReceiptFilename, + KeyReportActionsDraftByReportActionID, + TransactionBackupsToCollection, + RemoveEmptyReportActionsDrafts, + NVPMigration, + ]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index e22b7c6cf1bc..8a367941f9d9 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import lodashIsEqual from 'lodash/isEqual'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {FlatList, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; @@ -9,8 +9,10 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {WithOnyxInstanceState} from 'react-native-onyx/dist/types'; import type {LayoutChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import Banner from '@components/Banner'; +import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import * as Illustrations from '@components/Icon/Illustrations'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -36,6 +38,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; +import variables from '@styles/variables'; import * as ComposerActions from '@userActions/Composer'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; @@ -69,8 +72,8 @@ type ReportScreenOnyxProps = { /** Whether the composer is full size */ isComposerFullSize: OnyxEntry; - /** All the report actions for this report */ - reportActions: OnyxTypes.ReportAction[]; + /** An array containing all report actions related to this report, sorted based on a date criterion */ + sortedAllReportActions: OnyxTypes.ReportAction[]; /** The report currently being looked at */ report: OnyxEntry; @@ -116,12 +119,12 @@ function ReportScreen({ betas = [], route, report: reportProp, + sortedAllReportActions, reportMetadata = { isLoadingInitialReportActions: true, isLoadingOlderReportActions: false, isLoadingNewerReportActions: false, }, - reportActions = [], parentReportAction, accountManagerReportID, markReadyForHydration, @@ -136,6 +139,8 @@ function ReportScreen({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); + const reportIDFromRoute = getReportID(route); + const reportActionIDFromRoute = route?.params?.reportActionID ?? ''; const isFocused = useIsFocused(); const prevIsFocused = usePrevious(isFocused); const firstRenderRef = useRef(true); @@ -228,6 +233,20 @@ function ReportScreen({ const prevReport = usePrevious(report); const prevUserLeavingStatus = usePrevious(userLeavingStatus); + const [isLinkingToMessage, setLinkingToMessage] = useState(!!reportActionIDFromRoute); + const reportActions = useMemo(() => { + if (!sortedAllReportActions.length) { + return []; + } + const currentRangeOfReportActions = ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions, reportActionIDFromRoute); + return currentRangeOfReportActions; + }, [reportActionIDFromRoute, sortedAllReportActions]); + + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. If we have cached reportActions, they will be shown immediately. We aim to display a loader first, then fetch relevant reportActions, and finally show them. + useLayoutEffect(() => { + setLinkingToMessage(!!reportActionIDFromRoute); + }, [route, reportActionIDFromRoute]); + const [isBannerVisible, setIsBannerVisible] = useState(true); const [listHeight, setListHeight] = useState(0); const [scrollPosition, setScrollPosition] = useState({}); @@ -238,7 +257,6 @@ function ReportScreen({ Performance.markStart(CONST.TIMING.CHAT_RENDER); } - const reportID = getReportID(route); const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; const isEmptyChat = useMemo((): boolean => reportActions.length === 0, [reportActions]); @@ -246,8 +264,7 @@ function ReportScreen({ const isLoadingInitialReportActions = reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions; const isOptimisticDelete = report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas); - - const isLoading = !reportID || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); + const isLoading = !reportIDFromRoute || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); const lastReportAction: OnyxEntry = useMemo( () => reportActions.length @@ -257,7 +274,7 @@ function ReportScreen({ ); const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] ?? null; - const isTopMostReportId = currentReportID === getReportID(route); + const isTopMostReportId = currentReportID === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); useEffect(() => { @@ -274,7 +291,7 @@ function ReportScreen({ let headerView = ( { - const reportIDFromPath = getReportID(route); - // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely - const isTransitioning = report && report.reportID !== reportIDFromPath; - return reportIDFromPath !== '' && !!report.reportID && !isTransitioning; - }, [route, report]); + const isTransitioning = report && report.reportID !== reportIDFromRoute; + return reportIDFromRoute !== '' && !!report.reportID && !isTransitioning; + }, [report, reportIDFromRoute]); + + const shouldShowSkeleton = + isLinkingToMessage || !isReportReadyForDisplay || isLoadingInitialReportActions || isLoading || (!!reportActionIDFromRoute && reportMetadata?.isLoadingInitialReportActions); + + const shouldShowReportActionList = isReportReadyForDisplay && !isLoading; + + const fetchReport = useCallback(() => { + Report.openReport(reportIDFromRoute, reportActionIDFromRoute); + }, [reportIDFromRoute, reportActionIDFromRoute]); useEffect(() => { if (!report.reportID || !isFocused) { @@ -319,23 +343,21 @@ function ReportScreen({ }, [report.reportID, isFocused]); const fetchReportIfNeeded = useCallback(() => { - const reportIDFromPath = getReportID(route); - // Report ID will be empty when the reports collection is empty. // This could happen when we are loading the collection for the first time after logging in. - if (!ReportUtils.isValidReportIDFromPath(reportIDFromPath)) { + if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) { return; } - // It possible that we may not have the report object yet in Onyx yet e.g. we navigated to a URL for an accessible report that + // It is possible that we may not have the report object yet in Onyx yet e.g. we navigated to a URL for an accessible report that // is not stored locally yet. If report.reportID exists, then the report has been stored locally and nothing more needs to be done. // If it doesn't exist, then we fetch the report from the API. - if (report.reportID === getReportID(route) && !isLoadingInitialReportActions) { + if (report.reportID && report.reportID === reportIDFromRoute && !reportMetadata?.isLoadingInitialReportActions) { return; } - Report.openReport(reportIDFromPath); - }, [report.reportID, route, isLoadingInitialReportActions]); + fetchReport(); + }, [report.reportID, reportMetadata?.isLoadingInitialReportActions, fetchReport, reportIDFromRoute]); const dismissBanner = useCallback(() => { setIsBannerVisible(false); @@ -399,7 +421,6 @@ function ReportScreen({ const onyxReportID = report.reportID; const prevOnyxReportID = prevReport.reportID; - const routeReportID = getReportID(route); // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) if ( @@ -407,7 +428,7 @@ function ReportScreen({ (!prevUserLeavingStatus && !!userLeavingStatus) || // optimistic case (!!prevOnyxReportID && - prevOnyxReportID === routeReportID && + prevOnyxReportID === reportIDFromRoute && !onyxReportID && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && (report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED || (!report.statusNum && !prevReport.parentReportID && prevReport.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM))) || @@ -435,16 +456,29 @@ function ReportScreen({ // the ReportScreen never actually unmounts and the reportID in the route also doesn't change. // Therefore, we need to compare if the existing reportID is the same as the one in the route // before deciding that we shouldn't call OpenReport. - if (onyxReportID === prevReport.reportID && (!onyxReportID || onyxReportID === routeReportID)) { + if (onyxReportID === prevReport.reportID && (!onyxReportID || onyxReportID === reportIDFromRoute)) { return; } fetchReportIfNeeded(); ComposerActions.setShouldShowComposeInput(true); - }, [route, report, fetchReportIfNeeded, prevReport.reportID, prevUserLeavingStatus, userLeavingStatus, prevReport.statusNum, prevReport.parentReportID, prevReport.chatType, prevReport]); + }, [ + route, + report, + // errors, + fetchReportIfNeeded, + prevReport.reportID, + prevUserLeavingStatus, + userLeavingStatus, + prevReport.statusNum, + prevReport.parentReportID, + prevReport.chatType, + prevReport, + reportIDFromRoute, + ]); useEffect(() => { - if (!ReportUtils.isValidReportIDFromPath(reportID)) { + if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) { return; } // Ensures subscription event succeeds when the report/workspace room is created optimistically. @@ -455,7 +489,7 @@ function ReportScreen({ let interactionTask: ReturnType | null = null; if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) { interactionTask = InteractionManager.runAfterInteractions(() => { - Report.subscribeToReportLeavingEvents(reportID); + Report.subscribeToReportLeavingEvents(reportIDFromRoute); didSubscribeToReportLeavingEvents.current = true; }); } @@ -465,7 +499,7 @@ function ReportScreen({ } interactionTask.cancel(); }; - }, [report, didSubscribeToReportLeavingEvents, reportID]); + }, [report, didSubscribeToReportLeavingEvents, reportIDFromRoute]); const onListLayout = useCallback((event: LayoutChangeEvent) => { setListHeight((prev) => event.nativeEvent?.layout?.height ?? prev); @@ -477,7 +511,6 @@ function ReportScreen({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const reportIDFromParams = route.params.reportID; // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo( (): boolean => @@ -489,12 +522,46 @@ function ReportScreen({ !isLoading && !userLeavingStatus) || shouldHideReport || - (!!reportIDFromParams && !ReportUtils.isValidReportIDFromPath(reportIDFromParams)), - [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromParams], + (!!reportIDFromRoute && !ReportUtils.isValidReportIDFromPath(reportIDFromRoute)), + [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromRoute], ); const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); + // This helps in tracking from the moment 'route' triggers useMemo until isLoadingInitialReportActions becomes true. It prevents blinking when loading reportActions from cache. + useEffect(() => { + InteractionManager.runAfterInteractions(() => { + setLinkingToMessage(false); + }); + }, [reportMetadata?.isLoadingInitialReportActions]); + + const onLinkPress = () => { + Navigation.setParams({reportActionID: ''}); + fetchReport(); + }; + + const isLinkedReportActionDeleted = useMemo(() => { + if (!reportActionIDFromRoute || !sortedAllReportActions) { + return false; + } + const action = sortedAllReportActions.find((item) => item.reportActionID === reportActionIDFromRoute); + return action && ReportActionsUtils.isDeletedAction(action); + }, [reportActionIDFromRoute, sortedAllReportActions]); + + if (isLinkedReportActionDeleted ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) { + return ( + + ); + } + return ( @@ -543,7 +610,7 @@ function ReportScreen({ style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} onLayout={onListLayout} > - {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && ( + {shouldShowReportActionList && ( )} {/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded. - If we prevent rendering the report while they are loading then - we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} - {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && } + If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + {shouldShowSkeleton && } {isReportReadyForDisplay ? ( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, canEvict: false, - selector: (reportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true), + selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), }, report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, @@ -642,7 +710,7 @@ export default withViewportOffsetTop( ReportScreen, (prevProps, nextProps) => prevProps.isSidebarLoaded === nextProps.isSidebarLoaded && - lodashIsEqual(prevProps.reportActions, nextProps.reportActions) && + lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && prevProps.isComposerFullSize === nextProps.isComposerFullSize && lodashIsEqual(prevProps.betas, nextProps.betas) && @@ -652,6 +720,7 @@ export default withViewportOffsetTop( prevProps.currentReportID === nextProps.currentReportID && prevProps.viewportOffsetTop === nextProps.viewportOffsetTop && lodashIsEqual(prevProps.parentReportAction, nextProps.parentReportAction) && + lodashIsEqual(prevProps.route, nextProps.route) && lodashIsEqual(prevProps.report, nextProps.report), ), ), diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 366b04634eb0..bcbb7a98c8c5 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -8,6 +8,7 @@ import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StylePr import type {OnyxEntry} from 'react-native-onyx'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import InvertedFlatList from '@components/InvertedFlatList'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {usePersonalDetails} from '@components/OnyxProvider'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; @@ -17,6 +18,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; @@ -24,10 +26,12 @@ import type {CentralPaneNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import FloatingMessageCounter from './FloatingMessageCounter'; +import getInitialNumToRender from './getInitialNumReportActionsToRender'; import ListBoundaryLoader from './ListBoundaryLoader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; @@ -65,10 +69,19 @@ type ReportActionsListProps = WithCurrentUserPersonalDetailsProps & { loadOlderChats: () => void; /** Function to load newer chats */ - loadNewerChats: LoadNewerChats; + loadNewerChats: () => void; /** Whether the composer is in full size */ isComposerFullSize?: boolean; + + /** ID of the list */ + listID: number; + + /** Callback executed on content size change */ + onContentSizeChange: (w: number, h: number) => void; + + /** Should enable auto scroll to top threshold */ + shouldEnableAutoScrollToTopThreshold?: boolean; }; const VERTICAL_OFFSET_THRESHOLD = 200; @@ -124,6 +137,9 @@ function ReportActionsList({ loadOlderChats, onLayout, isComposerFullSize, + listID, + onContentSizeChange, + shouldEnableAutoScrollToTopThreshold, }: ReportActionsListProps) { const personalDetailsList = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); @@ -132,6 +148,7 @@ function ReportActionsList({ const {isOffline} = useNetwork(); const route = useRoute>(); const opacity = useSharedValue(0); + const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(null); const lastMessageTime = useRef(null); @@ -152,7 +169,6 @@ function ReportActionsList({ } return cacheUnreadMarkers.get(report.reportID); }; - const reportScrollManager = useReportScrollManager(); const [currentUnreadMarker, setCurrentUnreadMarker] = useState(markerInit); const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); @@ -162,14 +178,21 @@ function ReportActionsList({ const lastReadTimeRef = useRef(report.lastReadTime); const sortedVisibleReportActions = useMemo( - () => sortedReportActions.filter((reportAction) => isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors), + () => + sortedReportActions.filter( + (reportAction) => + (isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && + ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID), + ), [sortedReportActions, isOffline], ); const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID; const reportActionSize = useRef(sortedVisibleReportActions.length); + const hasNewestReportAction = sortedReportActions?.[0].created === report.lastVisibleActionCreated; const previousLastIndex = useRef(lastActionIndex); + const isLastPendingActionIsDelete = sortedReportActions?.[0]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const linkedReportActionID = route.params?.reportActionID ?? ''; // This state is used to force a re-render when the user manually marks a message as unread @@ -185,12 +208,17 @@ function ReportActionsList({ }, [opacity]); useEffect(() => { - if (previousLastIndex.current !== lastActionIndex && reportActionSize.current > sortedVisibleReportActions.length) { + if ( + scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + previousLastIndex.current !== lastActionIndex && + reportActionSize.current > sortedVisibleReportActions.length && + hasNewestReportAction + ) { reportScrollManager.scrollToBottom(); } previousLastIndex.current = lastActionIndex; reportActionSize.current = sortedVisibleReportActions.length; - }, [lastActionIndex, sortedVisibleReportActions.length, reportScrollManager]); + }, [lastActionIndex, sortedVisibleReportActions, reportScrollManager, hasNewestReportAction, linkedReportActionID]); useEffect(() => { // If the reportID changes, we reset the userActiveSince to null, we need to do it because @@ -273,12 +301,27 @@ function ReportActionsList({ }, [report.reportID]); useEffect(() => { + if (linkedReportActionID) { + return; + } InteractionManager.runAfterInteractions(() => { reportScrollManager.scrollToBottom(); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const scrollToBottomForCurrentUserAction = useCallback( + (isFromCurrentUser: boolean) => { + // 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 || !hasNewestReportAction) { + return; + } + InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom()); + }, + [hasNewestReportAction, reportScrollManager], + ); + 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, @@ -294,14 +337,7 @@ function ReportActionsList({ // 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 = Report.subscribeToNewActionEvent(report.reportID, (isFromCurrentUser) => { - // 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) { - return; - } - InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom()); - }); + const unsubscribe = Report.subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); const cleanup = () => { if (unsubscribe) { @@ -343,6 +379,11 @@ function ReportActionsList({ }; const scrollToBottomAndMarkReportAsRead = () => { + if (!hasNewestReportAction) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); + Report.openReport(report.reportID); + return; + } reportScrollManager.scrollToBottom(); readActionSkipped.current = false; Report.readNewestAction(report.reportID); @@ -355,9 +396,12 @@ function ReportActionsList({ const initialNumToRender = useMemo((): number | undefined => { const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - const itemsToRender = Math.ceil(availableHeight / minimumReportActionHeight); - return itemsToRender > 0 ? itemsToRender : undefined; - }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight]); + const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); + if (linkedReportActionID) { + return getInitialNumToRender(numToRender); + } + return numToRender || undefined; + }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); /** * Thread's divider line should hide when the first chat in the thread is marked as unread. @@ -488,10 +532,11 @@ function ReportActionsList({ const extraData = useMemo(() => [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)], [currentUnreadMarker, isSmallScreenWidth, report]); const hideComposer = !ReportUtils.canUserPerformWriteAction(report); const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + const canShowHeader = !isOffline && !hasHeaderRendered.current && scrollingVerticalOffset.current > VERTICAL_OFFSET_THRESHOLD; const contentContainerStyle: StyleProp = useMemo( - () => [styles.chatContentScrollView, isLoadingNewerReportActions ? styles.chatContentScrollViewWithHeaderLoader : {}], - [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader], + () => [styles.chatContentScrollView, isLoadingNewerReportActions && canShowHeader ? styles.chatContentScrollViewWithHeaderLoader : {}], + [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader, canShowHeader], ); const lastReportAction: OnyxTypes.ReportAction | EmptyObject = useMemo(() => sortedReportActions.at(-1) ?? {}, [sortedReportActions]); @@ -521,9 +566,15 @@ function ReportActionsList({ }, [onLayout], ); + const onContentSizeChangeInner = useCallback( + (w: number, h: number) => { + onContentSizeChange(w, h); + }, + [onContentSizeChange], + ); const listHeaderComponent = useCallback(() => { - if (!isOffline && !hasHeaderRendered.current) { + if (!canShowHeader) { hasHeaderRendered.current = true; return null; } @@ -534,12 +585,15 @@ function ReportActionsList({ isLoadingNewerReportActions={isLoadingNewerReportActions} /> ); - }, [isLoadingNewerReportActions, isOffline]); + }, [isLoadingNewerReportActions, canShowHeader]); + // When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server. + // This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet. + const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete; return ( <> @@ -548,7 +602,7 @@ function ReportActionsList({ ref={reportScrollManager.ref} testID="report-actions-list" style={styles.overscrollBehaviorContain} - data={sortedReportActions} + data={sortedVisibleReportActions} renderItem={renderItem} contentContainerStyle={contentContainerStyle} keyExtractor={keyExtractor} @@ -561,9 +615,12 @@ function ReportActionsList({ ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} + onContentSizeChange={onContentSizeChangeInner} onScroll={trackVerticalScrolling} onScrollToIndexFailed={onScrollToIndexFailed} extraData={extraData} + key={listID} + shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} /> @@ -574,4 +631,4 @@ ReportActionsList.displayName = 'ReportActionsList'; export default withCurrentUserPersonalDetails(memo(ReportActionsList)); -export type {LoadNewerChats}; +export type {LoadNewerChats, ReportActionsListProps}; diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 3153fd1061ff..520a9a3604c5 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -1,7 +1,7 @@ -import {useIsFocused} from '@react-navigation/native'; +import type {RouteProp} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; -import lodashThrottle from 'lodash/throttle'; -import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -11,6 +11,8 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useWindowDimensions from '@hooks/useWindowDimensions'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; +import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types'; +import {generateNewRandomInt} from '@libs/NumberUtils'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; @@ -20,11 +22,12 @@ import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; 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 getInitialPaginationSize from './getInitialPaginationSize'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import ReportActionsList from './ReportActionsList'; -import type {LoadNewerChats} from './ReportActionsList'; type ReportActionsViewOnyxProps = { /** Session info for the currently logged in user. */ @@ -49,34 +52,63 @@ type ReportActionsViewProps = ReportActionsViewOnyxProps & { /** The report actions are loading newer data */ isLoadingNewerReportActions?: boolean; + + /** Whether the report is ready for comment linking */ + isReadyForCommentLinking?: boolean; }; +const DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST = 120; +const SPACER = 16; + +let listOldID = Math.round(Math.random() * 100); + function ReportActionsView({ report, session, parentReportAction, - reportActions = [], + reportActions: allReportActions = [], isLoadingInitialReportActions = false, isLoadingOlderReportActions = false, isLoadingNewerReportActions = false, + isReadyForCommentLinking = false, }: ReportActionsViewProps) { useCopySelectionHelper(); const reactionListRef = useContext(ReactionListContext); + const route = useRoute>(); + const reportActionID = route?.params?.reportActionID; const didLayout = useRef(false); const didSubscribeToReportTypingEvents = useRef(false); - const isFirstRender = useRef(true); - const hasCachedActions = useInitialValue(() => reportActions.length > 0); - const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); + + // triggerListID is used when navigating to a chat with messages loaded from LHN. Typically, these include thread actions, task actions, etc. Since these messages aren't the latest,we don't maintain their position and instead trigger a recalculation of their positioning in the list. + // we don't set currentReportActionID on initial render as linkedID as it should trigger visibleReportActions after linked message was positioned + const [currentReportActionID, setCurrentReportActionID] = useState(''); + const isFirstLinkedActionRender = useRef(true); + const network = useNetwork(); - const {isSmallScreenWidth} = useWindowDimensions(); + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); + const contentListHeight = useRef(0); + const isFocused = useIsFocused(); const prevNetworkRef = useRef(network); const prevAuthTokenType = usePrevious(session?.authTokenType); - + const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionID); const prevIsSmallScreenWidthRef = useRef(isSmallScreenWidth); - - const isFocused = useIsFocused(); const reportID = report.reportID; - const hasNewestReportAction = reportActions[0]?.isNewestReportAction; + const isLoading = (!!reportActionID && isLoadingInitialReportActions) || !isReadyForCommentLinking; + + /** + * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently + * displaying. + */ + const fetchNewerAction = useCallback( + (newestReportAction: OnyxTypes.ReportAction) => { + if (isLoadingNewerReportActions || isLoadingInitialReportActions) { + return; + } + + Report.getNewerActions(reportID, newestReportAction.reportActionID); + }, + [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID], + ); const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); @@ -87,10 +119,75 @@ function ReportActionsView({ return; } - Report.openReport(reportID); + Report.openReport(reportID, reportActionID); }; + useLayoutEffect(() => { + setCurrentReportActionID(''); + }, [route]); + + const listID = useMemo(() => { + isFirstLinkedActionRender.current = true; + const newID = generateNewRandomInt(listOldID, 1, Number.MAX_SAFE_INTEGER); + listOldID = newID; + return newID; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route, isLoadingInitialReportActions]); + + const indexOfLinkedAction = useMemo(() => { + if (!reportActionID || isLoading) { + return -1; + } + + return allReportActions.findIndex((obj) => String(obj.reportActionID) === String(isFirstLinkedActionRender.current ? reportActionID : currentReportActionID)); + }, [allReportActions, currentReportActionID, reportActionID, isLoading]); + + const reportActions = useMemo(() => { + if (!reportActionID) { + return allReportActions; + } + if (isLoading || indexOfLinkedAction === -1) { + return []; + } + + if (isFirstLinkedActionRender.current) { + return allReportActions.slice(indexOfLinkedAction); + } + const paginationSize = getInitialPaginationSize; + return allReportActions.slice(Math.max(indexOfLinkedAction - paginationSize, 0)); + // currentReportActionID is needed to trigger batching once the report action has been positioned + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportActionID, allReportActions, indexOfLinkedAction, isLoading, currentReportActionID]); + + const hasMoreCached = reportActions.length < allReportActions.length; + const newestReportAction = useMemo(() => reportActions?.[0], [reportActions]); + const handleReportActionPagination = useCallback( + ({firstReportActionID}: {firstReportActionID: string}) => { + // This function is a placeholder as the actual pagination is handled by visibleReportActions + if (!hasMoreCached) { + isFirstLinkedActionRender.current = false; + fetchNewerAction(newestReportAction); + } + if (isFirstLinkedActionRender.current) { + isFirstLinkedActionRender.current = false; + } + setCurrentReportActionID(firstReportActionID); + }, + [fetchNewerAction, hasMoreCached, newestReportAction], + ); + + const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); + const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0); + const hasNewestReportAction = reportActions[0]?.created === report.lastVisibleActionCreated; + + const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + const hasCreatedAction = oldestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; + useEffect(() => { + if (reportActionID) { + return; + } + const interactionTask = InteractionManager.runAfterInteractions(() => { openReportIfNecessary(); }); @@ -100,9 +197,22 @@ function ReportActionsView({ interactionTask.cancel(); }; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!reportActionID) { + return; + } + + // This function is triggered when a user clicks on a link to navigate to a report. + // For each link click, we retrieve the report data again, even though it may already be cached. + // There should be only one openReport execution per page start or navigating + Report.openReport(reportID, reportActionID); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route]); + useEffect(() => { const prevNetwork = prevNetworkRef.current; // When returning from offline to online state we want to trigger a request to OpenReport which @@ -164,7 +274,11 @@ function ReportActionsView({ } }, [report.pendingFields, didSubscribeToReportTypingEvents, reportID]); - const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + const onContentSizeChange = useCallback((w: number, h: number) => { + contentListHeight.current = h; + }, []); + + const checkIfContentSmallerThanList = useCallback(() => windowHeight - DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST - SPACER > contentListHeight.current, [windowHeight]); /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -172,48 +286,44 @@ function ReportActionsView({ */ const loadOlderChats = useCallback(() => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. - if (!!network.isOffline || isLoadingOlderReportActions) { + if (!!network.isOffline || isLoadingOlderReportActions || isLoadingInitialReportActions) { return; } // Don't load more chats if we're already at the beginning of the chat history - if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + if (!oldestReportAction || hasCreatedAction) { return; } // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments - Report.getOlderActions(reportID); - }, [isLoadingOlderReportActions, network.isOffline, oldestReportAction, reportID]); + Report.getOlderActions(reportID, oldestReportAction.reportActionID); + }, [network.isOffline, isLoadingOlderReportActions, isLoadingInitialReportActions, oldestReportAction, hasCreatedAction, reportID]); - /** - * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently - * displaying. - */ - const loadNewerChats: LoadNewerChats = useMemo( - () => - lodashThrottle(({distanceFromStart}) => { - if (isLoadingNewerReportActions || isLoadingInitialReportActions || hasNewestReportAction) { - return; - } - - // Ideally, we wouldn't need to use the 'distanceFromStart' variable. However, due to the low value set for 'maxToRenderPerBatch', - // the component undergoes frequent re-renders. This frequent re-rendering triggers the 'onStartReached' callback multiple times. - // - // To mitigate this issue, we use 'CONST.CHAT_HEADER_LOADER_HEIGHT' as a threshold. This ensures that 'onStartReached' is not - // triggered unnecessarily when the chat is initially opened or when the user has reached the end of the list but hasn't scrolled further. - // - // Additionally, we use throttling on the 'onStartReached' callback to further reduce the frequency of its invocation. - // This should be removed once the issue of frequent re-renders is resolved. - // - // onStartReached is triggered during the first render. Since we use OpenReport on the first render and are confident about the message ordering, we can safely skip this call - if (isFirstRender.current || distanceFromStart <= CONST.CHAT_HEADER_LOADER_HEIGHT) { - isFirstRender.current = false; - return; - } - - Report.getNewerActions(reportID); - }, 500), - [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID, hasNewestReportAction], - ); + const loadNewerChats = useCallback(() => { + if (isLoadingInitialReportActions || isLoadingOlderReportActions || network.isOffline || newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + // Determines if loading older reports is necessary when the content is smaller than the list + // and there are fewer than 23 items, indicating we've reached the oldest message. + const isLoadingOlderReportsFirstNeeded = checkIfContentSmallerThanList() && reportActions.length > 23; + + if ( + (reportActionID && indexOfLinkedAction > -1 && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded) || + (!reportActionID && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded) + ) { + handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID}); + } + }, [ + isLoadingInitialReportActions, + isLoadingOlderReportActions, + checkIfContentSmallerThanList, + reportActionID, + indexOfLinkedAction, + hasNewestReportAction, + handleReportActionPagination, + network.isOffline, + reportActions.length, + newestReportAction, + ]); /** * Runs when the FlatList finishes laying out @@ -224,7 +334,7 @@ function ReportActionsView({ } didLayout.current = true; - Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActions ? CONST.TIMING.WARM : CONST.TIMING.COLD); + Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActionOnFirstRender ? CONST.TIMING.WARM : CONST.TIMING.COLD); // Capture the init measurement only once not per each chat switch as the value gets overwritten if (!ReportActionsView.initMeasured) { @@ -233,12 +343,68 @@ function ReportActionsView({ } else { Performance.markEnd(CONST.TIMING.SWITCH_REPORT); } - }, [hasCachedActions]); + }, [hasCachedActionOnFirstRender]); + + useEffect(() => { + // Temporary solution for handling REPORTPREVIEW. More details: https://expensify.slack.com/archives/C035J5C9FAP/p1705417778466539?thread_ts=1705035404.136629&cid=C035J5C9FAP + // This code should be removed once REPORTPREVIEW is no longer repositioned. + // We need to call openReport for gaps created by moving REPORTPREVIEW, which causes mismatches in previousReportActionID and reportActionID of adjacent reportActions. The server returns the correct sequence, allowing us to overwrite incorrect data with the correct one. + const shouldOpenReport = + newestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && + !hasCreatedAction && + isReadyForCommentLinking && + reportActions.length < 24 && + reportActions.length >= 1 && + !isLoadingInitialReportActions && + !isLoadingOlderReportActions && + !isLoadingNewerReportActions; + + if (shouldOpenReport) { + Report.openReport(reportID, reportActionID); + } + }, [ + hasCreatedAction, + reportID, + reportActions, + reportActionID, + newestReportAction?.actionName, + isReadyForCommentLinking, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isLoadingInitialReportActions, + ]); + + // Check if the first report action in the list is the one we're currently linked to + const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; + + useEffect(() => { + let timerID: NodeJS.Timeout; + + if (isTheFirstReportActionIsLinked) { + setNavigatingToLinkedMessage(true); + } else { + // After navigating to the linked reportAction, apply this to correctly set + // `autoscrollToTopThreshold` prop when linking to a specific reportAction. + InteractionManager.runAfterInteractions(() => { + // Using a short delay to ensure the view is updated after interactions + timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10); + }); + } + + return () => { + if (!timerID) { + return; + } + clearTimeout(timerID); + }; + }, [isTheFirstReportActionIsLinked]); // Comments have not loaded at all yet do nothing if (!reportActions.length) { return null; } + // AutoScroll is disabled when we do linking to a specific reportAction + const shouldEnableAutoScroll = hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage); return ( <> @@ -253,6 +419,9 @@ function ReportActionsView({ isLoadingInitialReportActions={isLoadingInitialReportActions} isLoadingOlderReportActions={isLoadingOlderReportActions} isLoadingNewerReportActions={isLoadingNewerReportActions} + listID={listID} + onContentSizeChange={onContentSizeChange} + shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} /> @@ -263,6 +432,9 @@ ReportActionsView.displayName = 'ReportActionsView'; ReportActionsView.initMeasured = false; function arePropsEqual(oldProps: ReportActionsViewProps, newProps: ReportActionsViewProps): boolean { + if (!lodashIsEqual(oldProps.isReadyForCommentLinking, newProps.isReadyForCommentLinking)) { + return false; + } if (!lodashIsEqual(oldProps.reportActions, newProps.reportActions)) { return false; } diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts new file mode 100644 index 000000000000..4d0986216e59 --- /dev/null +++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts @@ -0,0 +1,4 @@ +function getInitialNumToRender(numToRender: number): number { + return numToRender; +} +export default getInitialNumToRender; diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts new file mode 100644 index 000000000000..cb1f0dfdcded --- /dev/null +++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts @@ -0,0 +1,7 @@ +const DEFAULT_NUM_TO_RENDER = 50; + +function getInitialNumToRender(numToRender: number): number { + // For web and desktop environments, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list. + return Math.max(numToRender, DEFAULT_NUM_TO_RENDER); +} +export default getInitialNumToRender; diff --git a/src/pages/home/report/getInitialPaginationSize/index.native.ts b/src/pages/home/report/getInitialPaginationSize/index.native.ts new file mode 100644 index 000000000000..195448f7e450 --- /dev/null +++ b/src/pages/home/report/getInitialPaginationSize/index.native.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/pages/home/report/getInitialPaginationSize/index.ts b/src/pages/home/report/getInitialPaginationSize/index.ts new file mode 100644 index 000000000000..87ec6856aa20 --- /dev/null +++ b/src/pages/home/report/getInitialPaginationSize/index.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.WEB_PAGINATION_SIZE; diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 64c8edb134b1..5e9efcc00617 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -2231,7 +2231,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); // When Opening a thread report with the given details - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); // Then The iou action has the transaction report id as a child report ID @@ -2310,7 +2310,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); // When Opening a thread report with the given details - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); // Then The iou action has the transaction report id as a child report ID @@ -2380,7 +2380,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); jest.advanceTimersByTime(10); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); Onyx.connect({ @@ -2472,7 +2472,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); @@ -2698,7 +2698,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); const allReportActions = await new Promise((resolve) => { diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index c040e634c1ea..0726dbc9c88d 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -253,15 +253,15 @@ function signInAndGetAppWithUnreadChat(): Promise { }, ], }, - 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1'), - 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2'), - 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3'), - 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4'), - 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5'), - 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6'), - 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7'), - 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8'), - 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9'), + 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1', createdReportActionID), + 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2', '1'), + 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3', '2'), + 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4', '3'), + 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5', '4'), + 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6', '5'), + 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7', '6'), + 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8', '7'), + 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9', '8'), }); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 14c749fc92de..bf528eca3e81 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -305,116 +305,6 @@ describe('ReportActionsUtils', () => { expect(result).toStrictEqual(input); }); - describe('getSortedReportActionsForDisplay with marked the first reportAction', () => { - it('should filter out non-whitelisted actions', () => { - const input: ReportAction[] = [ - { - created: '2022-11-13 22:27:01.825', - reportActionID: '8401445780099176', - actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-12 22:27:01.825', - reportActionID: '6401435781022176', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-11 22:27:01.825', - reportActionID: '2962390724708756', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - originalMessage: { - amount: 0, - currency: 'USD', - type: 'split', // change to const - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-10 22:27:01.825', - reportActionID: '1609646094152486', - actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, - originalMessage: { - html: 'Hello world', - lastModified: '2022-11-10 22:27:01.825', - oldName: 'old name', - newName: 'new name', - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-09 22:27:01.825', - reportActionID: '8049485084562457', - actionName: CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.UPDATE_FIELD, - originalMessage: {}, - message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"', type: 'Action type', text: 'Action text'}], - }, - { - created: '2022-11-08 22:27:06.825', - reportActionID: '1661970171066216', - actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED, - originalMessage: { - paymentType: 'ACH', - }, - message: [{html: 'Waiting for the bank account', type: 'Action type', text: 'Action text'}], - }, - { - created: '2022-11-06 22:27:08.825', - reportActionID: '1661970171066220', - actionName: CONST.REPORT.ACTIONS.TYPE.TASKEDITED, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [{html: 'I have changed the task', type: 'Action type', text: 'Action text'}], - }, - ]; - - const resultWithoutNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input); - const resultWithNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input, true); - input.pop(); - // Mark the newest report action as the newest report action - resultWithoutNewestFlag[0] = { - ...resultWithoutNewestFlag[0], - isNewestReportAction: true, - }; - expect(resultWithoutNewestFlag).toStrictEqual(resultWithNewestFlag); - }); - }); - it('should filter out closed actions', () => { const input: ReportAction[] = [ { @@ -551,6 +441,1349 @@ describe('ReportActionsUtils', () => { expect(result).toStrictEqual(input); }); }); + describe('getContinuousReportActionChain', () => { + it('given an input ID of 1, ..., 7 it will return the report actions with id 1 - 7', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '3'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 9, ..., 12 it will return the report actions with id 9 - 12', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '10'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 14, ..., 17 it will return the report actions with id 14 - 17', () => { + const input = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '16'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 8 or 13 which are not exist in Onyx it will return an empty array', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult: ReportAction[] = []; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '8'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + }); describe('getLastVisibleAction', () => { it('should return the last visible action for a report', () => { diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js index b26c601a1c06..c7bc95c58244 100644 --- a/tests/utils/TestHelper.js +++ b/tests/utils/TestHelper.js @@ -200,9 +200,10 @@ function setPersonalDetails(login, accountID) { * @param {String} created * @param {Number} actorAccountID * @param {String} actionID + * @param {String} previousReportActionID * @returns {Object} */ -function buildTestReportComment(created, actorAccountID, actionID = null) { +function buildTestReportComment(created, actorAccountID, actionID = null, previousReportActionID = null) { const reportActionID = actionID || NumberUtils.rand64(); return { actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, @@ -211,6 +212,7 @@ function buildTestReportComment(created, actorAccountID, actionID = null) { message: [{type: 'COMMENT', html: `Comment ${actionID}`, text: `Comment ${actionID}`}], reportActionID, actorAccountID, + previousReportActionID, }; }