diff --git a/__mocks__/reportData/personalDetails.ts b/__mocks__/reportData/personalDetails.ts
index b3c6179497f6c..3d22e14b23b4c 100644
--- a/__mocks__/reportData/personalDetails.ts
+++ b/__mocks__/reportData/personalDetails.ts
@@ -2,7 +2,7 @@ import type {PersonalDetailsList} from '@src/types/onyx';
const usersIDs = [15593135, 51760358, 26502375] as const;
-const personalDetails: PersonalDetailsList = {
+const personalDetails = {
[usersIDs[0]]: {
accountID: usersIDs[0],
avatar: '@assets/images/avatars/user/default-avatar_1.svg',
@@ -63,6 +63,6 @@ const personalDetails: PersonalDetailsList = {
phoneNumber: '33333333',
validated: true,
},
-};
+} satisfies PersonalDetailsList;
export default personalDetails;
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index a0fb57fff240d..688d4b9bdaf5b 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -4,6 +4,7 @@ import type {ColorValue, TextStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import useOnyx from '@hooks/useOnyx';
+import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -39,6 +40,7 @@ import {FallbackAvatar} from './Icon/Expensicons';
import MultipleAvatars from './MultipleAvatars';
import ParentNavigationSubtitle from './ParentNavigationSubtitle';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
+import SingleReportAvatar from './ReportActionItem/SingleReportAvatar';
import type {TransactionListItemType} from './SelectionList/types';
import SubscriptAvatar from './SubscriptAvatar';
import Text from './Text';
@@ -73,6 +75,9 @@ type AvatarWithDisplayNameProps = {
/** Color of the secondary avatar border, usually should match the container background */
avatarBorderColor?: ColorValue;
+
+ /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */
+ singleAvatarDetails?: ReportAvatarDetails;
};
const fallbackIcon: Icon = {
@@ -167,6 +172,7 @@ function AvatarWithDisplayName({
shouldEnableAvatarNavigation = true,
shouldUseCustomSearchTitleName = false,
transactions = [],
+ singleAvatarDetails,
openParentReportInCurrentTab = false,
avatarBorderColor: avatarBorderColorProp,
}: AvatarWithDisplayNameProps) {
@@ -236,40 +242,74 @@ function AvatarWithDisplayName({
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID));
}
}, [report, shouldEnableDetailPageNavigation, goToDetailsPage]);
+
const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous;
- const avatar = (
-
- {shouldShowSubscriptAvatar ? (
-
- ) : (
- {
+ if (shouldShowSubscriptAvatar) {
+ return (
+
+ );
+ }
+
+ if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) {
+ return (
+
+ );
+ }
+
+ return (
+
- )}
-
+ );
+ },
+ [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles],
);
+
+ const getWrappedAvatar = useCallback(
+ (accountID: number) => {
+ const avatar = getAvatar(accountID);
+
+ if (!shouldEnableAvatarNavigation) {
+ return {avatar};
+ }
+
+ return (
+
+
+ {avatar}
+
+
+ );
+ },
+ [getAvatar, shouldEnableAvatarNavigation, showActorDetails, title],
+ );
+
+ const WrappedAvatar = getWrappedAvatar(actorAccountID?.current ?? CONST.DEFAULT_NUMBER_ID);
+
const headerView = (
{!!report && !!title && (
- {shouldEnableAvatarNavigation ? (
-
- {avatar}
-
- ) : (
- avatar
- )}
+ {WrappedAvatar}
{getCustomDisplayName(
shouldUseCustomSearchTitleName,
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index 7bf69ec39b39d..8d55acf70f703 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -37,6 +37,7 @@ function HeaderWithBackButton({
report,
policy,
policyAvatar,
+ singleAvatarDetails,
shouldShowReportAvatarWithDisplay = false,
shouldShowBackButton = true,
shouldShowBorderBottom = false,
@@ -103,6 +104,7 @@ function HeaderWithBackButton({
@@ -138,6 +140,7 @@ function HeaderWithBackButton({
titleColor,
translate,
openParentReportInCurrentTab,
+ singleAvatarDetails,
]);
const ThreeDotMenuButton = useMemo(() => {
if (shouldShowThreeDotsButton) {
diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts
index 19135d36ace58..db23738a9952b 100644
--- a/src/components/HeaderWithBackButton/types.ts
+++ b/src/components/HeaderWithBackButton/types.ts
@@ -2,6 +2,7 @@ import type {ReactNode} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {PopoverMenuItem} from '@components/PopoverMenu';
+import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails';
import type {Action} from '@hooks/useSingleExecution';
import type {StepCounterParams} from '@src/languages/params';
import type {TranslationPaths} from '@src/languages/types';
@@ -161,6 +162,9 @@ type HeaderWithBackButtonProps = Partial & {
shouldMinimizeMenuButton?: boolean;
/** Whether to open the parent report link in the current tab if possible */
openParentReportInCurrentTab?: boolean;
+
+ /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */
+ singleAvatarDetails?: ReportAvatarDetails;
};
export type {ThreeDotsMenuItem};
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 35f1fc35434bf..4c652cb77b4cd 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -10,6 +10,7 @@ import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaymentAnimations from '@hooks/usePaymentAnimations';
import usePaymentOptions from '@hooks/usePaymentOptions';
+import useReportAvatarDetails from '@hooks/useReportAvatarDetails';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions';
@@ -185,6 +186,15 @@ function MoneyReportHeader({
const isExported = isExportedUtils(reportActions);
const integrationNameFromExportMessage = isExported ? getIntegrationNameFromExportMessageUtils(reportActions) : null;
+ const [reportPreviewAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, {
+ canBeMissing: true,
+ selector: (actions) => Object.entries(actions ?? {}).find(([id]) => id === moneyRequestReport?.parentReportActionID)?.[1],
+ });
+
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {
+ canBeMissing: true,
+ });
+
const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false);
const [isCancelPaymentModalVisible, setIsCancelPaymentModalVisible] = useState(false);
const [isDeleteExpenseModalVisible, setIsDeleteExpenseModalVisible] = useState(false);
@@ -220,6 +230,8 @@ function MoneyReportHeader({
[allViolations, transactionIDs],
);
+ const details = useReportAvatarDetails({report: chatReport, iouReport: moneyRequestReport, action: reportPreviewAction, policy, innerPolicies: policies, personalDetails});
+
const messagePDF = useMemo(() => {
if (!reportPDFFilename) {
return translate('reportDetailsPage.waitForPDF');
@@ -944,6 +956,7 @@ function MoneyReportHeader({
+
+
+
+
+ );
+}
+
+export default SingleReportAvatar;
diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts
index c75d2ef03ee1d..886dcf25a185e 100644
--- a/src/components/ReportActionItem/TransactionPreview/types.ts
+++ b/src/components/ReportActionItem/TransactionPreview/types.ts
@@ -105,7 +105,7 @@ type TransactionPreviewContentProps = {
/** Holds the transaction data entry from Onyx */
transaction: OnyxEntry;
- /** The original amount value on the transaction. This is used to deduce who is the sender and who is the receiver of the money request
+ /** The amount of the transaction saved in the database. This is used to deduce who is the sender and who is the receiver of the money request
* In case of Splits the property `transaction` is actually an original transaction (for the whole split) and it does not have the data required to deduce who is the sender */
transactionRawAmount: number;
diff --git a/src/hooks/useReportAvatarDetails.ts b/src/hooks/useReportAvatarDetails.ts
new file mode 100644
index 0000000000000..8815696094dad
--- /dev/null
+++ b/src/hooks/useReportAvatarDetails.ts
@@ -0,0 +1,287 @@
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import {FallbackAvatar} from '@components/Icon/Expensicons';
+import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils';
+import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
+import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
+import {
+ getDefaultWorkspaceAvatar,
+ getDisplayNameForParticipant,
+ getIcons,
+ getPolicyName,
+ getReportActionActorAccountID,
+ getWorkspaceIcon,
+ isIndividualInvoiceRoom,
+ isInvoiceReport as isInvoiceReportUtils,
+ isInvoiceRoom,
+ isPolicyExpenseChat,
+ isTripRoom as isTripRoomReportUtils,
+} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction} from '@src/types/onyx';
+import type {Icon} from '@src/types/onyx/OnyxCommon';
+import useOnyx from './useOnyx';
+
+type ReportAvatarDetails = {
+ reportPreviewSenderID: number | undefined;
+ reportPreviewAction: OnyxEntry;
+ primaryAvatar: Icon;
+ secondaryAvatar: Icon;
+ shouldDisplayAllActors: boolean;
+ displayName: string;
+ isWorkspaceActor: boolean;
+ actorHint: string;
+ fallbackIcon: string | undefined;
+};
+
+type AvatarDetailsProps = {
+ personalDetails: OnyxEntry;
+ innerPolicies: OnyxCollection;
+ policy: OnyxEntry;
+ action: OnyxEntry;
+ report: OnyxEntry;
+ iouReport?: OnyxEntry;
+ policies?: OnyxCollection;
+};
+
+function getSplitAuthor(transaction: Transaction, splits?: Array>) {
+ const {originalTransactionID, source} = transaction.comment ?? {};
+
+ if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) {
+ return undefined;
+ }
+
+ const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID);
+
+ if (!splitAction) {
+ return undefined;
+ }
+
+ return splitAction.actorAccountID;
+}
+
+function getIconDetails({
+ action,
+ report,
+ iouReport,
+ policies,
+ personalDetails,
+ reportPreviewSenderID,
+ innerPolicies,
+ policy,
+}: AvatarDetailsProps & {reportPreviewSenderID: number | undefined}) {
+ const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined;
+ const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails);
+ const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID;
+
+ const activePolicies = policies ?? innerPolicies;
+
+ const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID;
+ const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW;
+
+ const invoiceReceiverPolicy =
+ report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined;
+
+ const {avatar, login, fallbackIcon} = personalDetails?.[accountID] ?? {};
+
+ const isTripRoom = isTripRoomReportUtils(report);
+ // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses.
+ const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID;
+ const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null);
+ const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors));
+
+ const getPrimaryAvatar = () => {
+ const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails});
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const actorHint = isWorkspaceActor ? getPolicyName({report, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
+
+ const defaultAvatar = {
+ source: avatar ?? FallbackAvatar,
+ id: actorAccountID,
+ name: defaultDisplayName,
+ type: CONST.ICON_TYPE_AVATAR,
+ };
+
+ if (isWorkspaceActor) {
+ return {
+ avatar: {
+ ...defaultAvatar,
+ name: getPolicyName({report, policy}),
+ type: CONST.ICON_TYPE_WORKSPACE,
+ source: getWorkspaceIcon(report, policy).source,
+ id: report?.policyID,
+ },
+ actorHint,
+ };
+ }
+
+ if (delegatePersonalDetails) {
+ return {
+ avatar: {
+ ...defaultAvatar,
+ name: delegatePersonalDetails?.displayName ?? '',
+ source: delegatePersonalDetails?.avatar ?? FallbackAvatar,
+ id: delegatePersonalDetails?.accountID,
+ },
+ actorHint,
+ };
+ }
+
+ if (isReportPreviewAction && isTripRoom) {
+ return {
+ avatar: {
+ ...defaultAvatar,
+ name: report?.reportName ?? '',
+ source: personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar ?? FallbackAvatar,
+ id: ownerAccountID,
+ },
+ actorHint,
+ };
+ }
+
+ return {
+ avatar: defaultAvatar,
+ actorHint,
+ };
+ };
+
+ const getSecondaryAvatar = () => {
+ const defaultAvatar = {name: '', source: '', type: CONST.ICON_TYPE_AVATAR};
+
+ // If this is a report preview, display names and avatars of both people involved
+ if (displayAllActors) {
+ const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID;
+ const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar;
+ const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId});
+ const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name);
+ const isWorkspaceInvoice = isInvoiceRoom(report) && !isIndividualInvoiceRoom(report);
+
+ return isWorkspaceInvoice
+ ? {
+ source: secondaryPolicyAvatar,
+ type: CONST.ICON_TYPE_WORKSPACE,
+ name: invoiceReceiverPolicy?.name,
+ id: invoiceReceiverPolicy?.id,
+ }
+ : {
+ source: secondaryUserAvatar,
+ type: CONST.ICON_TYPE_AVATAR,
+ name: secondaryDisplayName ?? '',
+ id: secondaryAccountId,
+ };
+ }
+
+ if (!isWorkspaceActor) {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const avatarIconIndex = report?.isOwnPolicyExpenseChat || isPolicyExpenseChat(report) ? 0 : 1;
+ const reportIcons = getIcons(report, personalDetails, undefined, undefined, undefined, policy);
+
+ return reportIcons.at(avatarIconIndex) ?? defaultAvatar;
+ }
+
+ if (isInvoiceReportUtils(iouReport)) {
+ const secondaryAccountId = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID;
+ const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar;
+ const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId});
+
+ return {
+ source: secondaryUserAvatar,
+ type: CONST.ICON_TYPE_AVATAR,
+ name: secondaryDisplayName,
+ id: secondaryAccountId,
+ };
+ }
+
+ return defaultAvatar;
+ };
+
+ const {avatar: primaryAvatar, actorHint} = getPrimaryAvatar();
+
+ return {
+ primaryAvatar,
+ secondaryAvatar: getSecondaryAvatar(),
+ shouldDisplayAllActors: displayAllActors,
+ displayName: primaryAvatar.name,
+ isWorkspaceActor,
+ actorHint,
+ fallbackIcon,
+ };
+}
+
+/**
+ * This hook is used to determine the ID of the sender, as well as the avatars of the actors and some additional data, for the report preview action.
+ * It was originally based on actions; now, it uses transactions and unique emails as a fallback.
+ * For a reason why, see https://github.com/Expensify/App/pull/64802 discussion.
+ */
+function useReportAvatarDetails({iouReport, report, action, ...rest}: AvatarDetailsProps): ReportAvatarDetails {
+ const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, {
+ canBeMissing: true,
+ selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction),
+ });
+
+ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {
+ canBeMissing: true,
+ selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []),
+ });
+
+ const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {
+ canBeMissing: true,
+ selector: (actions) =>
+ Object.values(actions ?? {})
+ .filter(isMoneyRequestAction)
+ .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT),
+ });
+
+ if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) {
+ return {
+ reportPreviewSenderID: undefined,
+ reportPreviewAction: undefined,
+ ...getIconDetails({
+ ...rest,
+ action,
+ report,
+ iouReport,
+ reportPreviewSenderID: undefined,
+ }),
+ };
+ }
+
+ // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative.
+ // We have to do it this way because there can be a case when actions are not available
+ // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401
+
+ const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2;
+
+ // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list.
+ // This is a fallback added because: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310
+
+ const attendeesIDs = transactions
+ // If the transaction is a split, then attendees are not present as a property so we need to use a helper function.
+ ?.flatMap((tr) =>
+ tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)),
+ )
+ .filter((accountID) => !!accountID);
+
+ const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1;
+
+ // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account.
+ const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1;
+ const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID;
+
+ const reportPreviewSenderID = areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined;
+
+ return {
+ reportPreviewSenderID,
+ reportPreviewAction: action,
+ ...getIconDetails({
+ ...rest,
+ action,
+ report,
+ iouReport,
+ reportPreviewSenderID,
+ }),
+ };
+}
+
+export default useReportAvatarDetails;
+export type {ReportAvatarDetails};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3af5e1e8c4e6f..473e412f3201a 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -2310,7 +2310,7 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo
function isOneTransactionReport(report: OnyxEntry): boolean {
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? ([] as ReportAction[]);
const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`];
- return getOneTransactionThreadReportID(report, chatReport, reportActions) !== null;
+ return !!getOneTransactionThreadReportID(report, chatReport, reportActions);
}
/*
@@ -8776,7 +8776,7 @@ function shouldReportShowSubscript(report: OnyxEntry, isReportArchived =
return true;
}
- if (isExpenseReport(report) && isOneTransactionReport(report)) {
+ if (isExpenseReport(report)) {
return true;
}
@@ -11469,7 +11469,6 @@ export {
hasReportBeenReopened,
getMoneyReportPreviewName,
getNextApproverAccountID,
- isOneTransactionReport,
isWorkspaceTaskReport,
isWorkspaceThread,
};
diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx
index eb099cae11e9c..0a79a2e77e70e 100644
--- a/src/pages/home/report/ReportActionItemSingle.tsx
+++ b/src/pages/home/report/ReportActionItemSingle.tsx
@@ -2,18 +2,17 @@ import React, {useCallback, useMemo} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import Avatar from '@components/Avatar';
-import {FallbackAvatar} from '@components/Icon/Expensicons';
import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar';
import SubscriptAvatar from '@components/SubscriptAvatar';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
-import UserDetailsTooltip from '@components/UserDetailsTooltip';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
+import useReportAvatarDetails from '@hooks/useReportAvatarDetails';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -22,25 +21,11 @@ import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
import {getReportActionMessage} from '@libs/ReportActionsUtils';
-import {
- getDefaultWorkspaceAvatar,
- getDisplayNameForParticipant,
- getIcons,
- getPolicyName,
- getReportActionActorAccountID,
- getWorkspaceIcon,
- isIndividualInvoiceRoom,
- isInvoiceReport as isInvoiceReportUtils,
- isInvoiceRoom,
- isOptimisticPersonalDetail,
- isPolicyExpenseChat,
- isTripRoom as isTripRoomReportUtils,
-} from '@libs/ReportUtils';
+import {getReportActionActorAccountID, isOptimisticPersonalDetail} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Policy, Report, ReportAction} from '@src/types/onyx';
-import type {Icon} from '@src/types/onyx/OnyxCommon';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import ReportActionItemDate from './ReportActionItemDate';
import ReportActionItemFragment from './ReportActionItemFragment';
@@ -108,102 +93,34 @@ function ReportActionItemSingle({
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {
canBeMissing: true,
});
+
const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
canBeMissing: true,
});
- const activePolicies = policies ?? innerPolicies;
+
const policy = usePolicy(report?.policyID);
+
const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined;
- const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID;
- const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW;
const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails);
- const invoiceReceiverPolicy =
- report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined;
-
- let displayName = getDisplayNameForParticipant({accountID: actorAccountID, personalDetailsData: personalDetails});
- const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[actorAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? {};
- const accountOwnerDetails = getPersonalDetailByEmail(login ?? '');
-
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
- const isTripRoom = isTripRoomReportUtils(report);
- const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report);
- const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null);
- const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors));
-
- let avatarSource = avatar;
- let avatarId: number | string | undefined = actorAccountID;
- if (isWorkspaceActor) {
- displayName = getPolicyName({report, policy});
- actorHint = displayName;
- avatarSource = getWorkspaceIcon(report, policy).source;
- avatarId = report?.policyID;
- } else if (delegatePersonalDetails) {
- displayName = delegatePersonalDetails?.displayName ?? '';
- avatarSource = delegatePersonalDetails?.avatar;
- avatarId = delegatePersonalDetails?.accountID;
- } else if (isReportPreviewAction && isTripRoom) {
- displayName = report?.reportName ?? '';
- avatarSource = personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar;
- avatarId = ownerAccountID;
- }
-
- // If this is a report preview, display names and avatars of both people involved
- let secondaryAvatar: Icon;
- const primaryDisplayName = displayName;
- if (displayAllActors) {
- if (isInvoiceRoom(report) && !isIndividualInvoiceRoom(report)) {
- const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name);
-
- secondaryAvatar = {
- source: secondaryPolicyAvatar,
- type: CONST.ICON_TYPE_WORKSPACE,
- name: invoiceReceiverPolicy?.name,
- id: invoiceReceiverPolicy?.id,
- };
- } else {
- // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice
- const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID;
- const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar;
- const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId});
-
- secondaryAvatar = {
- source: secondaryUserAvatar,
- type: CONST.ICON_TYPE_AVATAR,
- name: secondaryDisplayName ?? '',
- id: secondaryAccountId,
- };
- }
- } else if (!isWorkspaceActor) {
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const avatarIconIndex = report?.isOwnPolicyExpenseChat || isPolicyExpenseChat(report) ? 0 : 1;
- const reportIcons = getIcons(report, personalDetails, undefined, undefined, undefined, policy);
- secondaryAvatar = reportIcons.at(avatarIconIndex) ?? {name: '', source: '', type: CONST.ICON_TYPE_AVATAR};
- } else if (isInvoiceReportUtils(iouReport)) {
- const secondaryAccountId = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID;
- const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar;
- const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId});
+ const reportPreviewDetails = useReportAvatarDetails({
+ action,
+ report,
+ iouReport,
+ policies,
+ personalDetails,
+ innerPolicies,
+ policy,
+ });
- secondaryAvatar = {
- source: secondaryUserAvatar,
- type: CONST.ICON_TYPE_AVATAR,
- name: secondaryDisplayName,
- id: secondaryAccountId,
- };
- } else {
- secondaryAvatar = {name: '', source: '', type: 'avatar'};
- }
- const icon = {
- source: avatarSource ?? FallbackAvatar,
- type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR,
- name: primaryDisplayName ?? '',
- id: avatarId,
- };
+ const {primaryAvatar, secondaryAvatar, displayName, shouldDisplayAllActors, isWorkspaceActor, reportPreviewSenderID, actorHint} = reportPreviewDetails;
+ const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID;
- const showMultipleUserAvatarPattern = displayAllActors && !shouldShowSubscriptAvatar;
+ const {login, pendingFields, status} = personalDetails?.[accountID] ?? {};
+ const accountOwnerDetails = getPersonalDetailByEmail(login ?? '');
- const headingText = showMultipleUserAvatarPattern ? `${icon.name} & ${secondaryAvatar.name}` : displayName;
+ const showMultipleUserAvatarPattern = shouldDisplayAllActors && !shouldShowSubscriptAvatar;
+ const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : displayName;
// Since the display name for a report action message is delivered with the report history as an array of fragments
// we'll need to take the displayName from personal details and have it be in the same format for now. Eventually,
@@ -225,13 +142,13 @@ function ReportActionItemSingle({
showWorkspaceDetails(reportID);
} else {
// Show participants page IOU report preview
- if (iouReportID && displayAllActors) {
+ if (iouReportID && shouldDisplayAllActors) {
Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID, Navigation.getReportRHPActiveRoute()));
return;
}
- showUserDetails(Number(icon.id));
+ showUserDetails(Number(primaryAvatar.id));
}
- }, [isWorkspaceActor, reportID, iouReportID, displayAllActors, icon.id]);
+ }, [isWorkspaceActor, reportID, iouReportID, shouldDisplayAllActors, primaryAvatar.id]);
const shouldDisableDetailPage = useMemo(
() =>
@@ -249,48 +166,40 @@ function ReportActionItemSingle({
}
return theme.sidebar;
};
+
const getAvatar = () => {
if (shouldShowSubscriptAvatar) {
return (
);
}
- if (displayAllActors) {
+ if (shouldDisplayAllActors) {
return (
);
}
+
return (
-
-
-
-
-
+
);
};
- const hasEmojiStatus = !displayAllActors && status?.emojiCode;
+ const hasEmojiStatus = !shouldDisplayAllActors && status?.emojiCode;
const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? '');
const statusText = status?.text ?? '';
const statusTooltipText = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText;
@@ -324,11 +233,11 @@ function ReportActionItemSingle({
diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts
index d57e76f03ffcc..8f3a2b713641e 100644
--- a/tests/actions/EnforceActionExportRestrictions.ts
+++ b/tests/actions/EnforceActionExportRestrictions.ts
@@ -18,11 +18,10 @@ describe('ReportUtils', () => {
expect(ReportUtils.getReport).toBeUndefined();
});
- // TODO: Re-enable this test when isOneTransactionReport is fixed https://github.com/Expensify/App/issues/64333
- // it('does not export isOneTransactionReport', () => {
- // // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal
- // expect(ReportUtils.isOneTransactionReport).toBeUndefined();
- // });
+ it('does not export isOneTransactionReport', () => {
+ // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal
+ expect(ReportUtils.isOneTransactionReport).toBeUndefined();
+ });
it('does not export getPolicy', () => {
// @ts-expect-error the test is asserting that it's undefined, so the TS error is normal
diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts
index b02f56b38d51f..9b32ec46634c1 100644
--- a/tests/unit/ReportActionItemSingleTest.ts
+++ b/tests/unit/ReportActionItemSingleTest.ts
@@ -1,22 +1,13 @@
import {screen, waitFor} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList} from '@src/types/onyx';
import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
-const ONYXKEYS = {
- PERSONAL_DETAILS_LIST: 'personalDetailsList',
- IS_LOADING_REPORT_DATA: 'isLoadingReportData',
- COLLECTION: {
- REPORT_ACTIONS: 'reportActions_',
- POLICY: 'policy_',
- },
- NETWORK: 'network',
-} as const;
-
describe('ReportActionItemSingle', () => {
beforeAll(() =>
Onyx.init({
diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts
new file mode 100644
index 0000000000000..05c35d3177335
--- /dev/null
+++ b/tests/unit/ReportUtilsGetIconsTest.ts
@@ -0,0 +1,529 @@
+import type {OnyxCollection} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import {getOneTransactionThreadReportID} from '@libs/ReportActionsUtils';
+import {
+ getIcons,
+ isAdminRoom,
+ isAnnounceRoom,
+ isChatReport,
+ isChatRoom,
+ isChatThread,
+ isExpenseReport,
+ isExpenseRequest,
+ isGroupChat,
+ isIndividualInvoiceRoom,
+ isInvoiceReport,
+ isInvoiceRoom,
+ isIOUReport,
+ isMoneyRequestReport,
+ isPolicyExpenseChat,
+ isSelfDM,
+ isTaskReport,
+ isThread,
+ isWorkspaceTaskReport,
+ isWorkspaceThread,
+} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report, ReportAction} from '@src/types/onyx';
+import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions';
+import * as LHNTestUtils from '../utils/LHNTestUtils';
+
+const FAKE_PERSONAL_DETAILS = LHNTestUtils.fakePersonalDetails;
+/* eslint-disable @typescript-eslint/naming-convention */
+const FAKE_REPORT_ACTIONS: OnyxCollection> = {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {
+ '1': {...actionR14932, actorAccountID: 2},
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: {
+ '2': {...actionR98765, actorAccountID: 1},
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: {
+ '2': {...actionR98765, actorAccountID: 1},
+ },
+ // For workspace thread test - parent report actions
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}workspaceParent`]: {
+ '1': {...actionR14932, actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT},
+ },
+ // For multi-transaction IOU test - multiple transactions
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}multiTxn`]: {
+ '1': {...actionR14932, actorAccountID: 1},
+ '2': {...actionR98765, actorAccountID: 1},
+ },
+};
+/* eslint-enable @typescript-eslint/naming-convention */
+const FAKE_REPORTS = {
+ [`${ONYXKEYS.COLLECTION.REPORT}1`]: {
+ ...LHNTestUtils.getFakeReport([1, 2], 0, true),
+ invoiceReceiver: {
+ type: CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL,
+ accountID: 3,
+ },
+ },
+ // This is the parent report for the expense request test.
+ // It MUST have type: 'expense' for the isExpenseRequest() check to pass.
+ [`${ONYXKEYS.COLLECTION.REPORT}2`]: {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ reportID: '2',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ },
+
+ // This is the parent report for the expense request test.
+ // It MUST have type: 'expense' for the isExpenseRequest() check to pass.
+ [`${ONYXKEYS.COLLECTION.REPORT}3`]: {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ reportID: '3',
+ parentReportID: '2',
+ parentReportActionID: '2',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ },
+ // Parent workspace chat for workspace thread test
+ [`${ONYXKEYS.COLLECTION.REPORT}workspaceParent`]: {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ reportID: 'workspaceParent',
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: '1',
+ },
+ // Parent policy expense chat for workspace task test
+ [`${ONYXKEYS.COLLECTION.REPORT}taskParent`]: {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ reportID: 'taskParent',
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: '1',
+ },
+ // Chat report for multi-transaction IOU test
+ [`${ONYXKEYS.COLLECTION.REPORT}chatReport`]: {
+ ...LHNTestUtils.getFakeReport([1, 2], 0, true),
+ reportID: 'chatReport',
+ },
+};
+const FAKE_POLICIES = {
+ [`${ONYXKEYS.COLLECTION.POLICY}1`]: LHNTestUtils.getFakePolicy('1'),
+};
+
+const currentUserAccountID = 5;
+
+beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ initialKeyStates: {
+ [ONYXKEYS.SESSION]: {email: FAKE_PERSONAL_DETAILS[currentUserAccountID]?.login, accountID: currentUserAccountID},
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: FAKE_PERSONAL_DETAILS,
+ ...FAKE_REPORT_ACTIONS,
+ ...FAKE_REPORTS,
+ ...FAKE_POLICIES,
+ },
+ });
+ // @ts-expect-error Until we add NVP_PRIVATE_DOMAINS to ONYXKEYS, we need to mock it here
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ Onyx.connect({key: ONYXKEYS.NVP_PRIVATE_DOMAINS, callback: () => {}});
+});
+
+describe('getIcons', () => {
+ it('should return a fallback icon if the report is empty', () => {
+ const report = {} as Report;
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ });
+
+ it('should return the correct icons for an expense request', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ parentReportID: '3',
+ parentReportActionID: '2',
+ type: CONST.REPORT.TYPE.IOU,
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isExpenseRequest(report)).toBe(true);
+ expect(isThread(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.name).toBe('Email One');
+ });
+
+ it('should return the correct icons for a chat thread', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ parentReportID: '1',
+ parentReportActionID: '1',
+ };
+
+ // Verify report type conditions
+ expect(isChatThread(report)).toBe(true);
+ expect(isThread(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Email\u00A0Two');
+ });
+
+ it('should return the correct icons for a task report', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ type: CONST.REPORT.TYPE.TASK,
+ ownerAccountID: 1,
+ };
+
+ // Verify report type conditions
+ expect(isTaskReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Email One');
+ });
+
+ it('should return the correct icons for a domain room', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL,
+ reportName: '#domain-test',
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('domain-test');
+ });
+
+ it('should return the correct icons for a policy room', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Workspace-Test-001');
+ });
+
+ it('should return the correct icons for a policy expense chat', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isPolicyExpenseChat(report)).toBe(true);
+ expect(isChatReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_AVATAR);
+ });
+
+ it('should return the correct icons for an expense report', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ type: CONST.REPORT.TYPE.EXPENSE,
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isExpenseReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ });
+
+ it('should return the correct icons for an IOU report with one transaction', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ reportID: '1',
+ type: CONST.REPORT.TYPE.IOU,
+ ownerAccountID: 1,
+ managerID: 2,
+ };
+
+ // Verify report type conditions
+ expect(isIOUReport(report)).toBe(true);
+ expect(isMoneyRequestReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Email One');
+ });
+
+ it('should return the correct icons for a Self DM', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([currentUserAccountID], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.SELF_DM,
+ };
+
+ // Verify report type conditions
+ expect(isSelfDM(report)).toBe(true);
+ expect(isChatReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Email Five');
+ });
+
+ it('should return the correct icons for a system chat', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.SYSTEM,
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.id).toBe(CONST.ACCOUNT_ID.NOTIFICATIONS);
+ });
+
+ it('should return the correct icons for a group chat', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.GROUP,
+ };
+
+ // Verify report type conditions
+ expect(isGroupChat(report)).toBe(true);
+ expect(isChatReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ });
+
+ it('should return the correct icons for a group chat without an avatar', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.GROUP,
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ });
+
+ it('should return the correct icons for a group chat with an avatar', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.GROUP,
+ avatarUrl: 'https://example.com/avatar.png',
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ });
+
+ it('should return the correct icons for an invoice report', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ type: CONST.REPORT.TYPE.INVOICE,
+ chatReportID: '1',
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isInvoiceReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.name).toBe('Email Three');
+ });
+
+ it('should return all participant icons for a one-on-one chat', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.name).toBe('Email One');
+ });
+
+ it('should return all participant icons as a fallback', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1, 2, 3, 4], 0, true),
+ type: undefined,
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(4);
+ });
+
+ it('should return the correct icons for a workspace thread', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ parentReportID: 'workspaceParent',
+ parentReportActionID: '1',
+ policyID: '1',
+ type: CONST.REPORT.TYPE.CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isChatThread(report)).toBe(true);
+ expect(isWorkspaceThread(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+
+ expect(icons).toHaveLength(2); // Actor + workspace icon
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ });
+
+ it('should return the correct icons for a workspace task report', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1], 0, true),
+ type: CONST.REPORT.TYPE.TASK,
+ ownerAccountID: 1,
+ parentReportID: 'taskParent',
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isTaskReport(report)).toBe(true);
+ expect(isWorkspaceTaskReport(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+
+ expect(icons).toHaveLength(2); // Owner + workspace icon
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ });
+
+ it('should return the correct icons for an admin room', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isAdminRoom(report)).toBe(true);
+ expect(isChatRoom(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ });
+
+ it('should return the correct icons for an announce room', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
+ policyID: '1',
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isAnnounceRoom(report)).toBe(true);
+ expect(isChatRoom(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ });
+
+ it('should return the correct icons for an invoice room with individual receiver', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.INVOICE,
+ policyID: '1',
+ invoiceReceiver: {
+ type: CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL,
+ accountID: 2,
+ },
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+
+ // Verify report type conditions
+ expect(isInvoiceRoom(report)).toBe(true);
+ expect(isIndividualInvoiceRoom(report)).toBe(true);
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2); // Workspace + individual receiver
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_AVATAR);
+ });
+
+ it('should return the correct icons for an invoice room with business receiver', () => {
+ const receiverPolicy = LHNTestUtils.getFakePolicy('2', 'Receiver-Policy');
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ chatType: CONST.REPORT.CHAT_TYPE.INVOICE,
+ policyID: '1',
+ invoiceReceiver: {
+ type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS,
+ policyID: '2',
+ },
+ };
+ const policy = LHNTestUtils.getFakePolicy('1');
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy, receiverPolicy);
+ expect(icons).toHaveLength(2); // Workspace + receiver workspace
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.name).toBe('Receiver-Policy');
+ });
+
+ it('should return the correct icons for a multi-transaction IOU report where current user is not manager', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([1, 2], 0, true),
+ reportID: 'multiTxn',
+ chatReportID: 'chatReport',
+ type: CONST.REPORT.TYPE.IOU,
+ ownerAccountID: 1,
+ managerID: 2, // Different from current user (5)
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ participants: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
+ },
+ };
+
+ const reportActions = FAKE_REPORT_ACTIONS?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`];
+ const chatReport = FAKE_REPORTS?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`];
+
+ // Verify report type conditions
+ expect(isIOUReport(report)).toBe(true);
+ expect(isMoneyRequestReport(report)).toBe(true);
+ expect(getOneTransactionThreadReportID(report, chatReport, reportActions)).toBeFalsy();
+
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.name).toBe('Email\u0020One');
+ expect(icons.at(1)?.name).toBe('Email\u0020Two');
+ });
+
+ it('should return the correct icons for a concierge chat', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([CONST.ACCOUNT_ID.CONCIERGE], 0, true),
+ participants: {
+ [CONST.ACCOUNT_ID.CONCIERGE]: {
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ },
+ },
+ };
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS);
+ expect(icons).toHaveLength(1);
+ expect(icons.at(0)?.id).toBe(CONST.ACCOUNT_ID.CONCIERGE);
+ });
+
+ it('should return the correct icons for an invoice report with individual receiver', () => {
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([], 0, true),
+ type: CONST.REPORT.TYPE.INVOICE,
+ chatReportID: '1',
+ policyID: '1',
+ };
+
+ // Verify report type conditions
+ expect(isInvoiceReport(report)).toBe(true);
+
+ const policy = LHNTestUtils.getFakePolicy('1');
+ const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy);
+ expect(icons).toHaveLength(2);
+ expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE);
+ expect(icons.at(1)?.name).toBe('Email Three');
+ });
+});
diff --git a/tests/unit/useReportAvatarDetailsTest.ts b/tests/unit/useReportAvatarDetailsTest.ts
new file mode 100644
index 0000000000000..4aa68fb0bfafa
--- /dev/null
+++ b/tests/unit/useReportAvatarDetailsTest.ts
@@ -0,0 +1,169 @@
+import {renderHook} from '@testing-library/react-native';
+import Onyx from 'react-native-onyx';
+import useReportAvatarDetails from '@hooks/useReportAvatarDetails';
+import CONST from '@src/CONST';
+import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
+import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions';
+import personalDetails from '../../__mocks__/reportData/personalDetails';
+import {policy420A} from '../../__mocks__/reportData/policies';
+import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports';
+import {transactionR14932} from '../../__mocks__/reportData/transactions';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+import PropertyKeysOf = jest.PropertyKeysOf;
+
+const reportActions = [{[actionR14932.reportActionID]: actionR14932}];
+const transactions = [transactionR14932];
+
+const transactionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.TRANSACTION, transactions, (transaction) => transaction.transactionID);
+const reportActionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT_ACTIONS, reportActions, (actions) => Object.values(actions).at(0)?.childReportID);
+
+const validAction = {
+ ...actionR98765,
+ childReportID: iouReportR14932.reportID,
+ actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
+ childOwnerAccountID: iouReportR14932.ownerAccountID,
+ childManagerAccountID: iouReportR14932.managerID,
+};
+
+describe('useReportAvatarDetails', () => {
+ const mockedOwnerAccountID = 15593135;
+ const mockedOwnerAccountAvatar = personalDetails[mockedOwnerAccountID].avatar;
+
+ const mockedManagerAccountID = 51760358;
+ const mockedManagerAccountAvatar = personalDetails[mockedManagerAccountID].avatar;
+ const mockedDMChatRoom = {...chatReportR14932, chatType: undefined};
+
+ const policiesMock = {
+ personalDetails,
+ policies: {
+ [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A,
+ },
+ innerPolicies: {
+ [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A,
+ },
+ policy: policy420A,
+ };
+
+ const mockedEmailToID: Record> = {
+ [personalDetails[15593135].login]: 15593135,
+ [personalDetails[51760358].login]: 51760358,
+ };
+
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ jest.spyOn(PersonalDetailsUtils, 'getPersonalDetailByEmail').mockImplementation((email) => personalDetails[mockedEmailToID[email]]);
+ });
+
+ beforeEach(() => {
+ Onyx.multiSet({
+ ...reportActionCollectionDataSet,
+ ...transactionCollectionDataSet,
+ });
+ return waitForBatchedUpdates();
+ });
+
+ afterEach(() => {
+ Onyx.clear();
+ return waitForBatchedUpdates();
+ });
+
+ it('returns avatar with no reportPreviewSenderID when action is not a report preview', () => {
+ const {result} = renderHook(() =>
+ useReportAvatarDetails({
+ action: actionR14932,
+ iouReport: iouReportR14932,
+ report: mockedDMChatRoom,
+ ...policiesMock,
+ }),
+ );
+
+ expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar);
+ expect(result.current.secondaryAvatar.source).toBeFalsy();
+ expect(result.current.reportPreviewSenderID).toBeUndefined();
+ });
+
+ it('returns childManagerAccountID and his avatar when all conditions are met for Send Money flow', () => {
+ const {result} = renderHook(() =>
+ useReportAvatarDetails({
+ action: {...validAction, childMoneyRequestCount: 0},
+ iouReport: iouReportR14932,
+ report: mockedDMChatRoom,
+ ...policiesMock,
+ }),
+ );
+
+ expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar);
+ expect(result.current.secondaryAvatar.source).toBeFalsy();
+ expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID);
+ });
+
+ it('returns both avatars & no reportPreviewSenderID when there are multiple attendees', async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, {
+ ...transactionR14932,
+ comment: {
+ attendees: [{email: personalDetails[15593135].login, displayName: 'Test One', avatarUrl: 'https://none.com/none'}],
+ },
+ });
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, {
+ ...transactionR14932,
+ comment: {
+ attendees: [{email: personalDetails[51760358].login, displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}],
+ },
+ });
+ const {result} = renderHook(() =>
+ useReportAvatarDetails({
+ action: validAction,
+ iouReport: iouReportR14932,
+ report: mockedDMChatRoom,
+ ...policiesMock,
+ }),
+ );
+
+ expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar);
+ expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar);
+ expect(result.current.reportPreviewSenderID).toBeUndefined();
+ });
+
+ it('returns both avatars & no reportPreviewSenderID when amounts have different signs', async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, {
+ ...transactionR14932,
+ amount: 100,
+ });
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, {
+ ...transactionR14932,
+ amount: -100,
+ });
+ const {result} = renderHook(() =>
+ useReportAvatarDetails({
+ action: validAction,
+ iouReport: iouReportR14932,
+ report: mockedDMChatRoom,
+ ...policiesMock,
+ }),
+ );
+
+ expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar);
+ expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar);
+ expect(result.current.reportPreviewSenderID).toBeUndefined();
+ });
+
+ it('returns childOwnerAccountID as reportPreviewSenderID and a single avatar when all conditions are met', () => {
+ const {result} = renderHook(() =>
+ useReportAvatarDetails({
+ action: validAction,
+ iouReport: iouReportR14932,
+ report: mockedDMChatRoom,
+ ...policiesMock,
+ }),
+ );
+
+ expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar);
+ expect(result.current.secondaryAvatar.source).toBeFalsy();
+ expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID);
+ });
+});