diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fd24f8d6558b..11eabfe5a217 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -800,6 +800,32 @@ function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID); } +/** + * @private + */ +function isReportActionUnread(reportAction: OnyxEntry, lastReadTime: string) { + if (!lastReadTime) { + return !isCreatedAction(reportAction); + } + + return Boolean(reportAction && lastReadTime && reportAction.created && lastReadTime < reportAction.created); +} + +/** + * Check whether the current report action of the report is unread or not + * + */ +function isCurrentActionUnread(report: Report | EmptyObject, reportAction: ReportAction): boolean { + const lastReadTime = report.lastReadTime ?? ''; + const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(report.reportID))); + const currentActionIndex = sortedReportActions.findIndex((action) => action.reportActionID === reportAction.reportActionID); + if (currentActionIndex === -1) { + return false; + } + const prevReportAction = sortedReportActions[currentActionIndex - 1]; + return isReportActionUnread(reportAction, lastReadTime) && (!prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime)); +} + export { extractLinksFromMessageHtml, getAllReportActions, @@ -849,6 +875,7 @@ export { getMemberChangeMessageFragment, getMemberChangeMessagePlainText, isReimbursementDeQueuedAction, + isCurrentActionUnread, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5f3efcbcdbb0..18f082ea1e86 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -402,6 +402,18 @@ type OnyxDataTaskAssigneeChat = { optimisticChatCreatedReportAction?: OptimisticCreatedReportAction; }; +type Ancestor = { + report: Report; + reportAction: ReportAction; + shouldDisplayNewMarker: boolean; + shouldHideThreadDividerLine: boolean; +}; + +type AncestorIDs = { + reportIDs: string[]; + reportActionsIDs: string[]; +}; + let currentUserEmail: string | undefined; let currentUserAccountID: number | undefined; let isAnonymousUser = false; @@ -4663,6 +4675,78 @@ function shouldDisableThread(reportAction: OnyxEntry, reportID: st ); } +function getAllAncestorReportActions(report: Report | null | undefined, shouldHideThreadDividerLine: boolean): Ancestor[] { + if (!report) { + return []; + } + const allAncestors: Ancestor[] = []; + let parentReportID = report.parentReportID; + let parentReportActionID = report.parentReportActionID; + + // Store the child of parent report + let currentReport = report; + let currentUnread = shouldHideThreadDividerLine; + + while (parentReportID) { + const parentReport = getReport(parentReportID); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '0'); + + if (!parentReportAction || ReportActionsUtils.isTransactionThread(parentReportAction) || !parentReport) { + break; + } + + const isParentReportActionUnread = ReportActionsUtils.isCurrentActionUnread(parentReport, parentReportAction); + allAncestors.push({ + report: currentReport, + reportAction: parentReportAction, + shouldDisplayNewMarker: isParentReportActionUnread, + // We should hide the thread divider line if the previous ancestor action is unread + shouldHideThreadDividerLine: currentUnread, + }); + parentReportID = parentReport?.parentReportID; + parentReportActionID = parentReport?.parentReportActionID; + if (!isEmptyObject(parentReport)) { + currentReport = parentReport; + currentUnread = isParentReportActionUnread; + } + } + + return allAncestors.reverse(); +} + +function getAllAncestorReportActionIDs(report: Report | null | undefined): AncestorIDs { + if (!report) { + return { + reportIDs: [], + reportActionsIDs: [], + }; + } + + const allAncestorIDs: AncestorIDs = { + reportIDs: [], + reportActionsIDs: [], + }; + let parentReportID = report.parentReportID; + let parentReportActionID = report.parentReportActionID; + + while (parentReportID) { + const parentReport = getReport(parentReportID); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '0'); + + if (!parentReportAction || ReportActionsUtils.isTransactionThread(parentReportAction) || !parentReport) { + break; + } + + allAncestorIDs.reportIDs.push(parentReportID ?? ''); + allAncestorIDs.reportActionsIDs.push(parentReportActionID ?? ''); + + parentReportID = parentReport?.parentReportID; + parentReportActionID = parentReport?.parentReportActionID; + } + + return allAncestorIDs; +} + function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry = null): boolean { if (!policy) { return false; @@ -4860,11 +4944,13 @@ export { shouldDisableThread, doesReportBelongToWorkspace, getChildReportNotificationPreference, + getAllAncestorReportActions, isReportParticipant, isValidReport, isReportFieldOfTypeTitle, isReportFieldDisabled, getAvailableReportFields, + getAllAncestorReportActionIDs, }; export type { @@ -4876,4 +4962,5 @@ export type { OptimisticAddCommentReportAction, OptimisticCreatedReportAction, OptimisticClosedReportAction, + Ancestor, }; diff --git a/src/libs/onyxSubscribe.ts b/src/libs/onyxSubscribe.ts index af775842fc16..3a2daca900e4 100644 --- a/src/libs/onyxSubscribe.ts +++ b/src/libs/onyxSubscribe.ts @@ -8,7 +8,7 @@ import type {OnyxCollectionKey, OnyxKey} from '@src/ONYXKEYS'; * @param mapping Same as for Onyx.connect() * @return Unsubscribe callback */ -function onyxSubscribe(mapping: ConnectOptions) { +function onyxSubscribe(mapping: ConnectOptions) { const connectionId = Onyx.connect(mapping); return () => Onyx.disconnect(connectionId); } diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index ffa6c0adf3b8..62301d58bd66 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -248,7 +248,8 @@ const ContextMenuActions: ContextMenuAction[] = [ shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat), onPress: (closePopover, {reportAction, reportID}) => { - Report.markCommentAsUnread(reportID, reportAction?.created); + const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction) ?? ''; + Report.markCommentAsUnread(originalReportID, reportAction?.created); if (closePopover) { hideContextMenu(true, ReportActionComposeFocusManager.focus); } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index fdf267125da2..9bee9b67f137 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -121,6 +121,9 @@ const propTypes = { /** All the report actions belonging to the report's parent */ parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + /** Callback to be called on onPress */ + onPress: PropTypes.func, }; const defaultProps = { @@ -132,6 +135,7 @@ const defaultProps = { shouldHideThreadDividerLine: false, userWallet: {}, parentReportActions: {}, + onPress: undefined, }; function ReportActionItem(props) { @@ -700,6 +704,7 @@ function ReportActionItem(props) { return ( props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index f6d09ab76f09..15a844ab5a72 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -6,75 +6,98 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import onyxSubscribe from '@libs/onyxSubscribe'; +import * as ReportUtils from '@libs/ReportUtils'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import ReportActionItem from './ReportActionItem'; type ReportActionItemParentActionOnyxProps = { - /** The report currently being looked at */ + /** The current report is displayed */ report: OnyxEntry; - - /** The actions from the parent report */ - parentReportActions: OnyxEntry; }; type ReportActionItemParentActionProps = ReportActionItemParentActionOnyxProps & { /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine?: boolean; - /** Flag to display the new marker on top of the comment */ - shouldDisplayNewMarker: boolean; - /** Position index of the report parent action in the overall report FlatList view */ index: number; /** The id of the report */ // eslint-disable-next-line react/no-unused-prop-types reportID: string; - - /** The id of the parent report */ - // eslint-disable-next-line react/no-unused-prop-types - parentReportID: string; }; -function ReportActionItemParentAction({report, parentReportActions = {}, index = 0, shouldHideThreadDividerLine = false, shouldDisplayNewMarker}: ReportActionItemParentActionProps) { +function ReportActionItemParentAction({report, index = 0, shouldHideThreadDividerLine = false}: ReportActionItemParentActionProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const parentReportAction = parentReportActions?.[`${report?.parentReportActionID ?? ''}`] ?? null; + const ancestorIDs = useRef(ReportUtils.getAllAncestorReportActionIDs(report)); + const [allAncestors, setAllAncestors] = useState([]); + + useEffect(() => { + const unsubscribeReports: Array<() => void> = []; + const unsubscribeReportActions: Array<() => void> = []; + ancestorIDs.current.reportIDs.forEach((ancestorReportID) => { + unsubscribeReports.push( + onyxSubscribe({ + key: `${ONYXKEYS.COLLECTION.REPORT}${ancestorReportID}`, + callback: () => { + setAllAncestors(ReportUtils.getAllAncestorReportActions(report, shouldHideThreadDividerLine)); + }, + }), + ); + unsubscribeReportActions.push( + onyxSubscribe({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReportID}`, + callback: () => { + setAllAncestors(ReportUtils.getAllAncestorReportActions(report, shouldHideThreadDividerLine)); + }, + }), + ); + }); + + return () => { + unsubscribeReports.forEach((unsubscribeReport) => unsubscribeReport()); + unsubscribeReportActions.forEach((unsubscribeReportAction) => unsubscribeReportAction()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // In case of transaction threads, we do not want to render the parent report action. - if (ReportActionsUtils.isTransactionThread(parentReportAction)) { - return null; - } return ( - Report.navigateToConciergeChatAndDeleteReport(report?.reportID ?? '0')} - > - + <> + - {parentReportAction && ( - - )} + {allAncestors.map((ancestor) => ( + Report.navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} + > + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID))} + report={ancestor.report} + action={ancestor.reportAction} + displayAsGroup={false} + isMostRecentIOUReportAction={false} + shouldDisplayNewMarker={ancestor.shouldDisplayNewMarker} + index={index} + /> + {!ancestor.shouldHideThreadDividerLine && } + + ))} - {!shouldHideThreadDividerLine && } - + ); } @@ -84,8 +107,4 @@ export default withOnyx `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, }, - parentReportActions: { - key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - canEvict: false, - }, })(ReportActionItemParentAction); diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js index b28a90f496ce..3fd6ddcef750 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.js +++ b/src/pages/home/report/ReportActionsListItemRenderer.js @@ -124,8 +124,6 @@ function ReportActionsListItemRenderer({ ) : ( diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js index 23b748068b65..a5b0a5d3c151 100644 --- a/tests/unit/ReportUtilsTest.js +++ b/tests/unit/ReportUtilsTest.js @@ -605,4 +605,70 @@ describe('ReportUtils', () => { expect(ReportUtils.sortReportsByLastRead(reports)).toEqual(sortedReports); }); }); + + describe('getAllAncestorReportActions', () => { + const reports = [ + {reportID: '1', lastReadTime: '2024-02-01 04:56:47.233', reportName: 'Report'}, + {reportID: '2', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '1', parentReportID: '1', reportName: 'Report'}, + {reportID: '3', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '2', parentReportID: '2', reportName: 'Report'}, + {reportID: '4', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '3', parentReportID: '3', reportName: 'Report'}, + {reportID: '5', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '4', parentReportID: '4', reportName: 'Report'}, + ]; + + const reportActions = [ + {reportActionID: '1', created: '2024-02-01 04:42:22.965'}, + {reportActionID: '2', created: '2024-02-01 04:42:28.003'}, + {reportActionID: '3', created: '2024-02-01 04:42:31.742'}, + {reportActionID: '4', created: '2024-02-01 04:42:35.619'}, + ]; + + beforeAll(() => { + Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${reports[0].reportID}`]: reports[0], + [`${ONYXKEYS.COLLECTION.REPORT}${reports[1].reportID}`]: reports[1], + [`${ONYXKEYS.COLLECTION.REPORT}${reports[2].reportID}`]: reports[2], + [`${ONYXKEYS.COLLECTION.REPORT}${reports[3].reportID}`]: reports[3], + [`${ONYXKEYS.COLLECTION.REPORT}${reports[4].reportID}`]: reports[4], + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[0].reportID}`]: {[reportActions[0].reportActionID]: reportActions[0]}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[1].reportID}`]: {[reportActions[1].reportActionID]: reportActions[1]}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[2].reportID}`]: {[reportActions[2].reportActionID]: reportActions[2]}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[3].reportID}`]: {[reportActions[3].reportActionID]: reportActions[3]}, + }); + return waitForBatchedUpdates(); + }); + + afterAll(() => Onyx.clear()); + + it('should return correctly all ancestors of a thread report', () => { + const resultAncestors = [ + {report: reports[1], reportAction: reportActions[0], shouldDisplayNewMarker: false, shouldHideThreadDividerLine: false}, + {report: reports[2], reportAction: reportActions[1], shouldDisplayNewMarker: false, shouldHideThreadDividerLine: false}, + {report: reports[3], reportAction: reportActions[2], shouldDisplayNewMarker: false, shouldHideThreadDividerLine: false}, + {report: reports[4], reportAction: reportActions[3], shouldDisplayNewMarker: false, shouldHideThreadDividerLine: false}, + ]; + + expect(ReportUtils.getAllAncestorReportActions(reports[4], false)).toEqual(resultAncestors); + }); + + it('should hide thread divider line of the nearest ancestor if the first action of thread report is unread', () => { + const allAncestors = ReportUtils.getAllAncestorReportActions(reports[4], true); + expect(allAncestors.reverse()[0].shouldHideThreadDividerLine).toBe(true); + }); + + it('should hide thread divider line of the previous ancestor and display unread marker of the current ancestor if the current ancestor action is unread', () => { + let allAncestors = ReportUtils.getAllAncestorReportActions(reports[4], false); + expect(allAncestors[0].shouldHideThreadDividerLine).toBe(false); + expect(allAncestors[1].shouldDisplayNewMarker).toBe(false); + + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}2`, { + lastReadTime: '2024-02-01 04:42:28.001', + }) + .then(() => waitForBatchedUpdates()) + .then(() => { + allAncestors = ReportUtils.getAllAncestorReportActions(reports[4], false); + expect(allAncestors[0].shouldHideThreadDividerLine).toBe(true); + expect(allAncestors[1].shouldDisplayNewMarker).toBe(true); + }); + }); + }); });