From f3f2dba926f72dfadac5fbe11a2d424c3298e0c4 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 14 Jul 2025 13:50:32 +0200 Subject: [PATCH 01/33] Merge usages of subscript & multiple avatars --- src/components/AvatarWithDisplayName.tsx | 37 ++++++--------- .../LHNOptionsList/OptionRowLHN.tsx | 40 +++++++--------- src/components/MenuItem.tsx | 46 ++++++++----------- src/components/MultipleAvatars.tsx | 43 ++++++++++++++++- src/components/OptionRow.tsx | 30 +++++------- .../SelectionList/InviteMemberListItem.tsx | 36 +++++++-------- .../Search/CardListItemHeader.tsx | 15 +++--- src/components/SelectionList/UserListItem.tsx | 36 +++++++-------- src/components/SubscriptAvatar.tsx | 5 +- src/pages/home/HeaderView.tsx | 24 +++++----- .../home/report/ReportActionItemSingle.tsx | 38 +++++++-------- 11 files changed, 177 insertions(+), 173 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 1833077975b12..de4b0f00ccc45 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -42,7 +42,6 @@ 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'; type AvatarWithDisplayNameProps = { @@ -246,33 +245,27 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; const getAvatar = useCallback(() => { - if (shouldShowSubscriptAvatar) { + if (!!singleAvatarDetails?.reportPreviewSenderID && !singleAvatarDetails.shouldDisplayAllActors && !shouldShowSubscriptAvatar) { return ( - - ); - } - - if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) { - return ( - ); } return ( - ); }, [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles]); diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 802a884a9d457..f4272a8292fd3 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -11,7 +11,6 @@ import {useSession} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import type {ProductTrainingTooltipName} from '@components/ProductTrainingContext/TOOLTIPS'; -import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; @@ -260,28 +259,23 @@ function OptionRowLHN({ > - {!!optionItem.icons?.length && - firstIcon && - (optionItem.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!optionItem.icons?.length && !!firstIcon && ( + + )} )} - {shouldShowAvatar && !shouldShowSubscriptAvatar && ( + {shouldShowAvatar && ( )} - {shouldShowAvatar && shouldShowSubscriptAvatar && ( - - )} {!icon && shouldPutLeftPaddingWhenNoIcon && ( 0 && !!firstRightIcon && ( - {shouldShowSubscriptRightAvatar ? ( - - ) : ( - - )} + )} {!!brickRoadIndicator && ( diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index 10e399e5c09ea..c1521d01dfe98 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -1,5 +1,5 @@ import React, {memo, useMemo} from 'react'; -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -11,10 +11,29 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; +import SubscriptAvatar from './SubscriptAvatar'; +import type {SubIcon} from './SubscriptAvatar'; import Text from './Text'; import Tooltip from './Tooltip'; import UserDetailsTooltip from './UserDetailsTooltip'; +type Subscript = { + /** Whether to show the subscript avatar */ + shouldShow: boolean; + + /** Border color for the subscript avatar */ + borderColor?: ColorValue; + + /** Whether to show the subscript avatar without margin */ + noMargin?: boolean; + + /** Subscript icon to display */ + subIcon?: SubIcon; + + /** A fallback main avatar icon */ + fallbackIcon?: Icon; +}; + type MultipleAvatarsProps = { /** Array of avatar URLs or icons */ icons: Icon[]; @@ -60,6 +79,9 @@ type MultipleAvatarsProps = { /** Prop to limit the amount of avatars displayed horizontally */ overlapDivider?: number; + + /** Subscript avatar properties */ + subscript?: Subscript; }; type AvatarStyles = { @@ -87,6 +109,7 @@ function MultipleAvatars({ shouldUseCardBackground = false, maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, overlapDivider = 3, + subscript, }: MultipleAvatarsProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -145,6 +168,24 @@ function MultipleAvatars({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); + const subscriptMainAvatar = icons.at(0) ?? subscript?.fallbackIcon; + + if (!!subscript?.shouldShow && subscriptMainAvatar) { + const {borderColor, noMargin, subIcon} = subscript; + + return ( + + ); + } + if (!icons.length) { return null; } diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 872fbf01f50a9..587f1234e4248 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -20,7 +20,6 @@ import MultipleAvatars from './MultipleAvatars'; import OfflineWithFeedback from './OfflineWithFeedback'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; import SelectCircle from './SelectCircle'; -import SubscriptAvatar from './SubscriptAvatar'; import Text from './Text'; type OptionRowProps = { @@ -207,23 +206,18 @@ function OptionRow({ > - {!!option.icons?.length && - firstIcon && - (option.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!option.icons?.length && !!firstIcon && ( + + )} ({ wrapperStyle={styles.productTrainingTooltipWrapper} > - {!!item.icons && - (item.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!item.icons && ( + + )} ({card: cardItem, onCheckboxP /> )} - ({ )} - {!!item.icons && - (item.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!item.icons && ( + + )} + ); + const multipleAvatars = ( + + ); + return ( <> - {shouldShowSubscript ? ( - - ) : ( - - - - )} + {shouldShowSubscript ? multipleAvatars : {multipleAvatars}} { - if (shouldShowSubscriptAvatar) { + if (!shouldDisplayAllActors && !shouldShowSubscriptAvatar) { return ( - - ); - } - if (shouldDisplayAllActors) { - return ( - ); } return ( - ); }; From 84f3fd201d251c129206703a516e87331809500a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 14 Jul 2025 15:30:04 +0200 Subject: [PATCH 02/33] Merge usages of single & multiple avatars --- src/components/AvatarWithDisplayName.tsx | 90 +++++++++---------- src/components/MultipleAvatars.tsx | 36 ++++++++ .../ReportActionItem/SingleReportAvatar.tsx | 2 +- .../home/report/ReportActionItemSingle.tsx | 49 +++++----- 4 files changed, 98 insertions(+), 79 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index de4b0f00ccc45..cef7c73f8a491 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {ColorValue, TextStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -40,7 +40,6 @@ 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 Text from './Text'; @@ -244,59 +243,52 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const getAvatar = useCallback(() => { - if (!!singleAvatarDetails?.reportPreviewSenderID && !singleAvatarDetails.shouldDisplayAllActors && !shouldShowSubscriptAvatar) { - return ( - - ); - } - - return ( - - ); - }, [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles]); - - const getWrappedAvatar = useCallback(() => { - const avatar = getAvatar(); - - if (!shouldEnableAvatarNavigation) { - return {avatar}; - } + const multipleAvatarDetails = useMemo( + () => ({ + singleReportAvatar: { + shouldShow: !!singleAvatarDetails?.reportPreviewSenderID && !singleAvatarDetails.shouldDisplayAllActors && !shouldShowSubscriptAvatar, + personalDetails, + reportPreviewDetails: singleAvatarDetails, + containerStyles: [styles.actionAvatar, styles.mr3], + actorAccountID: singleAvatarDetails?.reportPreviewSenderID, + }, + subscript: { + shouldShow: shouldShowSubscriptAvatar, + borderColor: avatarBorderColor, + fallbackIcon, + }, + }), + [avatarBorderColor, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, styles], + ); - return ( - - - {avatar} - - - ); - }, [getAvatar, shouldEnableAvatarNavigation, showActorDetails, title]); - - const WrappedAvatar = getWrappedAvatar(); + const multipleAvatars = ( + + ); const headerView = ( {!!report && !!title && ( - {WrappedAvatar} + + {shouldEnableAvatarNavigation ? ( + + {multipleAvatars} + + ) : ( + multipleAvatars + )} + + {getCustomDisplayName( shouldUseCustomSearchTitleName, diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index c1521d01dfe98..fb7491e3b0cfb 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -2,6 +2,7 @@ import React, {memo, useMemo} from 'react'; import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -9,8 +10,10 @@ import {getUserDetailTooltipText} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {PersonalDetailsList} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; +import SingleReportAvatar from './ReportActionItem/SingleReportAvatar'; import SubscriptAvatar from './SubscriptAvatar'; import type {SubIcon} from './SubscriptAvatar'; import Text from './Text'; @@ -34,6 +37,23 @@ type Subscript = { fallbackIcon?: Icon; }; +type SingleAvatar = { + /** Whether to show the single report avatar */ + shouldShow: boolean; + + /** Details for the report avatar */ + reportPreviewDetails: ReportAvatarDetails | undefined; + + /** Personal details for the report avatar */ + personalDetails: PersonalDetailsList | undefined; + + /** Styles for the container */ + containerStyles: ViewStyle[]; + + /** The account ID of the actor */ + actorAccountID: number | null | undefined; +}; + type MultipleAvatarsProps = { /** Array of avatar URLs or icons */ icons: Icon[]; @@ -82,6 +102,8 @@ type MultipleAvatarsProps = { /** Subscript avatar properties */ subscript?: Subscript; + + singleReportAvatar?: SingleAvatar; }; type AvatarStyles = { @@ -110,6 +132,7 @@ function MultipleAvatars({ maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, overlapDivider = 3, subscript, + singleReportAvatar, }: MultipleAvatarsProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -170,6 +193,19 @@ function MultipleAvatars({ const subscriptMainAvatar = icons.at(0) ?? subscript?.fallbackIcon; + if (!!singleReportAvatar?.shouldShow && !!singleReportAvatar.reportPreviewDetails) { + const {reportPreviewDetails, personalDetails, containerStyles, actorAccountID} = singleReportAvatar; + + return ( + + ); + } + if (!!subscript?.shouldShow && subscriptMainAvatar) { const {borderColor, noMargin, subIcon} = subscript; diff --git a/src/components/ReportActionItem/SingleReportAvatar.tsx b/src/components/ReportActionItem/SingleReportAvatar.tsx index 9a7c25d6808b3..71f0caa81c140 100644 --- a/src/components/ReportActionItem/SingleReportAvatar.tsx +++ b/src/components/ReportActionItem/SingleReportAvatar.tsx @@ -15,7 +15,7 @@ function SingleReportAvatar({ }: { reportPreviewDetails: ReportAvatarDetails; personalDetails: PersonalDetailsList | undefined; - containerStyles: ViewStyle[]; + containerStyles?: ViewStyle[]; actorAccountID: number | null | undefined; }) { const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 33ef0b69892ee..a260d19d427a5 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -5,7 +5,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; @@ -166,33 +165,6 @@ function ReportActionItemSingle({ return theme.sidebar; }; - const getAvatar = () => { - if (!shouldDisplayAllActors && !shouldShowSubscriptAvatar) { - return ( - - ); - } - - return ( - - ); - }; - const hasEmojiStatus = !shouldDisplayAllActors && status?.emojiCode; const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? ''); const statusText = status?.text ?? ''; @@ -209,7 +181,26 @@ function ReportActionItemSingle({ accessibilityLabel={actorHint} role={CONST.ROLE.BUTTON} > - {getAvatar()} + + + {showHeader ? ( From 1445300da6d9149d165954f1a5bc4605b64a89c3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 14 Jul 2025 15:56:29 +0200 Subject: [PATCH 03/33] Fix avatars in LHN --- .../LHNOptionsList/OptionRowLHN.tsx | 24 +++++++++++++++++++ src/components/MultipleAvatars.tsx | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index a497651f3127f..3ae2332e52170 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -15,6 +15,8 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -42,6 +44,7 @@ import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OptionRowLHNProps} from './types'; @@ -99,6 +102,11 @@ function OptionRowLHN({ const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten( @@ -112,6 +120,15 @@ function OptionRowLHN({ [optionItem?.alternateText], ); + const details = useReportAvatarDetails({ + action: optionItem?.parentReportAction, + report: chatReport, + iouReport: report, + innerPolicies: policies, + personalDetails, + policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${optionItem?.policyID}`], + }); + if (!optionItem && !isOptionFocused) { // rendering null as a render item causes the FlashList to render all // its children and consume significant memory on the first render. We can avoid this by @@ -273,6 +290,13 @@ function OptionRowLHN({ isOptionFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !isOptionFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} + singleReportAvatar={{ + shouldShow: !!details.reportPreviewSenderID && !optionItem.shouldShowSubscript, + personalDetails, + reportPreviewDetails: details, + actorAccountID: optionItem.accountID, + containerStyles: [styles.actionAvatar, styles.mr3], + }} shouldShowTooltip={shouldOptionShowTooltip(optionItem)} /> )} diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index fb7491e3b0cfb..63aee984bcab3 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -48,7 +48,7 @@ type SingleAvatar = { personalDetails: PersonalDetailsList | undefined; /** Styles for the container */ - containerStyles: ViewStyle[]; + containerStyles?: ViewStyle[]; /** The account ID of the actor */ actorAccountID: number | null | undefined; From e58d9a1a2a5d44d47389672116ab735672cfd0a6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 15 Jul 2025 16:22:26 +0200 Subject: [PATCH 04/33] Fix avatars in Details panel --- src/components/MultipleAvatars.tsx | 12 ++- .../ReportActionItem/SingleReportAvatar.tsx | 4 + src/components/SubscriptAvatar.tsx | 18 +++- src/pages/ReportDetailsPage.tsx | 88 ++++++++++++------- src/styles/index.ts | 5 ++ src/styles/utils/index.ts | 4 + 6 files changed, 92 insertions(+), 39 deletions(-) diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index 63aee984bcab3..21de6ff700215 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -35,6 +35,9 @@ type Subscript = { /** A fallback main avatar icon */ fallbackIcon?: Icon; + + /** Size of the secondary avatar */ + secondaryAvatarSize?: ValueOf; }; type SingleAvatar = { @@ -52,6 +55,9 @@ type SingleAvatar = { /** The account ID of the actor */ actorAccountID: number | null | undefined; + + /** Size of the avatar */ + size?: ValueOf; }; type MultipleAvatarsProps = { @@ -194,7 +200,7 @@ function MultipleAvatars({ const subscriptMainAvatar = icons.at(0) ?? subscript?.fallbackIcon; if (!!singleReportAvatar?.shouldShow && !!singleReportAvatar.reportPreviewDetails) { - const {reportPreviewDetails, personalDetails, containerStyles, actorAccountID} = singleReportAvatar; + const {reportPreviewDetails, personalDetails, containerStyles, actorAccountID, size: singleAvatarSize} = singleReportAvatar; return ( ); } if (!!subscript?.shouldShow && subscriptMainAvatar) { - const {borderColor, noMargin, subIcon} = subscript; + const {borderColor, noMargin, subIcon, secondaryAvatarSize} = subscript; return ( ); } diff --git a/src/components/ReportActionItem/SingleReportAvatar.tsx b/src/components/ReportActionItem/SingleReportAvatar.tsx index 71f0caa81c140..6c95367aae54d 100644 --- a/src/components/ReportActionItem/SingleReportAvatar.tsx +++ b/src/components/ReportActionItem/SingleReportAvatar.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import Avatar from '@components/Avatar'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; +import type {AvatarSizeName} from '@styles/utils'; import CONST from '@src/CONST'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -12,11 +13,13 @@ function SingleReportAvatar({ personalDetails, containerStyles, actorAccountID, + size, }: { reportPreviewDetails: ReportAvatarDetails; personalDetails: PersonalDetailsList | undefined; containerStyles?: ViewStyle[]; actorAccountID: number | null | undefined; + size?: AvatarSizeName; }) { const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; const delegatePersonalDetails = reportPreviewAction?.delegateAccountID ? personalDetails?.[reportPreviewAction?.delegateAccountID] : undefined; @@ -34,6 +37,7 @@ function SingleReportAvatar({ type={primaryAvatar.type} name={primaryAvatar.name} avatarID={primaryAvatar.id} + size={size} fallbackIcon={reportFallbackIcon} /> diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index 3cd3150057306..e1d30275dfa98 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -47,9 +47,21 @@ type SubscriptAvatarProps = { /** Whether to show the tooltip */ showTooltip?: boolean; + + /** Size of the secondary avatar */ + secondaryAvatarSize?: ValueOf; }; -function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { +function SubscriptAvatar({ + mainAvatar, + secondaryAvatar, + subscriptIcon, + size = CONST.AVATAR_SIZE.DEFAULT, + backgroundColor, + noMargin = false, + showTooltip = true, + secondaryAvatarSize = CONST.AVATAR_SIZE.SUBSCRIPT, +}: SubscriptAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -96,11 +108,11 @@ function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CON > (report?.parentReportActionID ? actions?.[report.parentReportActionID] : undefined), canBeMissing: true, @@ -221,6 +226,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const participants = useMemo(() => { return getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); }, [report, personalDetails, shouldOpenRoomMembersPage]); + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); let caseID: CaseID; if (isMoneyRequestReport || isInvoiceReport) { @@ -269,6 +275,18 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail }, [caseID, parentReport, report]); const isMoneyRequestReportArchived = useReportIsArchived(moneyRequestReport?.reportID); + const reportPreviewDetails = useReportAvatarDetails({ + action: parentReportAction, + report: chatReport, + iouReport: report, + personalDetails, + innerPolicies, + policy, + }); + + const delegatePersonalDetails = parentReportAction?.delegateAccountID ? personalDetails?.[parentReportAction?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(parentReportAction, report, chatReport, delegatePersonalDetails); + const shouldShowTaskDeleteButton = isTaskReport && !isCanceledTaskReport && @@ -569,50 +587,52 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail ) : null; const renderedAvatar = useMemo(() => { - if (isMoneyRequestReport || isInvoiceReport) { + if (!isGroupChat || isThread) { return ( ); } - if (isGroupChat && !isThread) { - return ( - Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))} - onImageRemoved={() => { - // Calling this without a file will remove the avatar - updateGroupChatAvatar(report.reportID); - }} - onImageSelected={(file) => updateGroupChatAvatar(report.reportID, file)} - editIcon={Expensicons.Camera} - editIconStyle={styles.smallEditIconAccount} - pendingAction={report.pendingFields?.avatar ?? undefined} - errors={report.errorFields?.avatar ?? null} - errorRowStyles={styles.mt6} - onErrorClose={() => clearAvatarErrors(report.reportID)} - shouldUseStyleUtilityForAnchorPosition - style={[styles.w100, styles.mb3]} - /> - ); - } return ( - - - + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(report.reportID))} + onImageRemoved={() => { + // Calling this without a file will remove the avatar + updateGroupChatAvatar(report.reportID); + }} + onImageSelected={(file) => updateGroupChatAvatar(report.reportID, file)} + editIcon={Expensicons.Camera} + editIconStyle={styles.smallEditIconAccount} + pendingAction={report.pendingFields?.avatar ?? undefined} + errors={report.errorFields?.avatar ?? null} + errorRowStyles={styles.mt6} + onErrorClose={() => clearAvatarErrors(report.reportID)} + shouldUseStyleUtilityForAnchorPosition + style={[styles.w100, styles.mb3]} + /> ); - }, [report, icons, isMoneyRequestReport, isInvoiceReport, isGroupChat, isThread, styles]); + }, [report, icons, isGroupChat, isThread, styles, actorAccountID, personalDetails, reportPreviewDetails, shouldShowSubscriptAvatar]); const canJoin = canJoinChat(report, parentReportAction, policy, !!reportNameValuePairs?.private_isArchived); diff --git a/src/styles/index.ts b/src/styles/index.ts index ebc96b08f3d7e..926c0c6d91af3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2651,6 +2651,11 @@ const styles = (theme: ThemeColors) => width: variables.avatarSizeLarge, }, + emptyAvatarXLarge: { + height: variables.avatarSizeXLarge, + width: variables.avatarSizeXLarge, + }, + emptyAvatarMargin: { marginRight: variables.avatarChatSpacing, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index df52b0c094166..e94d13c60a28f 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -143,6 +143,7 @@ const avatarBorderWidths: Partial> = { [CONST.AVATAR_SIZE.LARGE]: 4, [CONST.AVATAR_SIZE.X_LARGE]: 4, [CONST.AVATAR_SIZE.MEDIUM]: 3, + [CONST.AVATAR_SIZE.HEADER]: 3, [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4, }; @@ -1736,6 +1737,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.AVATAR_SIZE.LARGE: containerStyles = [styles.emptyAvatarLarge, styles.mb2, styles.mr2]; break; + case CONST.AVATAR_SIZE.X_LARGE: + containerStyles = [styles.emptyAvatarXLarge, styles.mb3, styles.mr3]; + break; default: containerStyles = [styles.emptyAvatar, isInReportAction ? styles.emptyAvatarMarginChat : styles.emptyAvatarMargin]; } From a78d38a13e6bcee2baceb4f5ad8785a3aa936bfb Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 18 Jul 2025 15:36:36 +0200 Subject: [PATCH 05/33] Create ReportAvatar & centralize avatars logic --- src/components/AnonymousReportFooter.tsx | 21 +- src/components/AvatarWithDisplayName.tsx | 48 +-- src/components/HeaderWithBackButton/index.tsx | 6 - src/components/HeaderWithBackButton/types.ts | 9 +- .../LHNOptionsList/OptionRowLHN.tsx | 36 +- src/components/MenuItem.tsx | 98 +++-- src/components/MoneyReportHeader.tsx | 14 - src/components/MoneyRequestHeader.tsx | 1 - src/components/MultipleAvatars.tsx | 88 +--- src/components/OptionRow.tsx | 11 +- .../ReportActionItem/SingleReportAvatar.tsx | 48 --- src/components/ReportAvatar.tsx | 408 ++++++++++++++++++ src/components/ReportSearchHeader/index.tsx | 5 +- src/components/ReportSearchHeader/types.ts | 5 +- .../SelectionList/InviteMemberListItem.tsx | 13 +- .../Search/CardListItemHeader.tsx | 33 +- .../Search/ReportListItemHeader.tsx | 13 - .../Search/TransactionGroupListItem.tsx | 4 - src/components/SelectionList/UserListItem.tsx | 15 +- src/components/SelectionList/types.ts | 1 - src/components/SubscriptAvatar.tsx | 156 ------- src/hooks/useReportAvatarDetails.ts | 288 ------------- src/hooks/useReportPreviewSenderID.ts | 71 +++ src/pages/ReportDetailsPage.tsx | 56 +-- src/pages/home/HeaderView.tsx | 14 +- .../home/report/PureReportActionItem.tsx | 1 - .../home/report/ReportActionItemSingle.tsx | 84 ++-- .../FloatingActionButtonAndPopover.tsx | 4 +- src/pages/workspace/WorkspaceInitialPage.tsx | 2 +- src/stories/SubscriptAvatar.stories.tsx | 56 --- tests/ui/ReportListItemHeaderTest.tsx | 1 - ...ilsTest.ts => useReportPreviewSenderID.ts} | 66 +-- 32 files changed, 677 insertions(+), 999 deletions(-) delete mode 100644 src/components/ReportActionItem/SingleReportAvatar.tsx create mode 100644 src/components/ReportAvatar.tsx delete mode 100644 src/components/SubscriptAvatar.tsx delete mode 100644 src/hooks/useReportAvatarDetails.ts create mode 100644 src/hooks/useReportPreviewSenderID.ts delete mode 100644 src/stories/SubscriptAvatar.stories.tsx rename tests/unit/{useReportAvatarDetailsTest.ts => useReportPreviewSenderID.ts} (67%) diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index b9f074e887ce7..35324ce7891e5 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,23 +1,17 @@ import React from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +// eslint-disable-next-line no-restricted-syntax import * as Session from '@userActions/Session'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; import Text from './Text'; -type AnonymousReportFooterPropsWithOnyx = { - /** The policy which the user has access to and which the report is tied to */ - policy: OnyxEntry; -}; - -type AnonymousReportFooterProps = AnonymousReportFooterPropsWithOnyx & { +type AnonymousReportFooterProps = { /** The report currently being looked at */ report: OnyxEntry; @@ -25,7 +19,7 @@ type AnonymousReportFooterProps = AnonymousReportFooterPropsWithOnyx & { isSmallSizeLayout?: boolean; }; -function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: AnonymousReportFooterProps) { +function AnonymousReportFooter({isSmallSizeLayout = false, report}: AnonymousReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -36,7 +30,6 @@ function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: Anon report={report} isAnonymous shouldEnableDetailPageNavigation - policy={policy} /> @@ -60,8 +53,4 @@ function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: Anon AnonymousReportFooter.displayName = 'AnonymousReportFooter'; -export default withOnyx({ - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, - }, -})(AnonymousReportFooter); +export default AnonymousReportFooter; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index cef7c73f8a491..aa92f9627a173 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,11 +1,9 @@ -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; 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'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,7 +13,6 @@ import type {DisplayNameWithTooltips} from '@libs/ReportUtils'; import { getChatRoomSubtitle, getDisplayNamesWithTooltips, - getIcons, getParentNavigationSubtitle, getReportName, isChatThread, @@ -26,20 +23,19 @@ import { isMoneyRequestReport, isTrackExpenseReport, navigateToDetailsPage, - shouldReportShowSubscript, } from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import {getButtonRole} from './Button/utils'; import DisplayNames from './DisplayNames'; import type DisplayNamesProps from './DisplayNames/types'; import {FallbackAvatar} from './Icon/Expensicons'; -import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; +import ReportAvatar from './ReportAvatar'; import type {TransactionListItemType} from './SelectionList/types'; import Text from './Text'; @@ -47,9 +43,6 @@ type AvatarWithDisplayNameProps = { /** The report currently being looked at */ report: OnyxEntry; - /** The policy which the user has access to and which the report is tied to */ - policy?: OnyxEntry; - /** The size of the avatar */ size?: ValueOf; @@ -73,9 +66,6 @@ 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 = { @@ -162,7 +152,6 @@ function getCustomDisplayName( } function AvatarWithDisplayName({ - policy, report, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, @@ -170,7 +159,6 @@ function AvatarWithDisplayName({ shouldEnableAvatarNavigation = true, shouldUseCustomSearchTitleName = false, transactions = [], - singleAvatarDetails, openParentReportInCurrentTab = false, avatarBorderColor: avatarBorderColorProp, }: AvatarWithDisplayNameProps) { @@ -190,11 +178,8 @@ function AvatarWithDisplayName({ const subtitle = getChatRoomSubtitle(report, {isCreateExpenseFlow: true}); const parentNavigationSubtitleData = getParentNavigationSubtitle(report); const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report); - const icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); - const isReportArchived = useReportIsArchived(report?.reportID); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); const avatarBorderColor = avatarBorderColorProp ?? (isAnonymous ? theme.highlightBG : theme.componentBG); const actorAccountID = useRef(null); @@ -243,31 +228,14 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const multipleAvatarDetails = useMemo( - () => ({ - singleReportAvatar: { - shouldShow: !!singleAvatarDetails?.reportPreviewSenderID && !singleAvatarDetails.shouldDisplayAllActors && !shouldShowSubscriptAvatar, - personalDetails, - reportPreviewDetails: singleAvatarDetails, - containerStyles: [styles.actionAvatar, styles.mr3], - actorAccountID: singleAvatarDetails?.reportPreviewSenderID, - }, - subscript: { - shouldShow: shouldShowSubscriptAvatar, - borderColor: avatarBorderColor, - fallbackIcon, - }, - }), - [avatarBorderColor, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, styles], - ); - const multipleAvatars = ( - ); diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 8d55acf70f703..5ebd0c54cf49e 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -35,9 +35,7 @@ function HeaderWithBackButton({ onDownloadButtonPress = () => {}, onThreeDotsButtonPress = () => {}, report, - policy, policyAvatar, - singleAvatarDetails, shouldShowReportAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, @@ -103,8 +101,6 @@ function HeaderWithBackButton({ return ( @@ -124,7 +120,6 @@ function HeaderWithBackButton({ StyleUtils, subTitleLink, shouldUseHeadlineHeader, - policy, progressBarPercentage, report, shouldEnableDetailPageNavigation, @@ -140,7 +135,6 @@ 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 db23738a9952b..fcb0541f15423 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -2,12 +2,11 @@ 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'; import type {AnchorPosition} from '@src/styles'; -import type {Policy, Report} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -116,9 +115,6 @@ type HeaderWithBackButtonProps = Partial & { /** Report, if we're showing the details for one and using AvatarWithDisplay */ report?: OnyxEntry; - /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */ - policy?: OnyxEntry; - /** Single execution function to prevent concurrent navigation actions */ singleExecution?: (action: Action) => Action; @@ -162,9 +158,6 @@ 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/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 95c337d3cbabc..d7f2e316cb1e6 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -5,18 +5,16 @@ import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxListItemProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import type {ProductTrainingTooltipName} from '@components/ProductTrainingContext/TOOLTIPS'; +import ReportAvatar from '@components/ReportAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -44,7 +42,6 @@ import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OptionRowLHNProps} from './types'; @@ -102,11 +99,6 @@ function OptionRowLHN({ const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - canBeMissing: true, - }); const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten( @@ -120,15 +112,6 @@ function OptionRowLHN({ [optionItem?.alternateText], ); - const details = useReportAvatarDetails({ - action: optionItem?.parentReportAction, - report: chatReport, - iouReport: report, - innerPolicies: policies, - personalDetails, - policy: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${optionItem?.policyID}`], - }); - if (!optionItem && !isOptionFocused) { // rendering null as a render item causes the FlashList to render all // its children and consume significant memory on the first render. We can avoid this by @@ -277,12 +260,8 @@ function OptionRowLHN({ {!!optionItem.icons?.length && !!firstIcon && ( - )} diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 725f2450fd9f3..ca0f125c3e42d 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -39,6 +39,7 @@ import PlaidCardFeedIcon from './PlaidCardFeedIcon'; import type {PressableRef} from './Pressable/GenericPressable/types'; import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; import RenderHTML from './RenderHTML'; +import ReportAvatar from './ReportAvatar'; import SelectCircle from './SelectCircle'; import Text from './Text'; import EducationalTooltip from './Tooltip/EducationalTooltip'; @@ -232,12 +233,6 @@ type MenuItemBaseProps = { /** Prop to represent the size of the float right avatar images to be shown */ floatRightAvatarSize?: ValueOf; - /** Whether the secondary right avatar should show as a subscript */ - shouldShowSubscriptRightAvatar?: boolean; - - /** Whether the secondary avatar should show as a subscript */ - shouldShowSubscriptAvatar?: boolean; - /** Affects avatar size */ viewMode?: ValueOf; @@ -380,6 +375,10 @@ type MenuItemBaseProps = { /** Plaid image for the bank */ plaidUrl?: string; + + iconReportID?: string; + + rightIconReportID?: string; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -436,6 +435,7 @@ function MenuItem( shouldShowRedDotIndicator, hintText, success = false, + iconReportID, focused = false, disabled = false, title, @@ -456,8 +456,7 @@ function MenuItem( rightComponent, floatRightAvatars = [], floatRightAvatarSize, - shouldShowSubscriptRightAvatar = false, - shouldShowSubscriptAvatar: shouldShowSubscriptAvatarProp = false, + rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, isSmallAvatarSubscriptMenu = false, brickRoadIndicator, @@ -535,8 +534,6 @@ function MenuItem( titleStyle ?? {}, ); const shouldShowAvatar = !!icon && Array.isArray(icon); - const firstIcon = Array.isArray(icon) && !!icon.length ? icon.at(0) : undefined; - const shouldShowSubscriptAvatar = shouldShowSubscriptAvatarProp && !!firstIcon; const descriptionTextStyles = StyleUtils.combineStyles([ styles.textLabelSupporting, icon && !Array.isArray(icon) ? styles.ml3 : {}, @@ -716,23 +713,34 @@ function MenuItem( )} - {shouldShowAvatar && ( - - )} + {shouldShowAvatar && + (iconReportID ? ( + + ) : ( + + ))} {!icon && shouldPutLeftPaddingWhenNoIcon && ( 0 && !!firstRightIcon && ( - + {rightIconReportID ? ( + + ) : ( + + )} )} {!!brickRoadIndicator && ( diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 0bca126c77a60..967822d34a3a8 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -10,7 +10,6 @@ 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'; @@ -191,15 +190,6 @@ 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); @@ -235,8 +225,6 @@ 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'); @@ -994,9 +982,7 @@ function MoneyReportHeader({ ; -}; - -type SingleAvatar = { - /** Whether to show the single report avatar */ - shouldShow: boolean; - - /** Details for the report avatar */ - reportPreviewDetails: ReportAvatarDetails | undefined; - - /** Personal details for the report avatar */ - personalDetails: PersonalDetailsList | undefined; - - /** Styles for the container */ - containerStyles?: ViewStyle[]; - - /** The account ID of the actor */ - actorAccountID: number | null | undefined; - - /** Size of the avatar */ - size?: ValueOf; -}; - type MultipleAvatarsProps = { /** Array of avatar URLs or icons */ icons: Icon[]; @@ -105,11 +60,6 @@ type MultipleAvatarsProps = { /** Prop to limit the amount of avatars displayed horizontally */ overlapDivider?: number; - - /** Subscript avatar properties */ - subscript?: Subscript; - - singleReportAvatar?: SingleAvatar; }; type AvatarStyles = { @@ -137,8 +87,6 @@ function MultipleAvatars({ shouldUseCardBackground = false, maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, overlapDivider = 3, - subscript, - singleReportAvatar, }: MultipleAvatarsProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -197,39 +145,6 @@ function MultipleAvatars({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - const subscriptMainAvatar = icons.at(0) ?? subscript?.fallbackIcon; - - if (!!singleReportAvatar?.shouldShow && !!singleReportAvatar.reportPreviewDetails) { - const {reportPreviewDetails, personalDetails, containerStyles, actorAccountID, size: singleAvatarSize} = singleReportAvatar; - - return ( - - ); - } - - if (!!subscript?.shouldShow && subscriptMainAvatar) { - const {borderColor, noMargin, subIcon, secondaryAvatarSize} = subscript; - - return ( - - ); - } - if (!icons.length) { return null; } @@ -413,3 +328,4 @@ function MultipleAvatars({ MultipleAvatars.displayName = 'MultipleAvatars'; export default memo(MultipleAvatars); +export type {MultipleAvatarsProps}; diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 587f1234e4248..98e89e6501c9d 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -16,9 +16,9 @@ import Hoverable from './Hoverable'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestAmountInput from './MoneyRequestAmountInput'; -import MultipleAvatars from './MultipleAvatars'; import OfflineWithFeedback from './OfflineWithFeedback'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import ReportAvatar from './ReportAvatar'; import SelectCircle from './SelectCircle'; import Text from './Text'; @@ -207,12 +207,9 @@ function OptionRow({ {!!option.icons?.length && !!firstIcon && ( - - - - - - ); -} - -export default SingleReportAvatar; diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx new file mode 100644 index 0000000000000..86a87c1fafe69 --- /dev/null +++ b/src/components/ReportAvatar.tsx @@ -0,0 +1,408 @@ +import React from 'react'; +import type {ColorValue, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getAvatarsForAccountIDs} from '@libs/OptionsListUtils'; +import {getReportAction} from '@libs/ReportActionsUtils'; +import { + getDefaultWorkspaceAvatar, + getDisplayNameForParticipant, + getIcons, + getPolicyName, + getReportActionActorAccountID, + getWorkspaceIcon, + isChatReport, + isIndividualInvoiceRoom, + isInvoiceReport, + isInvoiceRoom, + isPolicyExpenseChat, + isTripRoom, + shouldReportShowSubscript, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; +import type IconAsset from '@src/types/utils/IconAsset'; +import Avatar from './Avatar'; +import Icon from './Icon'; +import {FallbackAvatar} from './Icon/Expensicons'; +import type {MultipleAvatarsProps} from './MultipleAvatars'; +import MultipleAvatars from './MultipleAvatars'; +import UserDetailsTooltip from './UserDetailsTooltip'; + +type SubIcon = { + /** Avatar source to display */ + source: IconAsset; + + /** Width of the icon */ + width?: number; + + /** Height of the icon */ + height?: number; + + /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ + fill?: string; +}; + +type ReportAvatarProps = MultipleAvatarsProps & { + /** IOU Report ID for single avatar */ + reportID?: string; + + /** IOU Report ID for single avatar */ + action?: OnyxEntry; + + /** Single avatar size */ + singleAvatarSize?: ValueOf; + + /** Single avatar container styles */ + singleAvatarContainerStyle?: ViewStyle[]; + + /** Border color for the subscript avatar */ + subscriptBorderColor?: ColorValue; + + /** Whether to show the subscript avatar without margin */ + subscriptNoMargin?: boolean; + + /** Subscript icon to display */ + subIcon?: SubIcon; + + /** A fallback main avatar icon */ + subscriptFallbackIcon?: IconType; + + /** Size of the secondary avatar */ + subscriptAvatarSize?: ValueOf; + + accountIDs?: number[]; +}; + +function getPrimaryAndSecondaryAvatar({ + iouReport, + action, + chatReport, + personalDetails, + policies, + reportPreviewSenderID, +}: { + iouReport: OnyxEntry; + action: OnyxEntry; + chatReport: OnyxEntry; + personalDetails: OnyxEntry; + policies: OnyxCollection; + reportPreviewSenderID: number | undefined; +}) { + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); + const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + + const usePersonalDetailsAvatars = !iouReport && chatReport && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + + const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; + const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? iouReport?.policyID : chatReport?.policyID; + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const invoiceReceiverPolicy = + chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.invoiceReceiver.policyID}`] : undefined; + + const {avatar, fallbackIcon} = personalDetails?.[accountID] ?? {}; + + const isATripRoom = isTripRoom(chatReport); + // 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 && !isATripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; + const isAInvoiceReport = isInvoiceReport(iouReport ?? null); + const isWorkspaceActor = isAInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || displayAllActors)); + + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; + + const defaultAvatar = { + source: avatar ?? FallbackAvatar, + id: accountID, + name: defaultDisplayName, + type: CONST.ICON_TYPE_AVATAR, + fill: undefined, + fallbackIcon, + }; + + const defaultSecondaryAvatar = {name: '', source: '', type: CONST.ICON_TYPE_AVATAR, id: 0, fill: undefined, fallbackIcon}; + + const getPrimaryAvatar = () => { + if (isWorkspaceActor) { + return { + ...defaultAvatar, + name: getPolicyName({report: chatReport, policy}), + type: CONST.ICON_TYPE_WORKSPACE, + source: getWorkspaceIcon(chatReport, policy).source, + id: chatReport?.policyID, + }; + } + + if (delegatePersonalDetails) { + return { + ...defaultAvatar, + name: delegatePersonalDetails?.displayName ?? '', + source: delegatePersonalDetails?.avatar ?? FallbackAvatar, + id: delegatePersonalDetails?.accountID, + }; + } + + if (isReportPreviewAction && isATripRoom) { + return { + ...defaultAvatar, + name: chatReport?.reportName ?? '', + source: personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar ?? FallbackAvatar, + id: ownerAccountID, + }; + } + + return defaultAvatar; + }; + + const getSecondaryAvatar = () => { + // If this is a report preview, display names and avatars of both people involved + if (displayAllActors) { + const secondaryAccountId = ownerAccountID === actorAccountID || isAInvoiceReport ? actorAccountID : ownerAccountID; + const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; + const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); + const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name); + const isWorkspaceInvoice = isInvoiceRoom(chatReport) && !isIndividualInvoiceRoom(chatReport); + + 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 = chatReport?.isOwnPolicyExpenseChat || isPolicyExpenseChat(chatReport) ? 0 : 1; + const reportIcons = getIcons(chatReport, personalDetails, undefined, undefined, undefined, policy); + + return reportIcons.at(avatarIconIndex) ?? defaultSecondaryAvatar; + } + + if (isInvoiceReport(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 defaultSecondaryAvatar; + }; + + const icons = getIcons(chatReport ?? iouReport, personalDetails); + + const primaryAvatar = (usePersonalDetailsAvatars ? icons.at(0) : getPrimaryAvatar()) ?? defaultAvatar; + const secondaryAvatar = (usePersonalDetailsAvatars ? icons.at(1) : getSecondaryAvatar()) ?? defaultSecondaryAvatar; + + return [primaryAvatar, secondaryAvatar]; +} + +function ReportAvatar({ + reportID, + singleAvatarContainerStyle, + singleAvatarSize, + subscriptBorderColor, + subscriptNoMargin = false, + subIcon, + subscriptFallbackIcon, + subscriptAvatarSize = CONST.AVATAR_SIZE.SUBSCRIPT, + accountIDs: passedAccountIDs, + action: passedAction, + ...multipleAvatarsProps +}: Omit) { + const {size = CONST.AVATAR_SIZE.DEFAULT, shouldShowTooltip = true} = multipleAvatarsProps; + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); + + const [potentialChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + + const iouReport = isChatReport(report) ? undefined : report; + const chatReport = isChatReport(report) ? report : potentialChatReport; + + const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); + + const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); + + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); + const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + + const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + + const {fallbackIcon} = personalDetails?.[accountID] ?? {}; + + const isATripRoom = isTripRoom(chatReport); + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; + const isAInvoiceReport = isInvoiceReport(iouReport ?? null); + const isWorkspaceActor = isAInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || shouldDisplayAllActors)); + + const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ + chatReport, + iouReport, + action, + personalDetails, + reportPreviewSenderID, + policies, + }); + + const isReportArchived = useReportIsArchived(reportID); + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); + + if (shouldShowSubscriptAvatar) { + const isSmall = size === CONST.AVATAR_SIZE.SMALL; + const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; + const containerStyle = StyleUtils.getContainerStyles(size); + + const mainAvatar = primaryAvatar ?? subscriptFallbackIcon; + + return ( + + + + + + + {!!secondaryAvatar && ( + + + + + + )} + {!!subIcon && ( + + + + )} + + ); + } + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (passedAccountIDs || (shouldDisplayAllActors && !reportPreviewSenderID)) { + const icons = getAvatarsForAccountIDs(passedAccountIDs ?? [], personalDetails); + + return ( + 0 ? icons : [primaryAvatar, secondaryAvatar]} + /> + ); + } + + return ( + + + + + + ); +} + +export default ReportAvatar; +export {getPrimaryAndSecondaryAvatar}; diff --git a/src/components/ReportSearchHeader/index.tsx b/src/components/ReportSearchHeader/index.tsx index 092a81dc7cbf2..fd2896f44ec3e 100755 --- a/src/components/ReportSearchHeader/index.tsx +++ b/src/components/ReportSearchHeader/index.tsx @@ -4,14 +4,13 @@ import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import useThemeStyles from '@hooks/useThemeStyles'; import type ReportSearchHeaderProps from './types'; -function ReportSearchHeader({report, policy, style, transactions, avatarBorderColor}: ReportSearchHeaderProps) { +function ReportSearchHeader({report, style, transactions, avatarBorderColor}: ReportSearchHeaderProps) { const styles = useThemeStyles(); const middleContent = useMemo(() => { return ( ); - }, [report, policy, transactions, avatarBorderColor]); + }, [report, transactions, avatarBorderColor]); return ( ; - /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */ - policy?: OnyxEntry; - /** Additional styles to add to the component */ style?: StyleProp; diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index b6812a49ff0e7..5726dad8cebd8 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -2,9 +2,9 @@ import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import {FallbackAvatar} from '@components/Icon/Expensicons'; -import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import ReportAvatar from '@components/ReportAvatar'; import SelectCircle from '@components/SelectCircle'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -111,19 +111,16 @@ function InviteMemberListItem({ > {!!item.icons && ( - )} diff --git a/src/components/SelectionList/Search/CardListItemHeader.tsx b/src/components/SelectionList/Search/CardListItemHeader.tsx index 94d418afc47e1..253c2545b0ccb 100644 --- a/src/components/SelectionList/Search/CardListItemHeader.tsx +++ b/src/components/SelectionList/Search/CardListItemHeader.tsx @@ -1,9 +1,8 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; -import MultipleAvatars from '@components/MultipleAvatars'; +import ReportAvatar from '@components/ReportAvatar'; import type {ListItem, TransactionCardGroupListItemType} from '@components/SelectionList/types'; -import type {SubIcon} from '@components/SubscriptAvatar'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -14,9 +13,7 @@ import {getCardFeedIcon} from '@libs/CardUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; type CardListItemHeaderProps = { /** The card currently being looked at */ @@ -47,22 +44,13 @@ function CardListItemHeader({card: cardItem, onCheckboxP const formattedDisplayName = useMemo(() => formatPhoneNumber(getDisplayNameOrDefault(cardItem)), [cardItem]); - const [memberAvatar, cardIcon] = useMemo(() => { - const avatar: Icon = { - source: cardItem.avatar, - type: CONST.ICON_TYPE_AVATAR, - name: formattedDisplayName, - id: cardItem.accountID, - }; - - const icon: SubIcon = { + const cardIcon = useMemo(() => { + return { source: getCardFeedIcon(cardItem.bank as CompanyCardFeed, illustrations), width: variables.cardAvatarWidth, height: variables.cardAvatarHeight, }; - - return [avatar, icon]; - }, [formattedDisplayName, illustrations, cardItem]); + }, [illustrations, cardItem]); const backgroundColor = StyleUtils.getItemBackgroundColorStyle(!!cardItem.isSelected, !!isFocused || !!isHovered, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? @@ -83,14 +71,11 @@ function CardListItemHeader({card: cardItem, onCheckboxP /> )} - = { /** The report currently being looked at */ report: TransactionReportGroupListItemType; - /** The policy tied to the expense report */ - policy: OnyxEntry; - /** Callback to fire when the item is pressed */ onSelectRow: (item: TItem) => void; @@ -48,9 +43,6 @@ type FirstRowReportHeaderProps = { /** The report currently being looked at */ report: TransactionReportGroupListItemType; - /** The policy tied to the expense report */ - policy: OnyxEntry; - /** Callback to fire when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; @@ -99,7 +91,6 @@ function TotalCell({showTooltip, isLargeScreenWidth, reportItem}: ReportCellProp } function HeaderFirstRow({ - policy, report: reportItem, onCheckboxPress, isDisabled, @@ -128,7 +119,6 @@ function HeaderFirstRow({ ({ } function ReportListItemHeader({ - policy, report: reportItem, onSelectRow, onCheckboxPress, @@ -185,7 +174,6 @@ function ReportListItemHeader({ ({ ({ onLongPressRow, shouldSyncFocus, groupBy, - policies, }: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${groupItem?.policyID}`]; const isEmpty = groupItem.transactions.length === 0; const isDisabledOrEmpty = isEmpty || isDisabled; const {isLargeScreenWidth} = useResponsiveLayout(); @@ -117,7 +114,6 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.REPORTS]: ( ({ )} {!!item.icons && ( - )} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8c2e66dbc04b5..5b9ad8ad2b910 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -463,7 +463,6 @@ type TaskListItemProps = ListItemProps & { type TransactionGroupListItemProps = ListItemProps & { groupBy?: SearchGroupBy; - policies?: OnyxCollection; }; type ChatListItemProps = ListItemProps & { diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx deleted file mode 100644 index e1d30275dfa98..0000000000000 --- a/src/components/SubscriptAvatar.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, {memo} from 'react'; -import type {ColorValue} from 'react-native'; -import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; -import type IconAsset from '@src/types/utils/IconAsset'; -import Avatar from './Avatar'; -import Icon from './Icon'; -import UserDetailsTooltip from './UserDetailsTooltip'; - -type SubIcon = { - /** Avatar source to display */ - source: IconAsset; - - /** Width of the icon */ - width?: number; - - /** Height of the icon */ - height?: number; - - /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ - fill?: string; -}; - -type SubscriptAvatarProps = { - /** Avatar icon */ - mainAvatar: IconType; - - /** Subscript avatar URL or icon */ - secondaryAvatar?: IconType; - - /** Set the size of avatars */ - size?: ValueOf; - - /** Background color used for subscript avatar border */ - backgroundColor?: ColorValue; - - /** Subscript icon */ - subscriptIcon?: SubIcon; - - /** Removes margin from around the avatar, used for the chat view */ - noMargin?: boolean; - - /** Whether to show the tooltip */ - showTooltip?: boolean; - - /** Size of the secondary avatar */ - secondaryAvatarSize?: ValueOf; -}; - -function SubscriptAvatar({ - mainAvatar, - secondaryAvatar, - subscriptIcon, - size = CONST.AVATAR_SIZE.DEFAULT, - backgroundColor, - noMargin = false, - showTooltip = true, - secondaryAvatarSize = CONST.AVATAR_SIZE.SUBSCRIPT, -}: SubscriptAvatarProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const isSmall = size === CONST.AVATAR_SIZE.SMALL; - const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; - const containerStyle = StyleUtils.getContainerStyles(size); - - return ( - - - - - - - {!!secondaryAvatar && ( - - - - - - )} - {!!subscriptIcon && ( - - - - )} - - ); -} - -SubscriptAvatar.displayName = 'SubscriptAvatar'; - -export default memo(SubscriptAvatar); -export type {SubIcon, SubscriptAvatarProps}; diff --git a/src/hooks/useReportAvatarDetails.ts b/src/hooks/useReportAvatarDetails.ts deleted file mode 100644 index 8ce24c1bc19ea..0000000000000 --- a/src/hooks/useReportAvatarDetails.ts +++ /dev/null @@ -1,288 +0,0 @@ -import {useMemo} from 'react'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import { - getDefaultWorkspaceAvatar, - getDisplayNameForParticipant, - getIcons, - getPolicyName, - getReportActionActorAccountID, - getWorkspaceIcon, - isDM, - 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'; -import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; - -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: accountID, - 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: reportTransactions} = useTransactionsAndViolationsForReport(action?.childReportID); - const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, iouActions ?? []), [reportTransactions, 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 && isDM(report); - 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/hooks/useReportPreviewSenderID.ts b/src/hooks/useReportPreviewSenderID.ts new file mode 100644 index 0000000000000..92e583caf145f --- /dev/null +++ b/src/hooks/useReportPreviewSenderID.ts @@ -0,0 +1,71 @@ +import {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isDM} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; +import useOnyx from './useOnyx'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +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 useReportPreviewSenderID({iouReport, action, chatReport}: {action: OnyxEntry; chatReport: OnyxEntry; iouReport: OnyxEntry}) { + const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), + }); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(action?.childReportID); + const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, iouActions ?? []), [reportTransactions, iouActions]); + + const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => + Object.values(actions ?? {}) + .filter(isMoneyRequestAction) + .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), + }); + + // 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 && isDM(chatReport); + const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; + + return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; +} + +export default useReportPreviewSenderID; diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 676467b9955a7..e84ab7bac992a 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -13,12 +13,12 @@ import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/M import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import type {PromotedAction} from '@components/PromotedActionsBar'; import PromotedActionsBar, {PromotedActions} from '@components/PromotedActionsBar'; +import ReportAvatar from '@components/ReportAvatar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -28,7 +28,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import usePermissions from '@hooks/usePermissions'; -import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -56,7 +55,6 @@ import { getParentNavigationSubtitle, getParticipantsAccountIDsForDisplay, getParticipantsList, - getReportActionActorAccountID, getReportDescription, getReportFieldKey, getReportName, @@ -91,7 +89,6 @@ import { navigateBackOnDeleteTransaction, navigateToPrivateNotes, shouldDisableRename as shouldDisableRenameUtil, - shouldReportShowSubscript, shouldUseFullTitleToDisplay, } from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -153,9 +150,6 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {canBeMissing: true}); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`, {canBeMissing: true}); - const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - canBeMissing: true, - }); const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, { selector: (actions) => (report?.parentReportActionID ? actions?.[report.parentReportActionID] : undefined), canBeMissing: true, @@ -227,7 +221,6 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const participants = useMemo(() => { return getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); }, [report, personalDetails, shouldOpenRoomMembersPage]); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); let caseID: CaseID; if (isMoneyRequestReport || isInvoiceReport) { @@ -275,18 +268,6 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail }, [caseID, parentReport, report]); const isMoneyRequestReportArchived = useReportIsArchived(moneyRequestReport?.reportID); - const reportPreviewDetails = useReportAvatarDetails({ - action: parentReportAction, - report: chatReport, - iouReport: report, - personalDetails, - innerPolicies, - policy, - }); - - const delegatePersonalDetails = parentReportAction?.delegateAccountID ? personalDetails?.[parentReportAction?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(parentReportAction, report, chatReport, delegatePersonalDetails); - const shouldShowTaskDeleteButton = isTaskReport && !isCanceledTaskReport && @@ -591,21 +572,12 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail if (!isGroupChat || isThread) { return ( - ); @@ -633,7 +605,21 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail style={[styles.w100, styles.mb3]} /> ); - }, [report, icons, isGroupChat, isThread, styles, actorAccountID, personalDetails, reportPreviewDetails, shouldShowSubscriptAvatar]); + }, [ + isGroupChat, + isThread, + icons, + report.avatarUrl, + report.pendingFields?.avatar, + report.errorFields?.avatar, + report.reportID, + styles.avatarXLarge, + styles.smallEditIconAccount, + styles.mt6, + styles.w100, + styles.mb3, + moneyRequestReport, + ]); const canJoin = canJoinChat(report, parentReportAction, policy, !!reportNameValuePairs?.private_isArchived); diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 6e25b1aad675d..a2ea826f7c134 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -10,11 +10,11 @@ import DisplayNames from '@components/DisplayNames'; import Icon from '@components/Icon'; import {BackArrow, DotIndicator, FallbackAvatar} from '@components/Icon/Expensicons'; import LoadingBar from '@components/LoadingBar'; -import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import OnboardingHelpDropdownButton from '@components/OnboardingHelpDropdownButton'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import ReportAvatar from '@components/ReportAvatar'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; import HelpButton from '@components/SidePanel/HelpComponents/HelpButton'; @@ -40,7 +40,6 @@ import { canUserPerformWriteAction, getChatRoomSubtitle, getDisplayNamesWithTooltips, - getIcons, getParentNavigationSubtitle, getParticipantsAccountIDsForDisplay, getPolicyDescriptionText, @@ -214,7 +213,6 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, const isArchived = isArchivedReport(reportNameValuePairs); const shouldShowSubscript = shouldReportShowSubscript(report, isArchived); const defaultSubscriptSize = isExpenseRequest(report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; - const icons = getIcons(reportHeaderData, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const brickRoadIndicator = hasReportNameError(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report); const shouldUseGroupTitle = isGroupChat && (!!report?.reportName || !isMultipleParticipant); @@ -242,13 +240,11 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, ); const multipleAvatars = ( - ); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index cb4af099ff569..93e621673086a 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1346,7 +1346,6 @@ function PureReportActionItem({ hasBeenFlagged={ ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) && !isPendingRemove(action) } - policies={policies} > {content} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index a260d19d427a5..abf5626442353 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -1,16 +1,15 @@ 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 MultipleAvatars from '@components/MultipleAvatars'; +import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import ReportAvatar, {getPrimaryAndSecondaryAvatar} from '@components/ReportAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; -import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; +import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,11 +18,19 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; -import {getReportActionActorAccountID, isOptimisticPersonalDetail} from '@libs/ReportUtils'; +import { + getDisplayNameForParticipant, + getPolicyName, + getReportActionActorAccountID, + isInvoiceReport as isInvoiceReportUtils, + isOptimisticPersonalDetail, + isPolicyExpenseChat, + isTripRoom as isTripRoomReportUtils, +} 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 {Report, ReportAction} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import ReportActionItemDate from './ReportActionItemDate'; import ReportActionItemFragment from './ReportActionItemFragment'; @@ -55,9 +62,6 @@ type ReportActionItemSingleProps = Partial & { /** If the action is active */ isActive?: boolean; - - /** Policies */ - policies?: OnyxCollection; }; const showUserDetails = (accountID: number | undefined) => { @@ -82,7 +86,6 @@ function ReportActionItemSingle({ iouReport, isHovered = false, isActive = false, - policies, }: ReportActionItemSingleProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -92,33 +95,44 @@ function ReportActionItemSingle({ canBeMissing: true, }); - const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - canBeMissing: true, - }); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - const policy = usePolicy(report?.policyID); - - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - - const reportPreviewDetails = useReportAvatarDetails({ + const reportPreviewSenderID = useReportPreviewSenderID({ + iouReport, action, - report, + chatReport: report, + }); + + const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ + chatReport: report, iouReport, - policies, + action, personalDetails, - innerPolicies, - policy, + reportPreviewSenderID, + policies, }); - const {primaryAvatar, secondaryAvatar, displayName, shouldDisplayAllActors, isWorkspaceActor, reportPreviewSenderID, actorHint} = reportPreviewDetails; + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + const isTripRoom = isTripRoomReportUtils(report); + const shouldDisplayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID; + const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); + const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || shouldDisplayAllActors)); + const policyID = report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? iouReport?.policyID : report?.policyID; + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const actorHint = isWorkspaceActor ? getPolicyName({report, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); + const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); const showMultipleUserAvatarPattern = shouldDisplayAllActors && !shouldShowSubscriptAvatar; - const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : displayName; + const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; // 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, @@ -182,23 +196,15 @@ function ReportActionItemSingle({ role={CONST.ROLE.BUTTON} > - diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index 37a557b77219b..b5ffd67b11b65 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -329,7 +329,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT description: quickActionSubtitle, onSelected, shouldCallAfterModalHide: shouldUseNarrowLayout, - shouldShowSubscriptRightAvatar: isPolicyExpenseChat(quickActionReport), + rightIconReportID: quickActionReport?.reportID, }, ]; } @@ -354,7 +354,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT description: getReportName(policyChatForActivePolicy), shouldCallAfterModalHide: shouldUseNarrowLayout, onSelected, - shouldShowSubscriptRightAvatar: true, + rightIconReportID: policyChatForActivePolicy?.reportID, }, ]; } diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index a21eeeb405f89..fe53ab346d21a 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -499,7 +499,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} shouldShowRightIcon wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} - shouldShowSubscriptAvatar + iconReportID={currentUserPolicyExpenseChatReportID} /> diff --git a/src/stories/SubscriptAvatar.stories.tsx b/src/stories/SubscriptAvatar.stories.tsx deleted file mode 100644 index 15f9e41068e2a..0000000000000 --- a/src/stories/SubscriptAvatar.stories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type {StoryFn} from '@storybook/react'; -import React from 'react'; -import * as defaultAvatars from '@components/Icon/DefaultAvatars'; -import * as Expensicons from '@components/Icon/Expensicons'; -import SubscriptAvatar from '@components/SubscriptAvatar'; -import type {SubscriptAvatarProps} from '@components/SubscriptAvatar'; -import CONST from '@src/CONST'; - -type SubscriptAvatarStory = StoryFn; - -/** - * We use the Component Story Format for writing stories. Follow the docs here: - * - * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format - */ -export default { - title: 'Components/SubscriptAvatar', - component: SubscriptAvatar, - args: { - mainAvatar: {source: defaultAvatars.Avatar5, name: '', type: CONST.ICON_TYPE_AVATAR}, - size: CONST.AVATAR_SIZE.DEFAULT, - }, - argTypes: { - size: { - options: [CONST.AVATAR_SIZE.SMALL, CONST.AVATAR_SIZE.DEFAULT], // SubscriptAvatar only supports these two sizes - }, - }, -}; - -function Template(props: SubscriptAvatarProps) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -} - -// Arguments can be passed to the component by binding -// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Default: SubscriptAvatarStory = Template.bind({}); - -const AvatarURLStory: SubscriptAvatarStory = Template.bind({}); -AvatarURLStory.args = { - mainAvatar: {source: defaultAvatars.Avatar1, name: '', type: CONST.ICON_TYPE_AVATAR}, - secondaryAvatar: {source: defaultAvatars.Avatar3, name: '', type: CONST.ICON_TYPE_AVATAR}, -}; - -const SubscriptIcon: SubscriptAvatarStory = Template.bind({}); -SubscriptIcon.args = { - subscriptIcon: {source: Expensicons.DownArrow, width: 8, height: 8}, -}; - -const WorkspaceSubscriptIcon: SubscriptAvatarStory = Template.bind({}); -WorkspaceSubscriptIcon.args = { - mainAvatar: {source: defaultAvatars.Avatar1, name: '', type: CONST.ICON_TYPE_WORKSPACE}, - subscriptIcon: {source: Expensicons.DownArrow, width: 8, height: 8}, -}; - -export {Default, AvatarURLStory, SubscriptIcon, WorkspaceSubscriptIcon}; diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index 5499ea3ac5637..eac751947c320 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -99,7 +99,6 @@ const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemTy { - const mockedOwnerAccountID = 15593135; - const mockedOwnerAccountAvatar = personalDetails[mockedOwnerAccountID].avatar; - - const mockedManagerAccountID = 51760358; - const mockedManagerAccountAvatar = personalDetails[mockedManagerAccountID].avatar; +describe('useReportPreviewSenderID', () => { 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, @@ -78,35 +61,29 @@ describe('useReportAvatarDetails', () => { it('returns avatar with no reportPreviewSenderID when action is not a report preview', async () => { const {result} = renderHook( () => - useReportAvatarDetails({ + useReportPreviewSenderID({ action: actionR14932, iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, + chatReport: mockedDMChatRoom, }), {wrapper: OnyxListItemProvider}, ); await waitForBatchedUpdates(); - expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBeUndefined(); + expect(result.current).toBeUndefined(); }); it('returns childManagerAccountID and his avatar when all conditions are met for Send Money flow', async () => { const {result} = renderHook( () => - useReportAvatarDetails({ + useReportPreviewSenderID({ action: {...validAction, childMoneyRequestCount: 0}, iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, + chatReport: mockedDMChatRoom, }), {wrapper: OnyxListItemProvider}, ); await waitForBatchedUpdates(); - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID); + expect(result.current).toBe(iouReportR14932.managerID); }); it('returns both avatars & no reportPreviewSenderID when there are multiple attendees', async () => { @@ -124,18 +101,15 @@ describe('useReportAvatarDetails', () => { }); const {result} = renderHook( () => - useReportAvatarDetails({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, + chatReport: mockedDMChatRoom, }), {wrapper: OnyxListItemProvider}, ); await waitForBatchedUpdates(); - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.reportPreviewSenderID).toBeUndefined(); + expect(result.current).toBeUndefined(); }); it('returns both avatars & no reportPreviewSenderID when amounts have different signs', async () => { @@ -149,34 +123,28 @@ describe('useReportAvatarDetails', () => { }); const {result} = renderHook( () => - useReportAvatarDetails({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, + chatReport: mockedDMChatRoom, }), {wrapper: OnyxListItemProvider}, ); await waitForBatchedUpdates(); - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.reportPreviewSenderID).toBeUndefined(); + expect(result.current).toBeUndefined(); }); it('returns childOwnerAccountID as reportPreviewSenderID and a single avatar when all conditions are met', async () => { const {result} = renderHook( () => - useReportAvatarDetails({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, + chatReport: mockedDMChatRoom, }), {wrapper: OnyxListItemProvider}, ); await waitForBatchedUpdates(); - expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID); + expect(result.current).toBe(iouReportR14932.ownerAccountID); }); }); From b6b64155d8d438a0affcd2fe55ff9643627df420 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 18 Jul 2025 16:33:49 +0200 Subject: [PATCH 06/33] Fix ReportActionItemSingleTest --- src/components/Avatar.tsx | 5 ++++- src/components/ReportAvatar.tsx | 9 ++++++++- src/components/SelectionList/ChatListItem.tsx | 11 ----------- src/pages/home/report/PureReportActionItem.tsx | 5 ----- src/pages/home/report/ReportActionItemSingle.tsx | 15 ++++++++------- .../home/report/ReportActionsListItemRenderer.tsx | 12 +----------- tests/unit/ReportActionItemSingleTest.ts | 11 +++++------ tests/utils/LHNTestUtils.tsx | 9 ++------- 8 files changed, 28 insertions(+), 49 deletions(-) diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index ecdf7832fc154..3a3a151631c8f 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -103,7 +103,10 @@ function Avatar({ } return ( - + {typeof avatarSource === 'string' ? ( { if (isWorkspaceActor) { diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index f447dd2226473..9a8acd7766074 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -2,7 +2,6 @@ import React from 'react'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isInvoiceRoom, isPolicyExpenseChat} from '@libs/ReportUtils'; import ReportActionItem from '@pages/home/report/ReportActionItem'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -81,16 +80,6 @@ function ChatListItem({ isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} shouldShowDraftMessage={false} - shouldShowSubscriptAvatar={ - (isPolicyExpenseChat(report) || isInvoiceRoom(report)) && - [ - CONST.REPORT.ACTIONS.TYPE.IOU, - CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - CONST.REPORT.ACTIONS.TYPE.APPROVED, - CONST.REPORT.ACTIONS.TYPE.FORWARDED, - ].some((type) => type === reportActionItem.actionName) - } policies={policies} shouldShowBorder /> diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 93e621673086a..1e98ea79b4038 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -225,9 +225,6 @@ type PureReportActionItemProps = { /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar?: boolean; - /** Position index of the report action in the overall report FlatList view */ index: number; @@ -386,7 +383,6 @@ function PureReportActionItem({ parentReportAction, shouldDisplayNewMarker, shouldHideThreadDividerLine = false, - shouldShowSubscriptAvatar = false, onPress = undefined, isFirstVisibleReportAction = false, isThreadReportParentAction = false, @@ -1338,7 +1334,6 @@ function PureReportActionItem({ ...(isOnSearch && styles.p0), ...(isWhisper && styles.pt1), }} - shouldShowSubscriptAvatar={shouldShowSubscriptAvatar} report={report} iouReport={iouReport} isHovered={hovered || isContextMenuActive} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index abf5626442353..aa75f2c84e776 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -9,6 +9,7 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -26,6 +27,7 @@ import { isOptimisticPersonalDetail, isPolicyExpenseChat, isTripRoom as isTripRoomReportUtils, + shouldReportShowSubscript, } from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,9 +53,6 @@ type ReportActionItemSingleProps = Partial & { /** Show header for action */ showHeader?: boolean; - /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar?: boolean; - /** If the message has been flagged for moderation */ hasBeenFlagged?: boolean; @@ -80,7 +79,6 @@ function ReportActionItemSingle({ children, wrapperStyle, showHeader = true, - shouldShowSubscriptAvatar = false, hasBeenFlagged = false, report, iouReport, @@ -91,6 +89,9 @@ function ReportActionItemSingle({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const reportID = report?.reportID; + const iouReportID = iouReport?.reportID; + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { canBeMissing: true, }); @@ -103,6 +104,8 @@ function ReportActionItemSingle({ chatReport: report, }); + const isReportArchived = useReportIsArchived(iouReportID); + const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ chatReport: report, iouReport, @@ -123,6 +126,7 @@ function ReportActionItemSingle({ const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || shouldDisplayAllActors)); const policyID = report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? iouReport?.policyID : report?.policyID; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; @@ -146,9 +150,6 @@ function ReportActionItemSingle({ ] : action?.person; - const reportID = report?.reportID; - const iouReportID = iouReport?.reportID; - const showActorDetails = useCallback(() => { if (isWorkspaceActor) { showWorkspaceDetails(reportID); diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index 513904335f672..2bdf62a5105d7 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx @@ -1,7 +1,7 @@ import React, {memo, useMemo} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; -import {isChatThread, isInvoiceRoom, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isChatThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import ReportActionItem from './ReportActionItem'; @@ -190,16 +190,6 @@ function ReportActionsListItemRenderer({ displayAsGroup={displayAsGroup} transactions={transactions} shouldDisplayNewMarker={shouldDisplayNewMarker} - shouldShowSubscriptAvatar={ - (isPolicyExpenseChat(report) || isInvoiceRoom(report)) && - [ - CONST.REPORT.ACTIONS.TYPE.IOU, - CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - CONST.REPORT.ACTIONS.TYPE.APPROVED, - CONST.REPORT.ACTIONS.TYPE.FORWARDED, - ].some((type) => type === reportAction.actionName) - } isMostRecentIOUReportAction={reportAction.reportActionID === mostRecentIOUReportActionID} index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index 9b32ec46634c1..f710a30b1c899 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -28,10 +28,9 @@ describe('ReportActionItemSingle', () => { Onyx.clear(); }); - describe('when the Report is a policy expense chat', () => { - describe('and the property "shouldShowSubscriptAvatar" is true', () => { - const shouldShowSubscriptAvatar = true; - const fakeReport = LHNTestUtils.getFakeReportWithPolicy([1, 2]); + describe('when the Report is a DM chat', () => { + describe('component properly renders both avatar & name of the sender', () => { + const fakeReport = {...LHNTestUtils.getFakeReportWithPolicy([1, 2]), chatType: undefined}; const fakeReportAction = LHNTestUtils.getFakeAdvancedReportAction(); const fakePolicy = LHNTestUtils.getFakePolicy(fakeReport.policyID); const faceAccountId = fakeReportAction.actorAccountID ?? CONST.DEFAULT_NUMBER_ID; @@ -56,12 +55,12 @@ describe('ReportActionItemSingle', () => { }), ) .then(() => { - LHNTestUtils.getDefaultRenderedReportActionItemSingle(shouldShowSubscriptAvatar, fakeReport, fakeReportAction); + LHNTestUtils.getDefaultRenderedReportActionItemSingle(fakeReport, fakeReportAction); }); } it('renders secondary Avatar properly', async () => { - const expectedSecondaryIconTestId = 'SvgDefaultAvatar_w Icon'; + const expectedSecondaryIconTestId = 'Avatar'; await setup(); await waitFor(() => { diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index 1ca562c551837..24f05cc284cb9 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -19,9 +19,6 @@ import type ReportActionName from '@src/types/onyx/ReportActionName'; import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; type MockedReportActionItemSingleProps = { - /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar?: boolean; - /** Report for this action */ report: Report; @@ -333,14 +330,13 @@ function internalRender(component: ReactElement) { } } -function MockedReportActionItemSingle({shouldShowSubscriptAvatar = true, report, reportAction}: MockedReportActionItemSingleProps) { +function MockedReportActionItemSingle({report, reportAction}: MockedReportActionItemSingleProps) { return ( , From deb1caf91271209c8aba0aa1da18d321a91a740c Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 21 Jul 2025 12:01:06 +0200 Subject: [PATCH 07/33] Add Onyx wrapper for HeaderViewTest --- tests/ui/components/HeaderViewTest.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index 164148188e124..d36a1a7154425 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type Navigation from '@libs/Navigation/Navigation'; import HeaderView from '@pages/home/HeaderView'; import CONST from '@src/CONST'; @@ -47,12 +48,14 @@ describe('HeaderView', () => { }); render( - {}} - parentReportAction={null} - reportID={report.reportID} - />, + + {}} + parentReportAction={null} + reportID={report.reportID} + /> + , ); await waitForBatchedUpdates(); From bd50e0e71da0a84ed91ed253c4d4c82a7c07ba43 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 21 Jul 2025 13:35:41 +0200 Subject: [PATCH 08/33] IOU Report handling fix in ReportActionItemSingle --- src/components/ReportAvatar.tsx | 2 +- .../home/report/ReportActionItemSingle.tsx | 38 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index a885bb0377daf..84b30006169eb 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -102,7 +102,7 @@ function getPrimaryAndSecondaryAvatar({ const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; - const usePersonalDetailsAvatars = !iouReport && chatReport && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + const usePersonalDetailsAvatars = !iouReport && chatReport && (!action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index aa75f2c84e776..4b25e5ebdc804 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -81,7 +81,7 @@ function ReportActionItemSingle({ showHeader = true, hasBeenFlagged = false, report, - iouReport, + iouReport: potentialIOUReport, isHovered = false, isActive = false, }: ReportActionItemSingleProps) { @@ -89,7 +89,15 @@ function ReportActionItemSingle({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const reportID = report?.reportID; + + const isReportAChatReport = report?.type === CONST.REPORT.TYPE.CHAT; + + const [reportChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + + const chatReport = isReportAChatReport ? report : reportChatReport; + const iouReport = potentialIOUReport ?? (!isReportAChatReport ? report : undefined); + + const reportID = chatReport?.reportID; const iouReportID = iouReport?.reportID; const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { @@ -101,13 +109,13 @@ function ReportActionItemSingle({ const reportPreviewSenderID = useReportPreviewSenderID({ iouReport, action, - chatReport: report, + chatReport, }); const isReportArchived = useReportIsArchived(iouReportID); const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ - chatReport: report, + chatReport, iouReport, action, personalDetails, @@ -116,22 +124,22 @@ function ReportActionItemSingle({ }); const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const isTripRoom = isTripRoomReportUtils(report); - const shouldDisplayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID; + const isTripRoom = isTripRoomReportUtils(chatReport); + const shouldDisplayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); - const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || shouldDisplayAllActors)); - const policyID = report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? iouReport?.policyID : report?.policyID; + const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || shouldDisplayAllActors)); + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? iouReport?.policyID : chatReport?.policyID; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); + const shouldShowSubscriptAvatar = shouldReportShowSubscript(chatReport, isReportArchived); const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const actorHint = isWorkspaceActor ? getPolicyName({report, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); + const actorHint = isWorkspaceActor ? getPolicyName({report: chatReport, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); @@ -165,9 +173,9 @@ function ReportActionItemSingle({ const shouldDisableDetailPage = useMemo( () => - CONST.RESTRICTED_ACCOUNT_IDS.includes(actorAccountID ?? CONST.DEFAULT_NUMBER_ID) || - (!isWorkspaceActor && isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : (actorAccountID ?? CONST.DEFAULT_NUMBER_ID))), - [action, isWorkspaceActor, actorAccountID], + CONST.RESTRICTED_ACCOUNT_IDS.includes(accountID ?? CONST.DEFAULT_NUMBER_ID) || + (!isWorkspaceActor && isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : (accountID ?? CONST.DEFAULT_NUMBER_ID))), + [action, isWorkspaceActor, accountID], ); const getBackgroundColor = () => { @@ -225,7 +233,7 @@ function ReportActionItemSingle({ Date: Tue, 22 Jul 2025 10:26:39 +0200 Subject: [PATCH 09/33] Remove MultipleAvatars & getAvatarsForAccountIDs --- src/components/MenuItem.tsx | 81 ++-- src/components/MultipleAvatars.tsx | 331 --------------- src/components/ReportActionItem/TaskView.tsx | 4 +- .../TransactionPreviewContent.tsx | 18 +- src/components/ReportAvatar.tsx | 389 +++++++++++++++++- .../SelectionList/InviteMemberListItem.tsx | 1 + .../SelectionList/TableListItem.tsx | 8 +- src/libs/OptionsListUtils.ts | 25 -- .../ScheduleCallConfirmationPage.tsx | 10 +- .../home/report/PureReportActionItem.tsx | 3 +- .../home/report/ReportActionItemCreated.tsx | 19 +- .../home/report/ReportActionItemSingle.tsx | 3 +- .../home/report/ReportActionItemThread.tsx | 16 +- .../FloatingActionButtonAndPopover.tsx | 3 +- src/pages/settings/InitialSettingsPage.tsx | 2 - src/pages/tasks/NewTaskPage.tsx | 4 +- src/pages/workspace/WorkspaceInitialPage.tsx | 4 +- .../workspace/WorkspaceInviteMessagePage.tsx | 9 +- 18 files changed, 436 insertions(+), 494 deletions(-) delete mode 100644 src/components/MultipleAvatars.tsx diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index ca0f125c3e42d..8bc2fa324437a 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -34,7 +34,6 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars'; import {MenuItemGroupContext} from './MenuItemGroup'; -import MultipleAvatars from './MultipleAvatars'; import PlaidCardFeedIcon from './PlaidCardFeedIcon'; import type {PressableRef} from './Pressable/GenericPressable/types'; import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; @@ -55,7 +54,7 @@ type IconProps = { type AvatarProps = { iconType?: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE | typeof CONST.ICON_TYPE_PLAID; - icon: AvatarSource | IconType[]; + icon?: AvatarSource | IconType[]; }; type NoIcon = { @@ -227,12 +226,6 @@ type MenuItemBaseProps = { /** Prop to represent the size of the avatar images to be shown */ avatarSize?: ValueOf; - /** Avatars to show on the right of the menu item */ - floatRightAvatars?: IconType[]; - - /** Prop to represent the size of the float right avatar images to be shown */ - floatRightAvatarSize?: ValueOf; - /** Affects avatar size */ viewMode?: ValueOf; @@ -263,6 +256,10 @@ type MenuItemBaseProps = { /** Should we remove the hover background color of the menu item */ shouldRemoveHoverBackground?: boolean; + rightIconAccountID?: number | string; + + iconAccountID?: number | string; + /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; @@ -376,8 +373,10 @@ type MenuItemBaseProps = { /** Plaid image for the bank */ plaidUrl?: string; + /** Report ID for the avatar */ iconReportID?: string; + /** Report ID for the avatar on the right */ rightIconReportID?: string; }; @@ -420,6 +419,8 @@ function MenuItem( fallbackIcon = Expensicons.FallbackAvatar, shouldShowTitleIcon = false, titleIcon, + rightIconAccountID, + iconAccountID, shouldShowRightIcon = false, iconRight = Expensicons.ArrowRight, furtherDetailsIcon, @@ -454,8 +455,6 @@ function MenuItem( shouldShowDescriptionOnTop = false, shouldShowRightComponent = false, rightComponent, - floatRightAvatars = [], - floatRightAvatarSize, rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, isSmallAvatarSubscriptMenu = false, @@ -514,8 +513,6 @@ function MenuItem( const isCompact = viewMode === CONST.OPTION_MODE.COMPACT; const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedback.deleted) : false; const descriptionVerticalMargin = shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1; - const fallbackAvatarSize = isCompact ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT; - const firstRightIcon = floatRightAvatars.at(0); const combinedTitleTextStyle = StyleUtils.combineStyles( [ @@ -533,7 +530,7 @@ function MenuItem( ], titleStyle ?? {}, ); - const shouldShowAvatar = !!icon && Array.isArray(icon); + const descriptionTextStyles = StyleUtils.combineStyles([ styles.textLabelSupporting, icon && !Array.isArray(icon) ? styles.ml3 : {}, @@ -706,15 +703,17 @@ function MenuItem( {!!label && isLabelHoverable && ( - + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + {label} )} - {shouldShowAvatar && - (iconReportID ? ( + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {iconReportID || + (iconAccountID !== undefined && ( - ) : ( - 0 ? [Number(iconAccountID)] : undefined} /> ))} {!icon && shouldPutLeftPaddingWhenNoIcon && ( @@ -913,31 +901,20 @@ function MenuItem( {subtitle} )} - {floatRightAvatars?.length > 0 && !!firstRightIcon && ( + {(!!rightIconAccountID || !!rightIconReportID) && ( - {rightIconReportID ? ( - - ) : ( - - )} + 0 ? [Number(rightIconAccountID)] : undefined} + isFocusMode + /> )} {!!brickRoadIndicator && ( diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx deleted file mode 100644 index fb88779f172c0..0000000000000 --- a/src/components/MultipleAvatars.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import React, {memo, useMemo} from 'react'; -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getUserDetailTooltipText} from '@libs/ReportUtils'; -import type {AvatarSource} from '@libs/UserUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; -import Avatar from './Avatar'; -import Text from './Text'; -import Tooltip from './Tooltip'; -import UserDetailsTooltip from './UserDetailsTooltip'; - -type MultipleAvatarsProps = { - /** Array of avatar URLs or icons */ - icons: Icon[]; - - /** Set the size of avatars */ - size?: ValueOf; - - /** Style for Second Avatar */ - secondAvatarStyle?: StyleProp; - - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon?: AvatarSource; - - /** Prop to identify if we should load avatars vertically instead of diagonally */ - shouldStackHorizontally?: boolean; - - /** Prop to identify if we should display avatars in rows */ - shouldDisplayAvatarsInRows?: boolean; - - /** Whether the avatars are hovered */ - isHovered?: boolean; - - /** Whether the avatars are active */ - isActive?: boolean; - - /** Whether the avatars are in an element being pressed */ - isPressed?: boolean; - - /** Whether #focus mode is on */ - isFocusMode?: boolean; - - /** Whether avatars are displayed within a reportAction */ - isInReportAction?: boolean; - - /** Whether to show the tooltip text */ - shouldShowTooltip?: boolean; - - /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ - shouldUseCardBackground?: boolean; - - /** Prop to limit the amount of avatars displayed horizontally */ - maxAvatarsInRow?: number; - - /** Prop to limit the amount of avatars displayed horizontally */ - overlapDivider?: number; -}; - -type AvatarStyles = { - singleAvatarStyle: ViewStyle & ImageStyle; - secondAvatarStyles: ViewStyle & ImageStyle; -}; - -type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; - -type AvatarSizeToStylesMap = Record; - -function MultipleAvatars({ - fallbackIcon, - icons = [], - size = CONST.AVATAR_SIZE.DEFAULT, - secondAvatarStyle: secondAvatarStyleProp, - shouldStackHorizontally = false, - shouldDisplayAvatarsInRows = false, - isHovered = false, - isActive = false, - isPressed = false, - isFocusMode = false, - isInReportAction = false, - shouldShowTooltip = true, - shouldUseCardBackground = false, - maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, - overlapDivider = 3, -}: MultipleAvatarsProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - - const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( - () => ({ - [CONST.AVATAR_SIZE.SMALL]: { - singleAvatarStyle: styles.singleAvatarSmall, - secondAvatarStyles: styles.secondAvatarSmall, - }, - [CONST.AVATAR_SIZE.LARGE]: { - singleAvatarStyle: styles.singleAvatarMedium, - secondAvatarStyles: styles.secondAvatarMedium, - }, - [CONST.AVATAR_SIZE.DEFAULT]: { - singleAvatarStyle: styles.singleAvatar, - secondAvatarStyles: styles.secondAvatar, - }, - }), - [styles], - ); - const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; - - let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); - const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); - - const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); - - const avatarSize = useMemo(() => { - if (isFocusMode) { - return CONST.AVATAR_SIZE.MID_SUBSCRIPT; - } - - if (size === CONST.AVATAR_SIZE.LARGE) { - return CONST.AVATAR_SIZE.MEDIUM; - } - - return CONST.AVATAR_SIZE.SMALLER; - }, [isFocusMode, size]); - - const avatarRows = useMemo(() => { - // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row - if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { - return [icons]; - } - - // Calculate the size of each row - const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); - - // Slice the icons array into two rows - const firstRow = icons.slice(0, rowSize); - const secondRow = icons.slice(rowSize); - - // Update the state with the two rows as an array - return [firstRow, secondRow]; - }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - - if (!icons.length) { - return null; - } - - if (icons.length === 1 && !shouldStackHorizontally) { - return ( - - - - - - ); - } - - const oneAvatarSize = StyleUtils.getAvatarStyle(size); - const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; - const overlapSize = oneAvatarSize.width / overlapDivider; - if (shouldStackHorizontally) { - // Height of one avatar + border space - const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; - avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); - } - - return shouldStackHorizontally ? ( - avatarRows.map((avatars, rowIndex) => ( - - {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( - - - - - - ))} - {avatars.length > maxAvatarsInRow && ( - - - - {`+${avatars.length - maxAvatarsInRow}`} - - - - )} - - )) - ) : ( - - - - {/* View is necessary for tooltip to show for multiple avatars in LHN */} - - - - - - {icons.length === 2 ? ( - - - - - - ) : ( - - - - {`+${icons.length - 1}`} - - - - )} - - - - ); -} - -MultipleAvatars.displayName = 'MultipleAvatars'; - -export default memo(MultipleAvatars); -export type {MultipleAvatarsProps}; diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 745f23e49a00c..dadb4af20de5a 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -21,7 +21,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import {getAvatarsForAccountIDs, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; +import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getDisplayNameForParticipant, getDisplayNamesWithTooltips, isCompletedTaskReport, isOpenTaskReport} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -179,7 +179,7 @@ function TaskView({report, parentReport, action}: TaskViewProps) { avatar.id); const isCardTransaction = isCardTransactionUtils(transaction); - if (isReportAPolicyExpenseChat && isBillSplit) { - sortedParticipantAvatars.push(getWorkspaceIcon(chatReport)); - } // Compute the from/to data only for IOU reports const {from, to} = useMemo(() => { @@ -249,10 +242,11 @@ function TransactionPreviewContent({ {previewHeaderText} {isBillSplit && ( - diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index 84b30006169eb..699c6a0e28703 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -1,5 +1,6 @@ -import React from 'react'; -import type {ColorValue, ViewStyle} from 'react-native'; +import lodashSortBy from 'lodash/sortBy'; +import React, {useMemo} from 'react'; +import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -9,7 +10,7 @@ import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getAvatarsForAccountIDs} from '@libs/OptionsListUtils'; +import localeCompare from '@libs/LocaleCompare'; import {getReportAction} from '@libs/ReportActionsUtils'; import { getDefaultWorkspaceAvatar, @@ -17,6 +18,7 @@ import { getIcons, getPolicyName, getReportActionActorAccountID, + getUserDetailTooltipText, getWorkspaceIcon, isChatReport, isIndividualInvoiceRoom, @@ -26,16 +28,18 @@ import { isTripRoom, shouldReportShowSubscript, } from '@libs/ReportUtils'; +import type {AvatarSource} from '@libs/UserUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; import Avatar from './Avatar'; import Icon from './Icon'; import {FallbackAvatar} from './Icon/Expensicons'; -import type {MultipleAvatarsProps} from './MultipleAvatars'; -import MultipleAvatars from './MultipleAvatars'; +import Text from './Text'; +import Tooltip from './Tooltip'; import UserDetailsTooltip from './UserDetailsTooltip'; type SubIcon = { @@ -52,7 +56,7 @@ type SubIcon = { fill?: string; }; -type ReportAvatarProps = MultipleAvatarsProps & { +type ReportAvatarProps = { /** IOU Report ID for single avatar */ reportID?: string; @@ -81,8 +85,92 @@ type ReportAvatarProps = MultipleAvatarsProps & { subscriptAvatarSize?: ValueOf; accountIDs?: number[]; + + reverseAvatars?: boolean; + + convertSubscriptToMultiple?: boolean; + + sortAvatarsByID?: boolean; + + sortAvatarsByName?: boolean; + + /** Set the size of avatars */ + size?: ValueOf; + + /** Style for Second Avatar */ + secondAvatarStyle?: StyleProp; + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIconForMultipleAvatars?: AvatarSource; + + /** Prop to identify if we should load avatars vertically instead of diagonally */ + shouldStackHorizontally?: boolean; + + /** Prop to identify if we should display avatars in rows */ + shouldDisplayAvatarsInRows?: boolean; + + /** Whether the avatars are hovered */ + isHovered?: boolean; + + /** Whether the avatars are active */ + isActive?: boolean; + + /** Whether the avatars are in an element being pressed */ + isPressed?: boolean; + + /** Whether #focus mode is on */ + isFocusMode?: boolean; + + /** Whether avatars are displayed within a reportAction */ + isInReportAction?: boolean; + + /** Whether to show the tooltip text */ + shouldShowTooltip?: boolean; + + /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ + shouldUseCardBackground?: boolean; + + /** Prop to limit the amount of avatars displayed horizontally */ + maxAvatarsInRow?: number; + + /** Prop to limit the amount of avatars displayed horizontally */ + overlapDivider?: number; +}; + +type AvatarStyles = { + singleAvatarStyle: ViewStyle & ImageStyle; + secondAvatarStyles: ViewStyle & ImageStyle; }; +type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; + +type AvatarSizeToStylesMap = Record; + +const getIconDisplayName = (icon: IconType, personalDetails: OnyxInputOrEntry) => + icon.id ? (personalDetails?.[icon.id]?.displayName ?? personalDetails?.[icon.id]?.login ?? '') : ''; + +function sortIconsByName(icons: IconType[], personalDetails: OnyxInputOrEntry) { + // const accountIDsWithDisplayName: Array<[number, string]> = []; + + // for (const accountID of accountIDs) { + // const displayNameLogin = personalDetails?.[accountID]?.displayName ? personalDetails?.[accountID]?.displayName : personalDetails?.[accountID]?.login; + // accountIDsWithDisplayName.push([accountID, displayNameLogin ?? '']); + // } + + return icons.sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = localeCompare(getIconDisplayName(first, personalDetails), getIconDisplayName(second, personalDetails)); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } + + // Then fallback on accountID as the final sorting criteria. + // This will ensure that the order of avatars with same login/displayName + // stay consistent across all users and devices + return Number(first?.id) - Number(second?.id); + }); +} + function getPrimaryAndSecondaryAvatar({ iouReport, action, @@ -100,14 +188,15 @@ function getPrimaryAndSecondaryAvatar({ }) { const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); const usePersonalDetailsAvatars = !iouReport && chatReport && (!action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? iouReport?.policyID : chatReport?.policyID; + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; const invoiceReceiverPolicy = @@ -240,9 +329,25 @@ function ReportAvatar({ subscriptAvatarSize = CONST.AVATAR_SIZE.SUBSCRIPT, accountIDs: passedAccountIDs, action: passedAction, - ...multipleAvatarsProps + reverseAvatars, + convertSubscriptToMultiple, + sortAvatarsByID, + sortAvatarsByName, + fallbackIconForMultipleAvatars, + size = CONST.AVATAR_SIZE.DEFAULT, + secondAvatarStyle: secondAvatarStyleProp, + shouldStackHorizontally = false, + shouldDisplayAvatarsInRows = false, + isHovered = false, + isActive = false, + isPressed = false, + isFocusMode = false, + isInReportAction = false, + shouldShowTooltip = true, + shouldUseCardBackground = false, + maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, + overlapDivider = 3, }: Omit) { - const {size = CONST.AVATAR_SIZE.DEFAULT, shouldShowTooltip = true} = multipleAvatarsProps; const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -265,7 +370,8 @@ function ReportAvatar({ const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; @@ -289,7 +395,77 @@ function ReportAvatar({ const isReportArchived = useReportIsArchived(reportID); const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); - if (shouldShowSubscriptAvatar) { + const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( + () => ({ + [CONST.AVATAR_SIZE.SMALL]: { + singleAvatarStyle: styles.singleAvatarSmall, + secondAvatarStyles: styles.secondAvatarSmall, + }, + [CONST.AVATAR_SIZE.LARGE]: { + singleAvatarStyle: styles.singleAvatarMedium, + secondAvatarStyles: styles.secondAvatarMedium, + }, + [CONST.AVATAR_SIZE.DEFAULT]: { + singleAvatarStyle: styles.singleAvatar, + secondAvatarStyles: styles.secondAvatar, + }, + }), + [styles], + ); + + const avatarsForAccountIDs: IconType[] = (passedAccountIDs ?? []).map((id) => ({ + id, + type: CONST.ICON_TYPE_AVATAR, + source: personalDetails?.[id]?.avatar ?? FallbackAvatar, + name: personalDetails?.[id]?.login ?? '', + })); + + const multipleAvatars = avatarsForAccountIDs.length > 0 ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar]; + const sortedAvatars = (() => { + if (sortAvatarsByName) { + return sortIconsByName(multipleAvatars, personalDetails); + } + return sortAvatarsByID ? lodashSortBy(multipleAvatars, (icon) => icon.id) : multipleAvatars; + })(); + const icons = reverseAvatars ? sortedAvatars.reverse() : sortedAvatars; + + const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; + + let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); + const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); + + const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); + + const avatarSize = useMemo(() => { + if (isFocusMode) { + return CONST.AVATAR_SIZE.MID_SUBSCRIPT; + } + + if (size === CONST.AVATAR_SIZE.LARGE) { + return CONST.AVATAR_SIZE.MEDIUM; + } + + return CONST.AVATAR_SIZE.SMALLER; + }, [isFocusMode, size]); + + const avatarRows = useMemo(() => { + // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row + if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { + return [icons]; + } + + // Calculate the size of each row + const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); + + // Slice the icons array into two rows + const firstRow = icons.slice(0, rowSize); + const secondRow = icons.slice(rowSize); + + // Update the state with the two rows as an array + return [firstRow, secondRow]; + }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); + + if (shouldShowSubscriptAvatar && !shouldStackHorizontally) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; const containerStyle = StyleUtils.getContainerStyles(size); @@ -378,15 +554,186 @@ function ReportAvatar({ } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (passedAccountIDs || (shouldDisplayAllActors && !reportPreviewSenderID)) { - const icons = getAvatarsForAccountIDs(passedAccountIDs ?? [], personalDetails); + if (passedAccountIDs || (shouldDisplayAllActors && !reportPreviewSenderID) || (convertSubscriptToMultiple && shouldShowSubscriptAvatar && !reportPreviewSenderID)) { + if (!icons.length) { + return null; + } - return ( - 0 ? icons : [primaryAvatar, secondaryAvatar]} - /> + if (icons.length === 1 && !shouldStackHorizontally) { + return ( + + + + + + ); + } + + const oneAvatarSize = StyleUtils.getAvatarStyle(size); + const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; + const overlapSize = oneAvatarSize.width / overlapDivider; + if (shouldStackHorizontally) { + // Height of one avatar + border space + const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; + avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); + } + + return shouldStackHorizontally ? ( + avatarRows.map((avatars, rowIndex) => ( + + {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( + + + + + + ))} + {avatars.length > maxAvatarsInRow && ( + + + + {`+${avatars.length - maxAvatarsInRow}`} + + + + )} + + )) + ) : ( + + + + {/* View is necessary for tooltip to show for multiple avatars in LHN */} + + + + + + {icons.length === 2 ? ( + + + + + + ) : ( + + + + {`+${icons.length - 1}`} + + + + )} + + + ); } diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index 5726dad8cebd8..d7381912f1dc3 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -121,6 +121,7 @@ function InviteMemberListItem({ hovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} reportID={item.reportID} + accountIDs={!item.reportID && item.accountID ? [item.accountID] : undefined} /> )} diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 709b95a50451f..75e5c84b7b4d9 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -2,8 +2,8 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import ReportAvatar from '@components/ReportAvatar'; import TextWithTooltip from '@components/TextWithTooltip'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -107,9 +107,9 @@ function TableListItem({ )} - {!!item.icons && ( - (reportAttributesDerivedValue = value?.reports), }); -/** - * @param defaultValues {login: accountID} In workspace invite page, when new user is added we pass available data to opt in - * @returns Returns avatar data for a list of user accountIDs - */ -function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntry, defaultValues: Record = {}): Icon[] { - const reversedDefaultValues: Record = {}; - - Object.entries(defaultValues).forEach((item) => { - reversedDefaultValues[item[1]] = item[0]; - }); - - return accountIDs.map((accountID) => { - const login = reversedDefaultValues[accountID] ?? ''; - const userPersonalDetail = personalDetails?.[accountID] ?? {login, accountID}; - - return { - id: accountID, - source: userPersonalDetail.avatar ?? FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: userPersonalDetail.login ?? '', - }; - }); -} - /** * Returns the personal details for an array of accountIDs * @returns keys of the object are emails, values are PersonalDetails objects. @@ -2613,7 +2589,6 @@ function shallowOptionsListCompare(a: OptionList, b: OptionList): boolean { } export { - getAvatarsForAccountIDs, isCurrentUser, isPersonalDetailsReady, getValidOptions, diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index d3a2721de4aa2..323f62af32a1a 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -5,7 +5,6 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOffli import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -106,14 +105,7 @@ function ScheduleCallConfirmationPage() { description={guideDetails?.login} label={translate('scheduledCall.confirmation.setupSpecialist')} interactive={false} - icon={[ - { - id: guideDetails?.accountID, - source: guideDetails?.avatarThumbnail ?? guideDetails?.avatar ?? guideDetails?.fallbackIcon ?? FallbackAvatar, - name: guideDetails?.login, - type: CONST.ICON_TYPE_AVATAR, - }, - ]} + iconAccountID={guideDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID} /> diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index e5da3655509a0..9acaa40bfa5a3 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -1,15 +1,15 @@ import React, {memo} from 'react'; import {View} from 'react-native'; -import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import ReportAvatar from '@components/ReportAvatar'; import ReportWelcomeText from '@components/ReportWelcomeText'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {getIcons, isChatReport, isCurrentUserInvoiceReceiver, isInvoiceRoom, navigateToDetailsPage, shouldDisableDetailPage as shouldDisableDetailPageReportUtils} from '@libs/ReportUtils'; +import {isChatReport, isCurrentUserInvoiceReceiver, isInvoiceRoom, navigateToDetailsPage, shouldDisableDetailPage as shouldDisableDetailPageReportUtils} from '@libs/ReportUtils'; import {clearCreateChatError} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -28,24 +28,15 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); - const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined}`, { - canBeMissing: true, - }); if (!isChatReport(report)) { return null; } - let icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report); - if (isInvoiceRoom(report) && isCurrentUserInvoiceReceiver(report)) { - icons = [...icons].reverse(); - } - return ( - diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 4b25e5ebdc804..3173317fcc31a 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -125,7 +125,8 @@ function ReportActionItemSingle({ const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const isTripRoom = isTripRoomReportUtils(chatReport); diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index 46e19c646e086..33e767f98b390 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -1,8 +1,8 @@ import React from 'react'; import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; -import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import ReportAvatar from '@components/ReportAvatar'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,12 +11,8 @@ import Timing from '@libs/actions/Timing'; import Performance from '@libs/Performance'; import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; type ReportActionItemThreadProps = { - /** List of participant icons for the thread */ - icons: Icon[]; - /** Number of comments under the thread */ numberOfReplies: number; @@ -35,11 +31,14 @@ type ReportActionItemThreadProps = { /** Whether the thread item / message is active */ isActive?: boolean; + /** Account IDs used for avatars */ + accountIDs: number[]; + /** The function that should be called when the thread is LongPressed or right-clicked */ onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => void; }; -function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, reportID, reportAction, isHovered, onSecondaryInteraction, isActive}: ReportActionItemThreadProps) { +function ReportActionItemThread({numberOfReplies, accountIDs, mostRecentReply, reportID, reportAction, isHovered, onSecondaryInteraction, isActive}: ReportActionItemThreadProps) { const styles = useThemeStyles(); const {translate, datetimeToCalendarTime} = useLocalize(); @@ -62,9 +61,10 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, report onSecondaryInteraction={onSecondaryInteraction} > - openPopover(item.link, event) : undefined} diff --git a/src/pages/tasks/NewTaskPage.tsx b/src/pages/tasks/NewTaskPage.tsx index 391750de3717c..8cbe743b0a634 100644 --- a/src/pages/tasks/NewTaskPage.tsx +++ b/src/pages/tasks/NewTaskPage.tsx @@ -152,7 +152,7 @@ function NewTaskPage({route}: NewTaskPageProps) { label={assignee?.displayName ? translate('task.assignee') : ''} title={assignee?.displayName ?? ''} description={assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} - icon={assignee?.icons} + iconAccountID={task?.assigneeAccountID} onPress={() => Navigation.navigate(ROUTES.NEW_TASK_ASSIGNEE.getRoute(backTo))} shouldShowRightIcon titleWithTooltips={assigneeTooltipDetails} @@ -161,7 +161,7 @@ function NewTaskPage({route}: NewTaskPageProps) { label={shareDestination?.displayName ? translate('common.share') : ''} title={shareDestination?.displayName ?? ''} description={shareDestination?.displayName ? shareDestination.subtitle : translate('common.share')} - icon={shareDestination?.icons} + iconReportID={task?.shareDestination} onPress={() => Navigation.navigate(ROUTES.NEW_TASK_SHARE_DESTINATION)} interactive={!task?.parentReportID} shouldShowRightIcon={!task?.parentReportID} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fe53ab346d21a..a73787d36455b 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -60,7 +60,7 @@ import { shouldShowSyncError, shouldShowTaxRateError, } from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar, getIcons, getPolicyExpenseChat, getReportName, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import {getDefaultWorkspaceAvatar, getPolicyExpenseChat, getReportName, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import type WORKSPACE_TO_RHP from '@navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; @@ -112,7 +112,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`, {canBeMissing: true}); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email, canBeMissing: false}); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params?.policyID}`, {canBeMissing: true}); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const cardsDomainIDs = Object.values(getCompanyFeeds(cardFeeds)) .map((data) => data.domainID) .filter((domainID): domainID is number => !!domainID); @@ -495,7 +494,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} shouldShowRightIcon wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index 6276855e14352..a584511b7886f 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -6,8 +6,8 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import ReportAvatar from '@components/ReportAvatar'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -26,7 +26,6 @@ import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getAvatarsForAccountIDs} from '@libs/OptionsListUtils'; import {getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -63,7 +62,6 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: canBeMissing: true, }); const [workspaceInviteRoleDraft = CONST.POLICY.ROLE.USER] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); - const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const isOnyxLoading = isLoadingOnyxValue(workspaceInviteMessageDraftResult, invitedEmailsToAccountIDsDraftResult, formDataResult); const welcomeNoteSubject = useMemo( @@ -187,9 +185,10 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: } > - Date: Tue, 22 Jul 2025 10:37:50 +0200 Subject: [PATCH 10/33] TS & Prettier minor fixes --- .../SelectionList/Search/ReportListItemHeader.tsx | 9 +-------- .../SelectionList/Search/TransactionGroupListItem.tsx | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/SelectionList/Search/ReportListItemHeader.tsx b/src/components/SelectionList/Search/ReportListItemHeader.tsx index cee5e8bfbb37e..88f77bdc84748 100644 --- a/src/components/SelectionList/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionList/Search/ReportListItemHeader.tsx @@ -143,14 +143,7 @@ function HeaderFirstRow({ ); } -function ReportListItemHeader({ - report: reportItem, - onSelectRow, - onCheckboxPress, - isDisabled, - isFocused, - canSelectMultiple, -}: ReportListItemHeaderProps) { +function ReportListItemHeader({report: reportItem, onSelectRow, onCheckboxPress, isDisabled, isFocused, canSelectMultiple}: ReportListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); diff --git a/src/components/SelectionList/Search/TransactionGroupListItem.tsx b/src/components/SelectionList/Search/TransactionGroupListItem.tsx index 84206bad286be..3afc999933ea1 100644 --- a/src/components/SelectionList/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionList/Search/TransactionGroupListItem.tsx @@ -141,7 +141,7 @@ function TransactionGroupListItem({ } return headers[groupBy]; - }, [groupItem, policy, onSelectRow, onCheckboxPress, isDisabledOrEmpty, isFocused, canSelectMultiple, groupBy]); + }, [groupItem, onSelectRow, onCheckboxPress, isDisabledOrEmpty, isFocused, canSelectMultiple, groupBy]); const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); From 7d917decd71766c3daa202e024661a0cc1df1944 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 22 Jul 2025 14:23:40 +0200 Subject: [PATCH 11/33] Fix Search avatars --- src/components/MenuItem.tsx | 33 +++++++++++---------- src/components/ReportAvatar.tsx | 41 ++++++++++++++------------- src/hooks/useReportPreviewSenderID.ts | 6 +++- src/pages/ReportDetailsPage.tsx | 2 +- 4 files changed, 43 insertions(+), 39 deletions(-) diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 8bc2fa324437a..b7c6feba3fac4 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -712,23 +712,22 @@ function MenuItem( )} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} - {iconReportID || - (iconAccountID !== undefined && ( - 0 ? [Number(iconAccountID)] : undefined} - /> - ))} + {(!!iconReportID || !!iconAccountID) && ( + 0 ? [Number(iconAccountID)] : undefined} + /> + )} {!icon && shouldPutLeftPaddingWhenNoIcon && ( ) { - // const accountIDsWithDisplayName: Array<[number, string]> = []; - - // for (const accountID of accountIDs) { - // const displayNameLogin = personalDetails?.[accountID]?.displayName ? personalDetails?.[accountID]?.displayName : personalDetails?.[accountID]?.login; - // accountIDsWithDisplayName.push([accountID, displayNameLogin ?? '']); - // } - return icons.sort((first, second) => { // First sort by displayName/login const displayNameLoginOrder = localeCompare(getIconDisplayName(first, personalDetails), getIconDisplayName(second, personalDetails)); @@ -191,8 +185,6 @@ function getPrimaryAndSecondaryAvatar({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); - const usePersonalDetailsAvatars = !iouReport && chatReport && (!action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); - const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; @@ -204,11 +196,16 @@ function getPrimaryAndSecondaryAvatar({ const {avatar, fallbackIcon} = personalDetails?.[accountID] ?? {}; + const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const usePersonalDetailsAvatars = ((!iouReport && chatReport) || (!chatReport && isWorkspaceChat)) && (!action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); + const isATripRoom = isTripRoom(chatReport); // 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 && !isATripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; + const displayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); - const isWorkspaceActor = isAInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || displayAllActors)); + const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || displayAllActors)); const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; @@ -230,6 +227,8 @@ function getPrimaryAndSecondaryAvatar({ fallbackIcon, }; + const reportIcons = getIcons(chatReport ?? iouReport, personalDetails, undefined, undefined, undefined, policy); + const getPrimaryAvatar = () => { if (isWorkspaceActor) { return { @@ -288,8 +287,7 @@ function getPrimaryAndSecondaryAvatar({ if (!isWorkspaceActor) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const avatarIconIndex = chatReport?.isOwnPolicyExpenseChat || isPolicyExpenseChat(chatReport) ? 0 : 1; - const reportIcons = getIcons(chatReport, personalDetails, undefined, undefined, undefined, policy); + const avatarIconIndex = chatReport?.isOwnPolicyExpenseChat || isWorkspaceChat ? 0 : 1; return reportIcons.at(avatarIconIndex) ?? defaultSecondaryAvatar; } @@ -310,10 +308,8 @@ function getPrimaryAndSecondaryAvatar({ return defaultSecondaryAvatar; }; - const icons = getIcons(chatReport ?? iouReport, personalDetails); - - const primaryAvatar = (usePersonalDetailsAvatars ? icons.at(0) : getPrimaryAvatar()) ?? defaultAvatar; - const secondaryAvatar = (usePersonalDetailsAvatars ? icons.at(1) : getSecondaryAvatar()) ?? defaultSecondaryAvatar; + const primaryAvatar = (usePersonalDetailsAvatars ? reportIcons.at(0) : getPrimaryAvatar()) ?? defaultAvatar; + const secondaryAvatar = (usePersonalDetailsAvatars ? reportIcons.at(1) : getSecondaryAvatar()) ?? defaultSecondaryAvatar; return [primaryAvatar, secondaryAvatar]; } @@ -364,6 +360,11 @@ function ReportAvatar({ const iouReport = isChatReport(report) ? undefined : report; const chatReport = isChatReport(report) ? report : potentialChatReport; + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); + const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); @@ -379,9 +380,9 @@ function ReportAvatar({ const isATripRoom = isTripRoom(chatReport); // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; + const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); - const isWorkspaceActor = isAInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || shouldDisplayAllActors)); + const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ chatReport, @@ -465,7 +466,7 @@ function ReportAvatar({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - if (shouldShowSubscriptAvatar && !shouldStackHorizontally) { + if (shouldShowSubscriptAvatar && !shouldStackHorizontally && !isChatThread(chatReport)) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; const containerStyle = StyleUtils.getContainerStyles(size); diff --git a/src/hooks/useReportPreviewSenderID.ts b/src/hooks/useReportPreviewSenderID.ts index 92e583caf145f..a707a1f58d534 100644 --- a/src/hooks/useReportPreviewSenderID.ts +++ b/src/hooks/useReportPreviewSenderID.ts @@ -43,6 +43,10 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), }); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`, { + canBeMissing: true, + }); + // 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 @@ -62,7 +66,7 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx 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 && isDM(chatReport); + const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1 && (chatReport ? isDM(chatReport) : policy?.type === CONST.POLICY.TYPE.PERSONAL); const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 7478221c3c0e1..03b3e445e8d90 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -577,7 +577,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail subscriptAvatarSize={CONST.AVATAR_SIZE.HEADER} singleAvatarSize={CONST.AVATAR_SIZE.X_LARGE} size={CONST.AVATAR_SIZE.LARGE} - reportID={moneyRequestReport?.reportID ?? report?.reportID} + reportID={report?.reportID ?? moneyRequestReport?.reportID} /> ); From 1addb68af2b8626c68931bf77cc840cb2274aa42 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 23 Jul 2025 12:52:17 +0200 Subject: [PATCH 12/33] Fix travel previews --- src/components/ReportAvatar.tsx | 53 ++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index 06ed3d3fb450a..bf09ab8a3934d 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -196,12 +196,18 @@ function getPrimaryAndSecondaryAvatar({ const {avatar, fallbackIcon} = personalDetails?.[accountID] ?? {}; - const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); + const isATripRoom = isTripRoom(chatReport); + const isWorkspaceWithoutChatReportProp = !chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL; + const isWorkspaceChat = isPolicyExpenseChat(chatReport) || isWorkspaceWithoutChatReportProp; + const isChatReportOnlyProp = !iouReport && chatReport; + const isWorkspaceChatWithoutChatReport = !chatReport && isWorkspaceChat; + const isTripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; + const isReportPreviewOrNoAction = !action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + const isReportPreviewInTripRoom = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isATripRoom; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const usePersonalDetailsAvatars = ((!iouReport && chatReport) || (!chatReport && isWorkspaceChat)) && (!action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); + const usePersonalDetailsAvatars = (isChatReportOnlyProp || isWorkspaceChatWithoutChatReport) && isReportPreviewOrNoAction && !isTripPreview; - const isATripRoom = isTripRoom(chatReport); // 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 && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); @@ -262,6 +268,16 @@ function getPrimaryAndSecondaryAvatar({ }; const getSecondaryAvatar = () => { + if (isTripPreview || isReportPreviewInTripRoom) { + return { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + source: policy?.avatarURL || getDefaultWorkspaceAvatar(policy?.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: policy?.name, + id: policy?.id, + }; + } + // If this is a report preview, display names and avatars of both people involved if (displayAllActors) { const secondaryAccountId = ownerAccountID === actorAccountID || isAInvoiceReport ? actorAccountID : ownerAccountID; @@ -315,7 +331,7 @@ function getPrimaryAndSecondaryAvatar({ } function ReportAvatar({ - reportID, + reportID: potentialReportID, singleAvatarContainerStyle, singleAvatarSize, subscriptBorderColor, @@ -348,6 +364,10 @@ function ReportAvatar({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const reportID = + potentialReportID ?? + ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === passedAction?.actionName) ? passedAction?.childReportID : undefined); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); const [potentialChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); @@ -357,16 +377,19 @@ function ReportAvatar({ }); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - const iouReport = isChatReport(report) ? undefined : report; - const chatReport = isChatReport(report) ? report : potentialChatReport; + const iouReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? undefined : report; + const chatReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? report : potentialChatReport; const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); - const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); + const isATripRoom = isTripRoom(chatReport); + const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); + const isTripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; + const isReportPreviewInTripRoom = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isATripRoom; + const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; @@ -378,11 +401,16 @@ function ReportAvatar({ const {fallbackIcon} = personalDetails?.[accountID] ?? {}; - const isATripRoom = isTripRoom(chatReport); // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const isReportArchived = useReportIsArchived(reportID); + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); + const shouldShowAllActors = shouldDisplayAllActors && !reportPreviewSenderID; + const shouldShowConvertedSubscriptAvatar = convertSubscriptToMultiple && shouldShowSubscriptAvatar && !reportPreviewSenderID; + const isReportPreviewOrNoAction = !action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ chatReport, @@ -393,9 +421,6 @@ function ReportAvatar({ policies, }); - const isReportArchived = useReportIsArchived(reportID); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); - const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( () => ({ [CONST.AVATAR_SIZE.SMALL]: { @@ -466,7 +491,7 @@ function ReportAvatar({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - if (shouldShowSubscriptAvatar && !shouldStackHorizontally && !isChatThread(chatReport)) { + if (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && !shouldStackHorizontally && !isChatThreadOutsideTripRoom) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; const containerStyle = StyleUtils.getContainerStyles(size); @@ -555,7 +580,7 @@ function ReportAvatar({ } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (passedAccountIDs || (shouldDisplayAllActors && !reportPreviewSenderID) || (convertSubscriptToMultiple && shouldShowSubscriptAvatar && !reportPreviewSenderID)) { + if (passedAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar) { if (!icons.length) { return null; } From 76a83e91908888719fa63b6241586c9b98da0ea3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 23 Jul 2025 13:53:35 +0200 Subject: [PATCH 13/33] Fix avatar size in details page --- src/pages/ReportDetailsPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 03b3e445e8d90..cfe254be98071 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -575,8 +575,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail From 99f551c884c81fbc9e933392ad152d08f9c376c9 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 23 Jul 2025 15:16:21 +0200 Subject: [PATCH 14/33] Create ReportAvatarTest draft --- __mocks__/reportData/reports.ts | 12 ++-- src/components/ReportAvatar.tsx | 36 ++++++++---- tests/ui/ReportAvatarTest.tsx | 97 +++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 tests/ui/ReportAvatarTest.tsx diff --git a/__mocks__/reportData/reports.ts b/__mocks__/reportData/reports.ts index 59f5e0759e5a3..7e9c8c7fca583 100644 --- a/__mocks__/reportData/reports.ts +++ b/__mocks__/reportData/reports.ts @@ -1,7 +1,7 @@ import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; -const usersIDs = [15593135, 51760358, 26502375]; +const usersIDs = [15593135, 51760358, 26502375] as const; const amount = 10402; const currency = CONST.CURRENCY.USD; @@ -20,7 +20,7 @@ const participants = usersIDs.reduce((prev, userID) => { }; }, {}); -const iouReportR14932: Report = { +const iouReportR14932 = { currency, participants, total: amount, @@ -31,9 +31,9 @@ const iouReportR14932: Report = { parentReportActionID: PARENT_REPORT_ACTION_ID_R14932, parentReportID: PARENT_REPORT_ID_R14932, reportID: REPORT_ID_R14932, - lastActorAccountID: usersIDs.at(0), - ownerAccountID: usersIDs.at(0), - managerID: usersIDs.at(1), + lastActorAccountID: usersIDs[0], + ownerAccountID: usersIDs[0], + managerID: usersIDs[1], permissions: [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE], policyID: CONST.POLICY.ID_FAKE, reportName: CONST.REPORT.ACTIONS.TYPE.IOU, @@ -60,7 +60,7 @@ const iouReportR14932: Report = { welcomeMessage: '', description: '', oldPolicyName: '', -}; +} satisfies Report; const chatReportR14932: Report = { currency, diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index bf09ab8a3934d..afd54e7b4c12b 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -359,7 +359,7 @@ function ReportAvatar({ shouldUseCardBackground = false, maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, overlapDivider = 3, -}: Omit) { +}: ReportAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -501,7 +501,7 @@ function ReportAvatar({ return ( - + - + {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( - + - + {/* View is necessary for tooltip to show for multiple avatars in LHN */} - + - + - + - + ({ + source: svg, + type: CONST.ICON_TYPE_AVATAR, +}); + +const defaultProperties: Parameters[0] = { + reportID: undefined, + action: undefined, + accountIDs: undefined, + subIcon, + subscriptFallbackIcon: SVGToAvatar(MessageInABottle), + reverseAvatars: false, + convertSubscriptToMultiple: false, + sortAvatarsByID: false, + sortAvatarsByName: false, + fallbackIconForMultipleAvatars: MoneyWaving, + shouldStackHorizontally: false, + shouldDisplayAvatarsInRows: false, + shouldShowTooltip: true, + maxAvatarsInRow: CONST.AVATAR_ROW_SIZE.DEFAULT, + overlapDivider: 3, +}; + +const reportActions = [{[actionR14932.reportActionID]: actionR14932}, {[actionR98765.reportActionID]: actionR98765}]; +const transactions = [transactionR14932, transactionR98765]; +const reports = [iouReportR14932, chatReportR14932]; +const policies = [policy420A]; + +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 reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, reports, (report) => report.reportID); +const policiesCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, policies, (item) => item.id); + +const personalDetailsWithChangedAvatar = {...personalDetails, [loggedUserID]: {...personalDetails[loggedUserID], avatar: MoneyWaving}}; + +function renderAvatar(props: Parameters[0]) { + return render( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + , + ); +} + +describe('ReportAvatar', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: loggedUserID, email: personalDetails[loggedUserID].login}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetailsWithChangedAvatar, + [ONYXKEYS.COLLECTION.POLICY]: policiesCollectionDataSet, + [ONYXKEYS.COLLECTION.TRANSACTION]: transactionCollectionDataSet, + [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: reportActionCollectionDataSet, + [ONYXKEYS.COLLECTION.REPORT]: reportCollectionDataSet, + }, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + it('renders properly when single account ID is passed', async () => { + renderAvatar({...defaultProperties, accountIDs: [loggedUserID]}); + + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(`ReportAvatar-MultipleAvatars-OneIcon--${loggedUserID}`)).toBeOnTheScreen(); + }); + + afterAll(async () => { + await Onyx.clear(); + }); +}); From c9542af6294ceba026e7c274164e38ccc85cb391 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 24 Jul 2025 16:00:58 +0200 Subject: [PATCH 15/33] Add test cases for ReportAvatarTest --- src/components/Avatar.tsx | 6 +- src/components/ReportAvatar.tsx | 37 +- tests/ui/MoneyRequestReportPreview.test.tsx | 2 +- tests/ui/ReportAvatarTest.tsx | 446 ++++++++++++++++-- tests/unit/SidebarUtilsTest.ts | 3 +- ...rID.ts => useReportPreviewSenderIDTest.ts} | 0 6 files changed, 422 insertions(+), 72 deletions(-) rename tests/unit/{useReportPreviewSenderID.ts => useReportPreviewSenderIDTest.ts} (100%) diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 3a3a151631c8f..2b14378c44a40 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -53,6 +53,9 @@ type AvatarProps = { /** Optional account id if it's user avatar or policy id if it's workspace avatar */ avatarID?: number | string; + + /** Test ID for the Avatar component */ + testID?: string; }; function Avatar({ @@ -67,6 +70,7 @@ function Avatar({ type, name = '', avatarID, + testID = 'Avatar', }: AvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -105,7 +109,7 @@ function Avatar({ return ( {typeof avatarSource === 'string' ? ( diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index afd54e7b4c12b..4b7d9ba39d029 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -403,7 +403,7 @@ function ReportAvatar({ // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. const isReportArchived = useReportIsArchived(reportID); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived) && policy.type !== CONST.POLICY.TYPE.PERSONAL; const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); @@ -491,7 +491,12 @@ function ReportAvatar({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - if (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && !shouldStackHorizontally && !isChatThreadOutsideTripRoom) { + if ( + ((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && + !shouldStackHorizontally && + !isChatThreadOutsideTripRoom && + !shouldShowConvertedSubscriptAvatar + ) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; const containerStyle = StyleUtils.getContainerStyles(size); @@ -511,7 +516,7 @@ function ReportAvatar({ displayName: mainAvatar?.name, }} > - + @@ -534,7 +540,6 @@ function ReportAvatar({ // Hover on overflowed part of icon will not work on Electron if dragArea is true // https://stackoverflow.com/questions/56338939/hover-in-css-is-not-working-with-electron dataSet={{dragArea: false}} - testID={`ReportAvatar-Subscript-SecondaryAvatar--${secondaryAvatar.id}`} > @@ -566,7 +572,6 @@ function ReportAvatar({ // Hover on overflowed part of icon will not work on Electron if dragArea is true // https://stackoverflow.com/questions/56338939/hover-in-css-is-not-working-with-electron dataSet={{dragArea: false}} - testID="ReportAvatar-Subscript-SubIcon" > )} @@ -597,10 +603,7 @@ function ReportAvatar({ }} shouldRender={shouldShowTooltip} > - + @@ -642,10 +646,7 @@ function ReportAvatar({ }} shouldRender={shouldShowTooltip} > - + @@ -721,7 +723,7 @@ function ReportAvatar({ shouldRender={shouldShowTooltip} > {/* View is necessary for tooltip to show for multiple avatars in LHN */} - + @@ -743,7 +746,7 @@ function ReportAvatar({ }} shouldRender={shouldShowTooltip} > - + @@ -785,7 +789,7 @@ function ReportAvatar({ delegateAccountID={action?.delegateAccountID} icon={primaryAvatar} > - + diff --git a/tests/ui/MoneyRequestReportPreview.test.tsx b/tests/ui/MoneyRequestReportPreview.test.tsx index 9ebe79e677588..12c72555a0330 100644 --- a/tests/ui/MoneyRequestReportPreview.test.tsx +++ b/tests/ui/MoneyRequestReportPreview.test.tsx @@ -72,7 +72,7 @@ const renderPage = ({isWhisper = false, isHovered = false, contextMenuAnchor = n policies={{}} policyID={mockChatReport.policyID} action={mockAction} - iouReportID={mockIOUReport.iouReportID} + iouReportID={mockIOUReport.reportID} chatReportID={mockChatReport.chatReportID} contextMenuAnchor={contextMenuAnchor} checkIfContextMenuActive={() => {}} diff --git a/tests/ui/ReportAvatarTest.tsx b/tests/ui/ReportAvatarTest.tsx index 798aa786852bc..729b6f5704e41 100644 --- a/tests/ui/ReportAvatarTest.tsx +++ b/tests/ui/ReportAvatarTest.tsx @@ -1,62 +1,274 @@ import {render, screen} from '@testing-library/react-native'; -import type {FC} from 'react'; +import {View as MockedAvatarData} from 'react-native'; import Onyx from 'react-native-onyx'; -import {MagnifyingGlassSpyMouthClosed, MessageInABottle, MoneyWaving} from '@components/Icon/Expensicons'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ReportAvatar from '@components/ReportAvatar'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import type {AvatarSource} from '@libs/UserUtils'; +import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; -import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; +import type IconAsset from '@src/types/utils/IconAsset'; +import {actionR14932} 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, transactionR98765} from '../../__mocks__/reportData/transactions'; +import {transactionR14932} from '../../__mocks__/reportData/transactions'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -const loggedUserID = iouReportR14932.ownerAccountID; +type AvatarProps = { + source?: AvatarSource; + name?: string; + avatarID?: number | string; + testID?: string; +}; + +type AvatarData = { + uri: string; + avatarID?: number; + name?: string; + parent: string; +}; -const subIcon = { - source: MagnifyingGlassSpyMouthClosed, - width: 20, - height: 20, - fill: '', +/* --- UI Mocks --- */ + +const parseSource = (source: AvatarSource | IconAsset): string => { + if (typeof source === 'string') { + return source; + } + if (typeof source === 'object' && 'name' in source) { + return source.name as string; + } + if (typeof source === 'object' && 'uri' in source) { + return source.uri ?? 'No Source'; + } + if (typeof source === 'function') { + // If the source is a function, we assume it's an SVG component + return source.name; + } + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return source?.toString() ?? 'No Source'; }; -const SVGToAvatar = (svg: FC) => ({ - source: svg, - type: CONST.ICON_TYPE_AVATAR, +jest.mock('@src/components/Avatar', () => { + return ({source, name, avatarID, testID = 'Avatar'}: AvatarProps) => { + return ( + + ); + }; +}); + +jest.mock('@src/components/Icon', () => { + return ({src, testID = 'Avatar'}: {src: IconAsset; testID?: string}) => { + return ( + + ); + }; }); -const defaultProperties: Parameters[0] = { - reportID: undefined, - action: undefined, - accountIDs: undefined, - subIcon, - subscriptFallbackIcon: SVGToAvatar(MessageInABottle), - reverseAvatars: false, - convertSubscriptToMultiple: false, - sortAvatarsByID: false, - sortAvatarsByName: false, - fallbackIconForMultipleAvatars: MoneyWaving, - shouldStackHorizontally: false, - shouldDisplayAvatarsInRows: false, - shouldShowTooltip: true, - maxAvatarsInRow: CONST.AVATAR_ROW_SIZE.DEFAULT, - overlapDivider: 3, -}; - -const reportActions = [{[actionR14932.reportActionID]: actionR14932}, {[actionR98765.reportActionID]: actionR98765}]; -const transactions = [transactionR14932, transactionR98765]; -const reports = [iouReportR14932, chatReportR14932]; -const policies = [policy420A]; +/* --- Data Mocks --- */ + +const LOGGED_USER_ID = iouReportR14932.ownerAccountID; +const SECOND_USER_ID = iouReportR14932.managerID; + +const policy = { + ...policy420A, + name: 'XYZ', + id: 'WORKSPACE_POLICY', +}; + +const personalPolicy = { + ...policy420A, + name: 'Test user expenses', + id: 'PERSONAL_POLICY', + type: CONST.POLICY.TYPE.PERSONAL, +}; + +const chatReport = { + ...chatReportR14932, + reportID: 'CHAT_REPORT', + policyID: policy.id, +}; + +const reportChatDM = { + ...chatReportR14932, + chatType: undefined, + reportID: 'CHAT_REPORT_DM', + policyID: personalPolicy.id, +}; + +const reportPreviewAction = { + ...actionR14932, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: 'REPORT_PREVIEW', + childReportID: 'IOU_REPORT', +}; + +const reportPreviewDMAction = { + ...actionR14932, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: 'REPORT_PREVIEW_DM', + childReportID: 'IOU_REPORT_DM', +}; + +const reportPreviewSingleTransactionDMAction = { + ...actionR14932, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: 'REPORT_PREVIEW_SINGLE_ACTION_DM', + childReportID: 'IOU_REPORT_SINGLE_EXPENSE_DM', + childOwnerAccountID: LOGGED_USER_ID, + childManagerAccountID: SECOND_USER_ID, +}; + +const tripPreviewAction = { + ...actionR14932, + actionName: CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW, + reportActionID: 'TRIP_PREVIEW', + childReportID: 'IOU_REPORT_TRIP', +}; + +const commentAction = { + ...actionR14932, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + reportActionID: 'ADD_COMMENT', +}; + +const iouDMReport = { + ...iouReportR14932, + reportID: 'IOU_REPORT_DM', + chatReportID: reportChatDM.reportID, + parentReportActionID: reportPreviewDMAction.reportActionID, + policyID: personalPolicy.id, +}; + +const iouDMSingleExpenseReport = { + ...iouReportR14932, + reportID: 'IOU_REPORT_SINGLE_EXPENSE_DM', + chatReportID: reportChatDM.reportID, + parentReportActionID: reportPreviewSingleTransactionDMAction.reportActionID, + policyID: personalPolicy.id, +}; + +const iouReport = { + ...iouReportR14932, + reportID: 'IOU_REPORT', + chatReportID: chatReport.reportID, + parentReportActionID: reportPreviewAction.reportActionID, + policyID: policy.id, +}; + +const iouTripReport = { + ...iouReportR14932, + reportID: 'IOU_REPORT_TRIP', + chatReportID: chatReport.reportID, + parentReportActionID: tripPreviewAction.reportActionID, + policyID: policy.id, +}; + +const iouActionOne = { + ...actionR14932, + originalMessage: { + ...getOriginalMessage(actionR14932), + IOUTransactionID: 'TRANSACTION_NUMBER_ONE', + IOUReportID: iouDMReport.reportID, + }, +}; + +const iouActionTwo = { + ...actionR14932, + originalMessage: { + ...getOriginalMessage(actionR14932), + IOUTransactionID: 'TRANSACTION_NUMBER_TWO', + IOUReportID: iouDMReport.reportID, + }, +}; + +const iouActionThree = { + ...actionR14932, + originalMessage: { + ...getOriginalMessage(actionR14932), + IOUTransactionID: 'TRANSACTION_NUMBER_THREE', + IOUReportID: iouDMSingleExpenseReport.reportID, + }, +}; + +const transactions = [ + { + ...transactionR14932, + reportID: iouDMReport.reportID, + transactionID: 'TRANSACTION_NUMBER_ONE', + }, + { + ...transactionR14932, + reportID: iouDMReport.reportID, + transactionID: 'TRANSACTION_NUMBER_TWO', + }, + { + ...transactionR14932, + reportID: iouDMSingleExpenseReport.reportID, + transactionID: 'TRANSACTION_NUMBER_THREE', + }, +]; + +const reports = [iouReport, iouTripReport, chatReport, iouDMReport, iouDMSingleExpenseReport, reportChatDM]; +const policies = [policy, personalPolicy]; + +const DEFAULT_WORKSPACE_AVATAR = getDefaultWorkspaceAvatar(policies.at(0)?.name); +const USER_AVATAR = personalDetails[LOGGED_USER_ID].avatar; +const SECOND_USER_AVATAR = personalDetails[SECOND_USER_ID].avatar; + +/* --- Onyx Mocks --- */ 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 reportActionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`]: { + [reportPreviewAction.reportActionID]: reportPreviewAction, + [tripPreviewAction.reportActionID]: tripPreviewAction, + [commentAction.reportActionID]: commentAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportChatDM.reportID}`]: { + [reportPreviewDMAction.reportActionID]: reportPreviewDMAction, + [reportPreviewSingleTransactionDMAction.reportActionID]: reportPreviewSingleTransactionDMAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewDMAction.reportID}`]: { + [iouActionOne.reportActionID]: iouActionOne, + [iouActionTwo.reportActionID]: iouActionTwo, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewSingleTransactionDMAction.reportID}`]: { + [iouActionThree.reportActionID]: iouActionThree, + }, +}; + const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, reports, (report) => report.reportID); const policiesCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, policies, (item) => item.id); -const personalDetailsWithChangedAvatar = {...personalDetails, [loggedUserID]: {...personalDetails[loggedUserID], avatar: MoneyWaving}}; +const onyxState = { + [ONYXKEYS.SESSION]: {accountID: LOGGED_USER_ID, email: personalDetails[LOGGED_USER_ID].login}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, + ...policiesCollectionDataSet, + ...transactionCollectionDataSet, + ...reportActionCollectionDataSet, + ...reportCollectionDataSet, +}; + +/* --- Helpers --- */ function renderAvatar(props: Parameters[0]) { return render( @@ -67,31 +279,161 @@ function renderAvatar(props: Parameters[0]) { ); } +async function retrieveDataFromAvatarView(props: Parameters[0]) { + renderAvatar(props); + + await waitForBatchedUpdatesWithAct(); + + const images = screen.queryAllByTestId('MockedAvatarData'); + const icons = screen.queryAllByTestId('MockedIconData'); + const reportAvatarFragments = screen.queryAllByTestId('ReportAvatar-', { + exact: false, + }); + + const imageData = images.map((img) => img.props.dataSet as AvatarData); + const iconData = icons.map((icon) => icon.props.dataSet as AvatarData); + const fragmentsData = reportAvatarFragments.map((fragment) => fragment.props.testID as string); + + return { + images: imageData, + icons: iconData, + fragments: fragmentsData, + }; +} + +function isSubscriptAvatarRendered({ + images, + fragments, + workspaceIconAsPrimaryAvatar, + negate = false, +}: { + images: AvatarData[]; + fragments: string[]; + workspaceIconAsPrimaryAvatar?: boolean; + negate?: boolean; +}) { + const isEveryAvatarFragmentASubscript = fragments.every((fragment) => fragment.startsWith('ReportAvatar-Subscript')) && fragments.length !== 0; + const isUserAvatarCorrect = images.some( + (image) => image.uri === USER_AVATAR && image.parent === `ReportAvatar-Subscript-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`, + ); + const isWorkspaceAvatarCorrect = images.some( + (image) => image.uri === DEFAULT_WORKSPACE_AVATAR.name && image.parent === `ReportAvatar-Subscript-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, + ); + + expect(isEveryAvatarFragmentASubscript).toBe(!negate); + expect(isWorkspaceAvatarCorrect).toBe(!negate); + expect(isUserAvatarCorrect).toBe(!negate); +} + +function isMultipleAvatarRendered({ + images, + fragments, + workspaceIconAsPrimaryAvatar, + negate = false, + secondUserAvatar, +}: { + images: AvatarData[]; + fragments: string[]; + workspaceIconAsPrimaryAvatar?: boolean; + negate?: boolean; + secondUserAvatar?: string; +}) { + const isEveryAvatarFragmentAMultiple = fragments.every((fragment) => fragment.startsWith('ReportAvatar-MultipleAvatars')) && fragments.length !== 0; + + const isUserAvatarCorrect = images.some( + (image) => image.uri === USER_AVATAR && image.parent === `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`, + ); + const isWorkspaceAvatarCorrect = images.some( + (image) => + image.uri === (secondUserAvatar ?? DEFAULT_WORKSPACE_AVATAR.name) && + image.parent === `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, + ); + + expect(isEveryAvatarFragmentAMultiple).toBe(!negate); + expect(isWorkspaceAvatarCorrect).toBe(!negate); + expect(isUserAvatarCorrect).toBe(!negate); +} + +function isSingleAvatarRendered({images, negate = false, userAvatar}: {images: AvatarData[]; negate?: boolean; userAvatar?: string}) { + const isUserAvatarCorrect = images.some( + (image) => image.uri === (userAvatar ?? USER_AVATAR) && ['ReportAvatar-SingleAvatar', 'ReportAvatar-MultipleAvatars-OneIcon'].includes(image.parent), + ); + + expect(isUserAvatarCorrect).toBe(!negate); +} + describe('ReportAvatar', () => { beforeAll(() => { Onyx.init({ keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: loggedUserID, email: personalDetails[loggedUserID].login}, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetailsWithChangedAvatar, - [ONYXKEYS.COLLECTION.POLICY]: policiesCollectionDataSet, - [ONYXKEYS.COLLECTION.TRANSACTION]: transactionCollectionDataSet, - [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: reportActionCollectionDataSet, - [ONYXKEYS.COLLECTION.REPORT]: reportCollectionDataSet, - }, - evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + initialKeyStates: onyxState, }); + initOnyxDerivedValues(); + return waitForBatchedUpdates(); }); - it('renders properly when single account ID is passed', async () => { - renderAvatar({...defaultProperties, accountIDs: [loggedUserID]}); + afterAll(async () => { + await Onyx.clear(); + }); + + describe('renders properly subscript avatars', () => { + it('renders user primary avatar and workspace subscript next to report preview action', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouReport.reportID}); + isSubscriptAvatarRendered(retrievedData); + }); + + it('renders workspace avatar with user subscript avatar on chat report view', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: chatReport.reportID}); + isSubscriptAvatarRendered({...retrievedData, workspaceIconAsPrimaryAvatar: true}); + }); + + it('renders user primary avatar and workspace subscript next to the trip preview', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouTripReport.reportID}); + isSubscriptAvatarRendered(retrievedData); + }); + + it('renders subscript avatar if the report preview action is provided instead of report ID', async () => { + const retrievedData = await retrieveDataFromAvatarView({action: reportPreviewAction}); + isSubscriptAvatarRendered(retrievedData); + }); - await waitForBatchedUpdatesWithAct(); + it('doesnt render subscript for user message in workspace if they are text messages', async () => { + const retrievedData = await retrieveDataFromAvatarView({action: commentAction, reportID: iouReport.reportID}); + isSubscriptAvatarRendered({...retrievedData, negate: true}); + }); - expect(screen.getByTestId(`ReportAvatar-MultipleAvatars-OneIcon--${loggedUserID}`)).toBeOnTheScreen(); + it('properly converts subscript avatars to multiple avatars if the prop is passed', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouReport.reportID, convertSubscriptToMultiple: true}); + isSubscriptAvatarRendered({...retrievedData, negate: true}); + isMultipleAvatarRendered(retrievedData); + }); }); - afterAll(async () => { - await Onyx.clear(); + describe('renders properly multiple and single avatars', () => { + it('renders single avatar if only one account ID is passed even if reportID & action is passed as well', async () => { + const retrievedData = await retrieveDataFromAvatarView({ + reportID: iouReport.reportID, + action: reportPreviewAction, + accountIDs: [SECOND_USER_ID], + convertSubscriptToMultiple: true, + }); + isMultipleAvatarRendered({...retrievedData, negate: true}); + isSingleAvatarRendered({...retrievedData, userAvatar: SECOND_USER_AVATAR}); + }); + + it('renders multiple avatars if more than one account ID is passed', async () => { + const retrievedData = await retrieveDataFromAvatarView({accountIDs: [LOGGED_USER_ID, SECOND_USER_ID]}); + isMultipleAvatarRendered({...retrievedData, secondUserAvatar: SECOND_USER_AVATAR}); + }); + + it('renders diagonal avatar if both DM chat members sent expense to each other in one report', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouDMReport.reportID}); + isMultipleAvatarRendered({...retrievedData, secondUserAvatar: SECOND_USER_AVATAR, workspaceIconAsPrimaryAvatar: true}); + }); + + it('renders single avatar if only one chat member sent an expense to the other', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouDMSingleExpenseReport.reportID}); + isSingleAvatarRendered(retrievedData); + }); }); }); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 165bb1b304718..54ed2582f08e3 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1263,7 +1263,6 @@ describe('SidebarUtils', () => { iouReportR14932.reportID = '5'; chatReportR14932.reportID = '6'; - iouReportR14932.lastActorAccountID = undefined; const report: Report = { ...createRandomReport(1), @@ -1310,7 +1309,7 @@ describe('SidebarUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportR14932.reportID}`, {[linkedCreateAction.reportActionID]: linkedCreateAction}); const result = SidebarUtils.getOptionData({ - report: iouReportR14932, + report: {...iouReportR14932, lastActorAccountID: undefined}, reportAttributes: undefined, reportNameValuePairs: {}, personalDetails: {}, diff --git a/tests/unit/useReportPreviewSenderID.ts b/tests/unit/useReportPreviewSenderIDTest.ts similarity index 100% rename from tests/unit/useReportPreviewSenderID.ts rename to tests/unit/useReportPreviewSenderIDTest.ts From 0c8559ad0c48e22221b6543dcdbebf4ba417697e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 24 Jul 2025 16:44:17 +0200 Subject: [PATCH 16/33] Fix checks --- src/components/ReportAvatar.tsx | 2 +- tests/ui/ReportAvatarTest.tsx | 2 +- tests/unit/ReportActionItemSingleTest.ts | 14 +++++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index 4b7d9ba39d029..f352daa661455 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -403,7 +403,7 @@ function ReportAvatar({ // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. const isReportArchived = useReportIsArchived(reportID); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived) && policy.type !== CONST.POLICY.TYPE.PERSONAL; + const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; const isAInvoiceReport = isInvoiceReport(iouReport ?? null); const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); diff --git a/tests/ui/ReportAvatarTest.tsx b/tests/ui/ReportAvatarTest.tsx index 729b6f5704e41..3517991ce83bd 100644 --- a/tests/ui/ReportAvatarTest.tsx +++ b/tests/ui/ReportAvatarTest.tsx @@ -397,7 +397,7 @@ describe('ReportAvatar', () => { isSubscriptAvatarRendered(retrievedData); }); - it('doesnt render subscript for user message in workspace if they are text messages', async () => { + it("doesn't render subscript for user message in workspace if they are text messages", async () => { const retrievedData = await retrieveDataFromAvatarView({action: commentAction, reportID: iouReport.reportID}); isSubscriptAvatarRendered({...retrievedData, negate: true}); }); diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index f710a30b1c899..9c0fbe9e23fe3 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -51,6 +51,14 @@ describe('ReportActionItemSingle', () => { Onyx.multiSet({ [ONYXKEYS.PERSONAL_DETAILS_LIST]: fakePersonalDetails, [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fakeReport.reportID}`]: { + [fakeReportAction.reportActionID]: fakeReportAction, + }, + }, + [ONYXKEYS.COLLECTION.REPORT]: { + [fakeReport.reportID]: fakeReport, + }, ...policyCollectionDataSet, }), ) @@ -59,12 +67,12 @@ describe('ReportActionItemSingle', () => { }); } - it('renders secondary Avatar properly', async () => { - const expectedSecondaryIconTestId = 'Avatar'; + it('renders avatar properly', async () => { + const expectedIconTestID = 'ReportAvatar-SingleAvatar'; await setup(); await waitFor(() => { - expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeOnTheScreen(); + expect(screen.getByTestId(expectedIconTestID)).toBeOnTheScreen(); }); }); From 99f99460d5ba201a9827bc9a1a13ac4784a315ec Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 24 Jul 2025 17:18:21 +0200 Subject: [PATCH 17/33] Add new styles for details diagonal avatars --- src/components/ReportAvatar.tsx | 11 ++++++++++- src/styles/index.ts | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index f352daa661455..5315ffb3532b4 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -431,6 +431,10 @@ function ReportAvatar({ singleAvatarStyle: styles.singleAvatarMedium, secondAvatarStyles: styles.secondAvatarMedium, }, + [CONST.AVATAR_SIZE.X_LARGE]: { + singleAvatarStyle: styles.singleAvatarLarge, + secondAvatarStyles: styles.secondAvatarLarge, + }, [CONST.AVATAR_SIZE.DEFAULT]: { singleAvatarStyle: styles.singleAvatar, secondAvatarStyles: styles.secondAvatar, @@ -471,6 +475,10 @@ function ReportAvatar({ return CONST.AVATAR_SIZE.MEDIUM; } + if (size === CONST.AVATAR_SIZE.X_LARGE) { + return CONST.AVATAR_SIZE.LARGE; + } + return CONST.AVATAR_SIZE.SMALLER; }, [isFocusMode, size]); @@ -627,6 +635,7 @@ function ReportAvatar({ const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); } + const useHugeBottomMargin = icons.length === 2 && size === CONST.AVATAR_SIZE.X_LARGE; return shouldStackHorizontally ? ( avatarRows.map((avatars, rowIndex) => ( @@ -709,7 +718,7 @@ function ReportAvatar({ )) ) : ( - + borderRadius: 52, }, + singleAvatarLarge: { + height: 80, + width: 80, + backgroundColor: theme.icon, + borderRadius: 52, + }, + secondAvatar: { position: 'absolute', right: -18, @@ -2400,13 +2407,22 @@ const styles = (theme: ThemeColors) => secondAvatarMedium: { position: 'absolute', - right: -36, - bottom: -36, + right: -42, + bottom: -42, borderWidth: 3, borderRadius: 52, borderColor: 'transparent', }, + secondAvatarLarge: { + position: 'absolute', + right: -50, + bottom: -50, + borderWidth: 4, + borderRadius: 80, + borderColor: 'transparent', + }, + secondAvatarSubscript: { position: 'absolute', right: -6, From 0477f244e646889b504ba25ababe3e8b13ac8832 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 25 Jul 2025 10:32:17 +0200 Subject: [PATCH 18/33] Fix AnonymousReportFooter after merge --- src/components/AnonymousReportFooter.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index af59fb7320e32..eaf8c93876102 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -2,7 +2,6 @@ import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; // eslint-disable-next-line no-restricted-syntax import {signOutAndRedirectToSignIn} from '@userActions/Session'; @@ -24,8 +23,6 @@ function AnonymousReportFooter({isSmallSizeLayout = false, report}: AnonymousRep const styles = useThemeStyles(); const {translate} = useLocalize(); - const policy = usePolicy(report?.policyID); - return ( From 84d28aad8743654c8c4735b041e8b01689f87b8f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 25 Jul 2025 12:28:35 +0200 Subject: [PATCH 19/33] Fix UI tweaks --- src/components/ReportAvatar.tsx | 21 ++++++++++++++++++--- src/styles/index.ts | 6 ++++++ src/styles/utils/index.ts | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index 5315ffb3532b4..df04c3878b178 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -482,6 +482,22 @@ function ReportAvatar({ return CONST.AVATAR_SIZE.SMALLER; }, [isFocusMode, size]); + const subscriptAvatarStyle = useMemo(() => { + if (size === CONST.AVATAR_SIZE.SMALL) { + return styles.secondAvatarSubscriptCompact; + } + + if (size === CONST.AVATAR_SIZE.SMALL_NORMAL) { + return styles.secondAvatarSubscriptSmallNormal; + } + + if (size === CONST.AVATAR_SIZE.X_LARGE) { + return styles.secondAvatarSubscriptXLarge; + } + + return styles.secondAvatarSubscript; + }, [size, styles]); + const avatarRows = useMemo(() => { // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { @@ -506,7 +522,6 @@ function ReportAvatar({ !shouldShowConvertedSubscriptAvatar ) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; - const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; const containerStyle = StyleUtils.getContainerStyles(size); const mainAvatar = primaryAvatar ?? subscriptFallbackIcon; @@ -544,7 +559,7 @@ function ReportAvatar({ icon={secondaryAvatar} > )) ) : ( - + bottom: -6, }, + secondAvatarSubscriptXLarge: { + position: 'absolute', + right: -8, + bottom: -8, + }, + secondAvatarSubscriptCompact: { position: 'absolute', bottom: -4, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 2f4756fc5a3eb..4e89aafe6fa3d 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -140,10 +140,10 @@ const avatarBorderWidths: Partial> = { [CONST.AVATAR_SIZE.SUBSCRIPT]: 2, [CONST.AVATAR_SIZE.SMALL]: 2, [CONST.AVATAR_SIZE.SMALLER]: 2, + [CONST.AVATAR_SIZE.HEADER]: 2, [CONST.AVATAR_SIZE.LARGE]: 4, [CONST.AVATAR_SIZE.X_LARGE]: 4, [CONST.AVATAR_SIZE.MEDIUM]: 3, - [CONST.AVATAR_SIZE.HEADER]: 3, [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4, }; From 9ca0139e29d3ea18b2f3dca36070d98926012245 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 25 Jul 2025 17:25:27 +0200 Subject: [PATCH 20/33] Refractor ReportAvatar props --- src/components/AvatarWithDisplayName.tsx | 14 +- .../LHNOptionsList/OptionRowLHN.tsx | 6 +- src/components/MenuItem.tsx | 21 +- src/components/OptionRow.tsx | 4 +- .../TransactionPreviewContent.tsx | 7 +- src/components/ReportAvatar.tsx | 228 ++++++++---------- .../SelectionList/InviteMemberListItem.tsx | 14 +- .../Search/CardListItemHeader.tsx | 18 +- .../SelectionList/TableListItem.tsx | 2 +- src/components/SelectionList/UserListItem.tsx | 14 +- src/pages/ReportDetailsPage.tsx | 3 +- src/pages/home/HeaderView.tsx | 11 +- .../home/report/ReportActionItemCreated.tsx | 12 +- .../home/report/ReportActionItemSingle.tsx | 9 +- .../home/report/ReportActionItemThread.tsx | 9 +- .../workspace/WorkspaceInviteMessagePage.tsx | 8 +- tests/ui/ReportAvatarTest.tsx | 17 +- 17 files changed, 167 insertions(+), 230 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index aa92f9627a173..e12b2a365d100 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -28,11 +28,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; import {getButtonRole} from './Button/utils'; import DisplayNames from './DisplayNames'; import type DisplayNamesProps from './DisplayNames/types'; -import {FallbackAvatar} from './Icon/Expensicons'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import ReportAvatar from './ReportAvatar'; @@ -68,13 +66,6 @@ type AvatarWithDisplayNameProps = { avatarBorderColor?: ColorValue; }; -const fallbackIcon: Icon = { - source: FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: '', - id: -1, -}; - function getCustomDisplayName( shouldUseCustomSearchTitleName: boolean, report: OnyxEntry, @@ -231,10 +222,9 @@ function AvatarWithDisplayName({ const multipleAvatars = ( ); diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index d7f2e316cb1e6..d07b15272fe63 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -261,10 +261,10 @@ function OptionRowLHN({ {!!optionItem.icons?.length && !!firstIcon && ( 0 ? [Number(rightIconAccountID)] : undefined} - isFocusMode + useMidSubscriptSizeForMultipleAvatars /> )} diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 98e89e6501c9d..ac20fcee46e17 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -208,10 +208,10 @@ function OptionRow({ {!!option.icons?.length && !!firstIcon && ( )} diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index cd50a2157ab1f..6434ce3d3ea2b 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -244,10 +244,11 @@ function TransactionPreviewContent({ )} diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index df04c3878b178..49abdd2f9dafa 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -9,7 +9,9 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; +import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getCardFeedIcon} from '@libs/CardUtils'; import localeCompare from '@libs/LocaleCompare'; import {getReportAction} from '@libs/ReportActionsUtils'; import { @@ -29,98 +31,79 @@ import { isTripRoom, shouldReportShowSubscript, } from '@libs/ReportUtils'; -import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxInputOrEntry, PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; -import type IconAsset from '@src/types/utils/IconAsset'; import Avatar from './Avatar'; import Icon from './Icon'; import {FallbackAvatar} from './Icon/Expensicons'; +import {WorkspaceBuilding} from './Icon/WorkspaceDefaultAvatars'; import Text from './Text'; import Tooltip from './Tooltip'; import UserDetailsTooltip from './UserDetailsTooltip'; -type SubIcon = { - /** Avatar source to display */ - source: IconAsset; +type SortingOptions = 'byID' | 'byName' | 'reverse'; - /** Width of the icon */ - width?: number; +/** Prop to identify if we should load avatars vertically instead of diagonally */ +type HorizontalStacking = + | Partial<{ + /** Prop to identify if we should display avatars in rows */ + displayInRows: boolean; - /** Height of the icon */ - height?: number; + /** Whether the avatars are hovered */ + isHovered: boolean; - /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ - fill?: string; -}; + /** Whether the avatars are active */ + isActive: boolean; + + /** Whether the avatars are in an element being pressed */ + isPressed: boolean; + + /** Prop to limit the amount of avatars displayed horizontally */ + overlapDivider: number; + + /** Prop to limit the amount of avatars displayed horizontally */ + maxAvatarsInRow: number; + + /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ + useCardBG: boolean; + + /** Prop to sort the avatars */ + sort: SortingOptions | SortingOptions[]; + }> + | boolean; type ReportAvatarProps = { + horizontalStacking?: HorizontalStacking; + /** IOU Report ID for single avatar */ reportID?: string; /** IOU Report ID for single avatar */ action?: OnyxEntry; - /** Single avatar size */ - singleAvatarSize?: ValueOf; - /** Single avatar container styles */ singleAvatarContainerStyle?: ViewStyle[]; /** Border color for the subscript avatar */ - subscriptBorderColor?: ColorValue; + subscriptAvatarBorderColor?: ColorValue; /** Whether to show the subscript avatar without margin */ - subscriptNoMargin?: boolean; - - /** Subscript icon to display */ - subIcon?: SubIcon; - - /** A fallback main avatar icon */ - subscriptFallbackIcon?: IconType; - - /** Size of the secondary avatar */ - subscriptAvatarSize?: ValueOf; + noRightMarginOnSubscriptContainer?: boolean; + /** Account IDs to display avatars for, it overrides the reportID and action props */ accountIDs?: number[]; - reverseAvatars?: boolean; - - convertSubscriptToMultiple?: boolean; - - sortAvatarsByID?: boolean; - - sortAvatarsByName?: boolean; - /** Set the size of avatars */ size?: ValueOf; /** Style for Second Avatar */ - secondAvatarStyle?: StyleProp; - - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIconForMultipleAvatars?: AvatarSource; - - /** Prop to identify if we should load avatars vertically instead of diagonally */ - shouldStackHorizontally?: boolean; - - /** Prop to identify if we should display avatars in rows */ - shouldDisplayAvatarsInRows?: boolean; - - /** Whether the avatars are hovered */ - isHovered?: boolean; - - /** Whether the avatars are active */ - isActive?: boolean; - - /** Whether the avatars are in an element being pressed */ - isPressed?: boolean; + secondaryAvatarContainerStyle?: StyleProp; /** Whether #focus mode is on */ - isFocusMode?: boolean; + useMidSubscriptSizeForMultipleAvatars?: boolean; /** Whether avatars are displayed within a reportAction */ isInReportAction?: boolean; @@ -128,14 +111,8 @@ type ReportAvatarProps = { /** Whether to show the tooltip text */ shouldShowTooltip?: boolean; - /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ - shouldUseCardBackground?: boolean; - - /** Prop to limit the amount of avatars displayed horizontally */ - maxAvatarsInRow?: number; - - /** Prop to limit the amount of avatars displayed horizontally */ - overlapDivider?: number; + /** Subscript card feed to display instead of the second avatar */ + subscriptCardFeed?: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK; }; type AvatarStyles = { @@ -332,37 +309,23 @@ function getPrimaryAndSecondaryAvatar({ function ReportAvatar({ reportID: potentialReportID, - singleAvatarContainerStyle, - singleAvatarSize, - subscriptBorderColor, - subscriptNoMargin = false, - subIcon, - subscriptFallbackIcon, - subscriptAvatarSize = CONST.AVATAR_SIZE.SUBSCRIPT, - accountIDs: passedAccountIDs, action: passedAction, - reverseAvatars, - convertSubscriptToMultiple, - sortAvatarsByID, - sortAvatarsByName, - fallbackIconForMultipleAvatars, + accountIDs: passedAccountIDs, size = CONST.AVATAR_SIZE.DEFAULT, - secondAvatarStyle: secondAvatarStyleProp, - shouldStackHorizontally = false, - shouldDisplayAvatarsInRows = false, - isHovered = false, - isActive = false, - isPressed = false, - isFocusMode = false, - isInReportAction = false, shouldShowTooltip = true, - shouldUseCardBackground = false, - maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, - overlapDivider = 3, + horizontalStacking, + singleAvatarContainerStyle, + subscriptAvatarBorderColor, + noRightMarginOnSubscriptContainer = false, + subscriptCardFeed, + secondaryAvatarContainerStyle, + useMidSubscriptSizeForMultipleAvatars = false, + isInReportAction = false, }: ReportAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const illustrations = useThemeIllustrations(); const reportID = potentialReportID ?? @@ -379,6 +342,7 @@ function ReportAvatar({ const iouReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? undefined : report; const chatReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? report : potentialChatReport; + const subscriptAvatarSize = size === CONST.AVATAR_SIZE.X_LARGE ? CONST.AVATAR_SIZE.HEADER : CONST.AVATAR_SIZE.SUBSCRIPT; const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; @@ -400,6 +364,18 @@ function ReportAvatar({ const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const {fallbackIcon} = personalDetails?.[accountID] ?? {}; + const { + displayInRows: shouldDisplayAvatarsInRows = false, + isHovered = false, + isActive = false, + isPressed = false, + overlapDivider = 3, + maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, + sort: sortAvatars = undefined, + useCardBG: shouldUseCardBackground = false, + } = typeof horizontalStacking === 'boolean' ? {} : (horizontalStacking ?? {}); + + const shouldStackHorizontally = !!horizontalStacking; // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. const isReportArchived = useReportIsArchived(reportID); @@ -408,7 +384,7 @@ function ReportAvatar({ const isAInvoiceReport = isInvoiceReport(iouReport ?? null); const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); const shouldShowAllActors = shouldDisplayAllActors && !reportPreviewSenderID; - const shouldShowConvertedSubscriptAvatar = convertSubscriptToMultiple && shouldShowSubscriptAvatar && !reportPreviewSenderID; + const shouldShowConvertedSubscriptAvatar = (shouldStackHorizontally || passedAccountIDs) && shouldShowSubscriptAvatar && !reportPreviewSenderID; const isReportPreviewOrNoAction = !action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; @@ -452,14 +428,14 @@ function ReportAvatar({ const multipleAvatars = avatarsForAccountIDs.length > 0 ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar]; const sortedAvatars = (() => { - if (sortAvatarsByName) { + if (sortAvatars?.includes('byName')) { return sortIconsByName(multipleAvatars, personalDetails); } - return sortAvatarsByID ? lodashSortBy(multipleAvatars, (icon) => icon.id) : multipleAvatars; + return sortAvatars?.includes('byID') ? lodashSortBy(multipleAvatars, (icon) => icon.id) : multipleAvatars; })(); - const icons = reverseAvatars ? sortedAvatars.reverse() : sortedAvatars; + const icons = sortAvatars?.includes('reverse') ? sortedAvatars.reverse() : sortedAvatars; - const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; + const secondaryAvatarContainerStyles = secondaryAvatarContainerStyle ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); @@ -467,7 +443,7 @@ function ReportAvatar({ const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); const avatarSize = useMemo(() => { - if (isFocusMode) { + if (useMidSubscriptSizeForMultipleAvatars) { return CONST.AVATAR_SIZE.MID_SUBSCRIPT; } @@ -480,7 +456,7 @@ function ReportAvatar({ } return CONST.AVATAR_SIZE.SMALLER; - }, [isFocusMode, size]); + }, [useMidSubscriptSizeForMultipleAvatars, size]); const subscriptAvatarStyle = useMemo(() => { if (size === CONST.AVATAR_SIZE.SMALL) { @@ -516,43 +492,42 @@ function ReportAvatar({ }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); if ( - ((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && - !shouldStackHorizontally && - !isChatThreadOutsideTripRoom && - !shouldShowConvertedSubscriptAvatar + (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && + !shouldStackHorizontally && + !isChatThreadOutsideTripRoom && + !shouldShowConvertedSubscriptAvatar) || + !!subscriptCardFeed ) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const containerStyle = StyleUtils.getContainerStyles(size); - const mainAvatar = primaryAvatar ?? subscriptFallbackIcon; - return ( - {!!secondaryAvatar && ( + {!!secondaryAvatar && !subscriptCardFeed && ( )} - {!!subIcon && ( + {!!subscriptCardFeed && ( )} @@ -683,7 +657,7 @@ function ReportAvatar({ }), StyleUtils.getAvatarBorderWidth(size), ]} - source={icon.source ?? fallbackIconForMultipleAvatars} + source={icon.source ?? WorkspaceBuilding} size={size} name={icon.name} avatarID={icon.id} @@ -749,7 +723,7 @@ function ReportAvatar({ {/* View is necessary for tooltip to show for multiple avatars in LHN */} - + {icons.length === 2 ? ( diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index d7381912f1dc3..117a604f7bd55 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -1,7 +1,6 @@ import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import {View} from 'react-native'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import ReportAvatar from '@components/ReportAvatar'; @@ -17,17 +16,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getIsUserSubmittedExpenseOrScannedReceipt} from '@libs/OptionsListUtils'; import {isSelectedManagerMcTest} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import type {Icon} from '@src/types/onyx/OnyxCommon'; import BaseListItem from './BaseListItem'; import type {InviteMemberListItemProps, ListItem} from './types'; -const fallbackIcon: Icon = { - source: FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: '', - id: -1, -}; - function InviteMemberListItem({ item, isFocused, @@ -112,10 +103,9 @@ function InviteMemberListItem({ {!!item.icons && ( = { @@ -36,18 +33,9 @@ function CardListItemHeader({card: cardItem, onCheckboxP const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, formatPhoneNumber} = useLocalize(); - const illustrations = useThemeIllustrations(); const formattedDisplayName = useMemo(() => formatPhoneNumber(getDisplayNameOrDefault(cardItem)), [cardItem, formatPhoneNumber]); - const cardIcon = useMemo(() => { - return { - source: getCardFeedIcon(cardItem.bank as CompanyCardFeed, illustrations), - width: variables.cardAvatarWidth, - height: variables.cardAvatarHeight, - }; - }, [illustrations, cardItem]); - const backgroundColor = StyleUtils.getItemBackgroundColorStyle(!!cardItem.isSelected, !!isFocused, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? theme.highlightBG; @@ -67,9 +55,9 @@ function CardListItemHeader({card: cardItem, onCheckboxP )} diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 75e5c84b7b4d9..e8eed06dfc0a8 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -111,7 +111,7 @@ function TableListItem({ ({ item, isFocused, @@ -110,10 +101,9 @@ function UserListItem({ )} {!!item.icons && ( diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index b6228ff924eb2..c6d2c23476589 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -8,7 +8,7 @@ import CaretWrapper from '@components/CaretWrapper'; import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; import Icon from '@components/Icon'; -import {BackArrow, DotIndicator, FallbackAvatar} from '@components/Icon/Expensicons'; +import {BackArrow, DotIndicator} from '@components/Icon/Expensicons'; import LoadingBar from '@components/LoadingBar'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import OnboardingHelpDropdownButton from '@components/OnboardingHelpDropdownButton'; @@ -77,7 +77,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Report, ReportAction} from '@src/types/onyx'; -import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type HeaderViewProps = { @@ -97,13 +96,6 @@ type HeaderViewProps = { shouldUseNarrowLayout?: boolean; }; -const fallbackIcon: IconType = { - source: FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: '', - id: -1, -}; - function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -246,7 +238,6 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, const multipleAvatars = ( diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 9acaa40bfa5a3..579a1bac00497 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -60,13 +60,13 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr > diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 3173317fcc31a..84153d15f3e1c 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -208,11 +208,14 @@ function ReportActionItemSingle({ diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index 33e767f98b390..dd98423d801a3 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -63,11 +63,12 @@ function ReportActionItemThread({numberOfReplies, accountIDs, mostRecentReply, r diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index a584511b7886f..6c656f67b77b9 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -188,10 +188,10 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: diff --git a/tests/ui/ReportAvatarTest.tsx b/tests/ui/ReportAvatarTest.tsx index 3517991ce83bd..902fe2db34389 100644 --- a/tests/ui/ReportAvatarTest.tsx +++ b/tests/ui/ReportAvatarTest.tsx @@ -331,22 +331,28 @@ function isMultipleAvatarRendered({ workspaceIconAsPrimaryAvatar, negate = false, secondUserAvatar, + stacked, }: { images: AvatarData[]; fragments: string[]; workspaceIconAsPrimaryAvatar?: boolean; negate?: boolean; secondUserAvatar?: string; + stacked?: boolean; }) { const isEveryAvatarFragmentAMultiple = fragments.every((fragment) => fragment.startsWith('ReportAvatar-MultipleAvatars')) && fragments.length !== 0; const isUserAvatarCorrect = images.some( - (image) => image.uri === USER_AVATAR && image.parent === `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`, + (image) => + image.uri === USER_AVATAR && + image.parent === + (stacked ? 'ReportAvatar-MultipleAvatars-StackedHorizontally-Avatar' : `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`), ); const isWorkspaceAvatarCorrect = images.some( (image) => image.uri === (secondUserAvatar ?? DEFAULT_WORKSPACE_AVATAR.name) && - image.parent === `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, + image.parent === + (stacked ? 'ReportAvatar-MultipleAvatars-StackedHorizontally-Avatar' : `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`), ); expect(isEveryAvatarFragmentAMultiple).toBe(!negate); @@ -402,10 +408,10 @@ describe('ReportAvatar', () => { isSubscriptAvatarRendered({...retrievedData, negate: true}); }); - it('properly converts subscript avatars to multiple avatars if the prop is passed', async () => { - const retrievedData = await retrieveDataFromAvatarView({reportID: iouReport.reportID, convertSubscriptToMultiple: true}); + it('properly converts subscript avatars to multiple avatars if the avatars are stacked horizontally', async () => { + const retrievedData = await retrieveDataFromAvatarView({reportID: iouReport.reportID, horizontalStacking: true}); isSubscriptAvatarRendered({...retrievedData, negate: true}); - isMultipleAvatarRendered(retrievedData); + isMultipleAvatarRendered({...retrievedData, stacked: true}); }); }); @@ -415,7 +421,6 @@ describe('ReportAvatar', () => { reportID: iouReport.reportID, action: reportPreviewAction, accountIDs: [SECOND_USER_ID], - convertSubscriptToMultiple: true, }); isMultipleAvatarRendered({...retrievedData, negate: true}); isSingleAvatarRendered({...retrievedData, userAvatar: SECOND_USER_AVATAR}); From 6f9782d0b7f093f38f3600286413ff3b443e214f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 28 Jul 2025 11:53:51 +0200 Subject: [PATCH 21/33] Fix subscript XLarge avatar position --- src/styles/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 84974ffe6615a..c36a7fb91de6d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2431,8 +2431,8 @@ const styles = (theme: ThemeColors) => secondAvatarSubscriptXLarge: { position: 'absolute', - right: -8, - bottom: -8, + right: -10, + bottom: -10, }, secondAvatarSubscriptCompact: { From fc3a658f4c57bf133dedb3fe23a8a5a667eb2160 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 28 Jul 2025 12:02:02 +0200 Subject: [PATCH 22/33] Add c3024 suggestions --- src/components/ReportAvatar.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/ReportAvatar.tsx b/src/components/ReportAvatar.tsx index 49abdd2f9dafa..2bd69fa6be7a0 100644 --- a/src/components/ReportAvatar.tsx +++ b/src/components/ReportAvatar.tsx @@ -340,8 +340,8 @@ function ReportAvatar({ }); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - const iouReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? undefined : report; - const chatReport = isChatReport(report) && report?.chatType !== 'tripRoom' ? report : potentialChatReport; + const iouReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? undefined : report; + const chatReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? report : potentialChatReport; const subscriptAvatarSize = size === CONST.AVATAR_SIZE.X_LARGE ? CONST.AVATAR_SIZE.HEADER : CONST.AVATAR_SIZE.SUBSCRIPT; const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; @@ -419,20 +419,22 @@ function ReportAvatar({ [styles], ); - const avatarsForAccountIDs: IconType[] = (passedAccountIDs ?? []).map((id) => ({ - id, - type: CONST.ICON_TYPE_AVATAR, - source: personalDetails?.[id]?.avatar ?? FallbackAvatar, - name: personalDetails?.[id]?.login ?? '', - })); + const sortedAvatars = useMemo(() => { + const avatarsForAccountIDs: IconType[] = (passedAccountIDs ?? []).map((id) => ({ + id, + type: CONST.ICON_TYPE_AVATAR, + source: personalDetails?.[id]?.avatar ?? FallbackAvatar, + name: personalDetails?.[id]?.login ?? '', + })); + + const multipleAvatars = avatarsForAccountIDs.length > 0 ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar]; - const multipleAvatars = avatarsForAccountIDs.length > 0 ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar]; - const sortedAvatars = (() => { if (sortAvatars?.includes('byName')) { return sortIconsByName(multipleAvatars, personalDetails); } return sortAvatars?.includes('byID') ? lodashSortBy(multipleAvatars, (icon) => icon.id) : multipleAvatars; - })(); + }, [passedAccountIDs, personalDetails, primaryAvatar, secondaryAvatar, sortAvatars]); + const icons = sortAvatars?.includes('reverse') ? sortedAvatars.reverse() : sortedAvatars; const secondaryAvatarContainerStyles = secondaryAvatarContainerStyle ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; From 232e30d41f0b9a4c1f05ee42ef54e2ba1421bc7a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 28 Jul 2025 12:51:05 +0200 Subject: [PATCH 23/33] Add Blazej suggestions --- src/components/AvatarWithDisplayName.tsx | 2 +- src/components/MenuItem.tsx | 10 ++++++---- src/components/ReportActionItem/TaskView.tsx | 2 +- src/pages/workspace/WorkspaceInviteMessagePage.tsx | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index e12b2a365d100..35f3a16fd755c 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -224,7 +224,7 @@ function AvatarWithDisplayName({ singleAvatarContainerStyle={[styles.actionAvatar, styles.mr3]} subscriptAvatarBorderColor={avatarBorderColor} size={size} - secondaryAvatarContainerStyle={[StyleUtils.getBackgroundAndBorderStyle(avatarBorderColor)]} + secondaryAvatarContainerStyle={StyleUtils.getBackgroundAndBorderStyle(avatarBorderColor)} reportID={report?.reportID} /> ); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index be6d6d8f368b1..edab1f261e7e7 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -257,7 +257,7 @@ type MenuItemBaseProps = { rightIconAccountID?: number | string; - iconAccountID?: number | string; + iconAccountID?: number; /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; @@ -645,6 +645,8 @@ function MenuItem( onSecondaryInteraction?.(event); }; + const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; + return ( {!!label && !isLabelHoverable && ( @@ -703,7 +705,7 @@ function MenuItem( {!!label && isLabelHoverable && ( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - + {label} @@ -711,7 +713,7 @@ function MenuItem( )} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} - {(!!iconReportID || !!iconAccountID) && ( + {isIDPassed && ( 0 ? [Number(iconAccountID)] : undefined} + accountIDs={iconAccountID ? [iconAccountID] : undefined} /> )} {!icon && shouldPutLeftPaddingWhenNoIcon && ( diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index dadb4af20de5a..1fbb6632af5e7 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -179,7 +179,7 @@ function TaskView({report, parentReport, action}: TaskViewProps) { From 80fb83ddc45fb6c0c6c077f9816e0b3ef56279b2 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 28 Jul 2025 13:01:43 +0200 Subject: [PATCH 24/33] Rename ReportAvatar to ReportActionAvatars --- src/components/AvatarWithDisplayName.tsx | 4 +-- .../LHNOptionsList/OptionRowLHN.tsx | 4 +-- src/components/MenuItem.tsx | 6 ++-- src/components/OptionRow.tsx | 4 +-- ...portAvatar.tsx => ReportActionAvatars.tsx} | 34 +++++++++---------- .../TransactionPreviewContent.tsx | 4 +-- .../SelectionList/InviteMemberListItem.tsx | 4 +-- .../Search/CardListItemHeader.tsx | 4 +-- .../SelectionList/TableListItem.tsx | 4 +-- src/components/SelectionList/UserListItem.tsx | 4 +-- src/pages/ReportDetailsPage.tsx | 4 +-- src/pages/home/HeaderView.tsx | 4 +-- .../home/report/ReportActionItemCreated.tsx | 4 +-- .../home/report/ReportActionItemSingle.tsx | 4 +-- .../home/report/ReportActionItemThread.tsx | 4 +-- .../workspace/WorkspaceInviteMessagePage.tsx | 4 +-- ...arTest.tsx => ReportActionAvatarsTest.tsx} | 30 +++++++++------- tests/unit/ReportActionItemSingleTest.ts | 2 +- 18 files changed, 66 insertions(+), 62 deletions(-) rename src/components/{ReportAvatar.tsx => ReportActionAvatars.tsx} (96%) rename tests/ui/{ReportAvatarTest.tsx => ReportActionAvatarsTest.tsx} (92%) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 35f3a16fd755c..898fb8c1f2e6f 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -33,7 +33,7 @@ import DisplayNames from './DisplayNames'; import type DisplayNamesProps from './DisplayNames/types'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; -import ReportAvatar from './ReportAvatar'; +import ReportActionAvatars from './ReportActionAvatars'; import type {TransactionListItemType} from './SelectionList/types'; import Text from './Text'; @@ -220,7 +220,7 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; const multipleAvatars = ( - {!!optionItem.icons?.length && !!firstIcon && ( - {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {isIDPassed && ( - - {!!option.icons?.length && !!firstIcon && ( - | boolean; -type ReportAvatarProps = { +type ReportActionAvatarsProps = { horizontalStacking?: HorizontalStacking; /** IOU Report ID for single avatar */ @@ -307,7 +307,7 @@ function getPrimaryAndSecondaryAvatar({ return [primaryAvatar, secondaryAvatar]; } -function ReportAvatar({ +function ReportActionAvatars({ reportID: potentialReportID, action: passedAction, accountIDs: passedAccountIDs, @@ -321,7 +321,7 @@ function ReportAvatar({ secondaryAvatarContainerStyle, useMidSubscriptSizeForMultipleAvatars = false, isInReportAction = false, -}: ReportAvatarProps) { +}: ReportActionAvatarsProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -506,7 +506,7 @@ function ReportAvatar({ return ( @@ -553,7 +553,7 @@ function ReportAvatar({ avatarID={secondaryAvatar.id} type={secondaryAvatar.type} fallbackIcon={secondaryAvatar.fallbackIcon} - testID="ReportAvatar-Subscript-SecondaryAvatar" + testID="ReportActionAvatars-Subscript-SecondaryAvatar" /> @@ -578,7 +578,7 @@ function ReportAvatar({ width={variables.cardAvatarWidth} height={variables.cardAvatarHeight} additionalStyles={styles.alignSelfCenter} - testID="ReportAvatar-Subscript-CardIcon" + testID="ReportActionAvatars-Subscript-CardIcon" /> )} @@ -611,7 +611,7 @@ function ReportAvatar({ avatarID={icons.at(0)?.id} type={icons.at(0)?.type ?? CONST.ICON_TYPE_AVATAR} fallbackIcon={icons.at(0)?.fallbackIcon} - testID="ReportAvatar-MultipleAvatars-OneIcon" + testID="ReportActionAvatars-MultipleAvatars-OneIcon" /> @@ -634,7 +634,7 @@ function ReportAvatar({ style={avatarContainerStyles} /* eslint-disable-next-line react/no-array-index-key */ key={`avatarRow-${rowIndex}`} - testID="ReportAvatar-MultipleAvatars-StackedHorizontally-Row" + testID="ReportActionAvatars-MultipleAvatars-StackedHorizontally-Row" > {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( @@ -677,7 +677,7 @@ function ReportAvatar({ shouldRender={shouldShowTooltip} > @@ -761,7 +761,7 @@ function ReportAvatar({ avatarID={icons.at(1)?.id} type={icons.at(1)?.type ?? CONST.ICON_TYPE_AVATAR} fallbackIcon={icons.at(1)?.fallbackIcon} - testID="ReportAvatar-MultipleAvatars-SecondaryAvatar" + testID="ReportActionAvatars-MultipleAvatars-SecondaryAvatar" /> @@ -772,7 +772,7 @@ function ReportAvatar({ > ); } -export default ReportAvatar; +export default ReportActionAvatars; export {getPrimaryAndSecondaryAvatar}; diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 6434ce3d3ea2b..a361b411a8717 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -5,8 +5,8 @@ import Button from '@components/Button'; import Icon from '@components/Icon'; import {DotIndicator, Folder, Tag} from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import ReportActionItemImages from '@components/ReportActionItem/ReportActionItemImages'; -import ReportAvatar from '@components/ReportAvatar'; import UserInfoCellsWithArrow from '@components/SelectionList/Search/UserInfoCellsWithArrow'; import Text from '@components/Text'; import TransactionPreviewSkeletonView from '@components/TransactionPreviewSkeletonView'; @@ -242,7 +242,7 @@ function TransactionPreviewContent({ {previewHeaderText} {isBillSplit && ( - ({ > {!!item.icons && ( - ({card: cardItem, onCheckboxP /> )} - ({ )} {!!item.accountID && ( - ({ )} {!!item.icons && ( - - - - - - [0]) { +function renderAvatar(props: Parameters[0]) { return render( {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + , ); } -async function retrieveDataFromAvatarView(props: Parameters[0]) { +async function retrieveDataFromAvatarView(props: Parameters[0]) { renderAvatar(props); await waitForBatchedUpdatesWithAct(); const images = screen.queryAllByTestId('MockedAvatarData'); const icons = screen.queryAllByTestId('MockedIconData'); - const reportAvatarFragments = screen.queryAllByTestId('ReportAvatar-', { + const reportAvatarFragments = screen.queryAllByTestId('ReportActionAvatars-', { exact: false, }); @@ -312,12 +312,12 @@ function isSubscriptAvatarRendered({ workspaceIconAsPrimaryAvatar?: boolean; negate?: boolean; }) { - const isEveryAvatarFragmentASubscript = fragments.every((fragment) => fragment.startsWith('ReportAvatar-Subscript')) && fragments.length !== 0; + const isEveryAvatarFragmentASubscript = fragments.every((fragment) => fragment.startsWith('ReportActionAvatars-Subscript')) && fragments.length !== 0; const isUserAvatarCorrect = images.some( - (image) => image.uri === USER_AVATAR && image.parent === `ReportAvatar-Subscript-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`, + (image) => image.uri === USER_AVATAR && image.parent === `ReportActionAvatars-Subscript-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`, ); const isWorkspaceAvatarCorrect = images.some( - (image) => image.uri === DEFAULT_WORKSPACE_AVATAR.name && image.parent === `ReportAvatar-Subscript-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, + (image) => image.uri === DEFAULT_WORKSPACE_AVATAR.name && image.parent === `ReportActionAvatars-Subscript-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, ); expect(isEveryAvatarFragmentASubscript).toBe(!negate); @@ -340,19 +340,23 @@ function isMultipleAvatarRendered({ secondUserAvatar?: string; stacked?: boolean; }) { - const isEveryAvatarFragmentAMultiple = fragments.every((fragment) => fragment.startsWith('ReportAvatar-MultipleAvatars')) && fragments.length !== 0; + const isEveryAvatarFragmentAMultiple = fragments.every((fragment) => fragment.startsWith('ReportActionAvatars-MultipleAvatars')) && fragments.length !== 0; const isUserAvatarCorrect = images.some( (image) => image.uri === USER_AVATAR && image.parent === - (stacked ? 'ReportAvatar-MultipleAvatars-StackedHorizontally-Avatar' : `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`), + (stacked + ? 'ReportActionAvatars-MultipleAvatars-StackedHorizontally-Avatar' + : `ReportActionAvatars-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'SecondaryAvatar' : 'MainAvatar'}`), ); const isWorkspaceAvatarCorrect = images.some( (image) => image.uri === (secondUserAvatar ?? DEFAULT_WORKSPACE_AVATAR.name) && image.parent === - (stacked ? 'ReportAvatar-MultipleAvatars-StackedHorizontally-Avatar' : `ReportAvatar-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`), + (stacked + ? 'ReportActionAvatars-MultipleAvatars-StackedHorizontally-Avatar' + : `ReportActionAvatars-MultipleAvatars-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`), ); expect(isEveryAvatarFragmentAMultiple).toBe(!negate); @@ -362,13 +366,13 @@ function isMultipleAvatarRendered({ function isSingleAvatarRendered({images, negate = false, userAvatar}: {images: AvatarData[]; negate?: boolean; userAvatar?: string}) { const isUserAvatarCorrect = images.some( - (image) => image.uri === (userAvatar ?? USER_AVATAR) && ['ReportAvatar-SingleAvatar', 'ReportAvatar-MultipleAvatars-OneIcon'].includes(image.parent), + (image) => image.uri === (userAvatar ?? USER_AVATAR) && ['ReportActionAvatars-SingleAvatar', 'ReportActionAvatars-MultipleAvatars-OneIcon'].includes(image.parent), ); expect(isUserAvatarCorrect).toBe(!negate); } -describe('ReportAvatar', () => { +describe('ReportActionAvatars', () => { beforeAll(() => { Onyx.init({ keys: ONYXKEYS, diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index 9c0fbe9e23fe3..02e5ba0f5aaaa 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -68,7 +68,7 @@ describe('ReportActionItemSingle', () => { } it('renders avatar properly', async () => { - const expectedIconTestID = 'ReportAvatar-SingleAvatar'; + const expectedIconTestID = 'ReportActionAvatars-SingleAvatar'; await setup(); await waitFor(() => { From cc7f73a06d83c92c974fe11b516e23c14f0dbab6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 29 Jul 2025 12:15:56 +0200 Subject: [PATCH 25/33] Use getIcons inside getReportActionAvatars --- src/components/ReportActionAvatars.tsx | 247 ++---------------- .../SelectionList/InviteMemberListItem.tsx | 1 + src/libs/ReportUtils.ts | 144 ++++++++++ .../home/report/ReportActionItemSingle.tsx | 47 ++-- 4 files changed, 184 insertions(+), 255 deletions(-) diff --git a/src/components/ReportActionAvatars.tsx b/src/components/ReportActionAvatars.tsx index 51b585e00cb4a..b03693a36633c 100644 --- a/src/components/ReportActionAvatars.tsx +++ b/src/components/ReportActionAvatars.tsx @@ -2,7 +2,7 @@ import lodashSortBy from 'lodash/sortBy'; import React, {useMemo} from 'react'; import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -14,27 +14,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getCardFeedIcon} from '@libs/CardUtils'; import localeCompare from '@libs/LocaleCompare'; import {getReportAction} from '@libs/ReportActionsUtils'; -import { - getDefaultWorkspaceAvatar, - getDisplayNameForParticipant, - getIcons, - getPolicyName, - getReportActionActorAccountID, - getUserDetailTooltipText, - getWorkspaceIcon, - isChatReport, - isChatThread, - isIndividualInvoiceRoom, - isInvoiceReport, - isInvoiceRoom, - isPolicyExpenseChat, - isTripRoom, - shouldReportShowSubscript, -} from '@libs/ReportUtils'; +import {getReportActionAvatars, getUserDetailTooltipText, isChatReport} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList, ReportAction} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; import Icon from './Icon'; @@ -142,175 +126,10 @@ function sortIconsByName(icons: IconType[], personalDetails: OnyxInputOrEntry; - action: OnyxEntry; - chatReport: OnyxEntry; - personalDetails: OnyxEntry; - policies: OnyxCollection; - reportPreviewSenderID: number | undefined; -}) { - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); - - const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; - const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - - const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - - const invoiceReceiverPolicy = - chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.invoiceReceiver.policyID}`] : undefined; - - const {avatar, fallbackIcon} = personalDetails?.[accountID] ?? {}; - - const isATripRoom = isTripRoom(chatReport); - const isWorkspaceWithoutChatReportProp = !chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL; - const isWorkspaceChat = isPolicyExpenseChat(chatReport) || isWorkspaceWithoutChatReportProp; - const isChatReportOnlyProp = !iouReport && chatReport; - const isWorkspaceChatWithoutChatReport = !chatReport && isWorkspaceChat; - const isTripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; - const isReportPreviewOrNoAction = !action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const isReportPreviewInTripRoom = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isATripRoom; - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const usePersonalDetailsAvatars = (isChatReportOnlyProp || isWorkspaceChatWithoutChatReport) && isReportPreviewOrNoAction && !isTripPreview; - - // 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 && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; - const isAInvoiceReport = isInvoiceReport(iouReport ?? null); - const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || displayAllActors)); - - const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; - - const defaultAvatar = { - source: avatar ?? FallbackAvatar, - id: accountID, - name: defaultDisplayName, - type: CONST.ICON_TYPE_AVATAR, - fill: undefined, - fallbackIcon, - }; - - const defaultSecondaryAvatar = { - name: '', - source: '', - type: CONST.ICON_TYPE_AVATAR, - id: 0, - fill: undefined, - fallbackIcon, - }; - - const reportIcons = getIcons(chatReport ?? iouReport, personalDetails, undefined, undefined, undefined, policy); - - const getPrimaryAvatar = () => { - if (isWorkspaceActor) { - return { - ...defaultAvatar, - name: getPolicyName({report: chatReport, policy}), - type: CONST.ICON_TYPE_WORKSPACE, - source: getWorkspaceIcon(chatReport, policy).source, - id: chatReport?.policyID, - }; - } - - if (delegatePersonalDetails) { - return { - ...defaultAvatar, - name: delegatePersonalDetails?.displayName ?? '', - source: delegatePersonalDetails?.avatar ?? FallbackAvatar, - id: delegatePersonalDetails?.accountID, - }; - } - - if (isReportPreviewAction && isATripRoom) { - return { - ...defaultAvatar, - name: chatReport?.reportName ?? '', - source: personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar ?? FallbackAvatar, - id: ownerAccountID, - }; - } - - return defaultAvatar; - }; - - const getSecondaryAvatar = () => { - if (isTripPreview || isReportPreviewInTripRoom) { - return { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - source: policy?.avatarURL || getDefaultWorkspaceAvatar(policy?.name), - type: CONST.ICON_TYPE_WORKSPACE, - name: policy?.name, - id: policy?.id, - }; - } - - // If this is a report preview, display names and avatars of both people involved - if (displayAllActors) { - const secondaryAccountId = ownerAccountID === actorAccountID || isAInvoiceReport ? actorAccountID : ownerAccountID; - const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; - const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); - const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name); - const isWorkspaceInvoice = isInvoiceRoom(chatReport) && !isIndividualInvoiceRoom(chatReport); - - 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 = chatReport?.isOwnPolicyExpenseChat || isWorkspaceChat ? 0 : 1; - - return reportIcons.at(avatarIconIndex) ?? defaultSecondaryAvatar; - } - - if (isInvoiceReport(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 defaultSecondaryAvatar; - }; - - const primaryAvatar = (usePersonalDetailsAvatars ? reportIcons.at(0) : getPrimaryAvatar()) ?? defaultAvatar; - const secondaryAvatar = (usePersonalDetailsAvatars ? reportIcons.at(1) : getSecondaryAvatar()) ?? defaultSecondaryAvatar; - - return [primaryAvatar, secondaryAvatar]; -} - function ReportActionAvatars({ reportID: potentialReportID, action: passedAction, - accountIDs: passedAccountIDs, + accountIDs: unfilteredPassedAccountIDs = [], size = CONST.AVATAR_SIZE.DEFAULT, shouldShowTooltip = true, horizontalStacking, @@ -327,6 +146,8 @@ function ReportActionAvatars({ const StyleUtils = useStyleUtils(); const illustrations = useThemeIllustrations(); + const passedAccountIDs = unfilteredPassedAccountIDs.filter((accountID) => accountID !== CONST.DEFAULT_NUMBER_ID); + const reportID = potentialReportID ?? ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === passedAction?.actionName) ? passedAction?.childReportID : undefined); @@ -344,26 +165,10 @@ function ReportActionAvatars({ const chatReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? report : potentialChatReport; const subscriptAvatarSize = size === CONST.AVATAR_SIZE.X_LARGE ? CONST.AVATAR_SIZE.HEADER : CONST.AVATAR_SIZE.SUBSCRIPT; - const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); - const isATripRoom = isTripRoom(chatReport); - const isWorkspaceChat = isPolicyExpenseChat(chatReport) || (!chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL); - const isTripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; - const isReportPreviewInTripRoom = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isATripRoom; - const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); - - const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - - const {fallbackIcon} = personalDetails?.[accountID] ?? {}; const { displayInRows: shouldDisplayAvatarsInRows = false, isHovered = false, @@ -379,24 +184,28 @@ function ReportActionAvatars({ // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. const isReportArchived = useReportIsArchived(reportID); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; - const shouldDisplayAllActors = isReportPreviewAction && !isATripRoom && !isWorkspaceChat && !reportPreviewSenderID; - const isAInvoiceReport = isInvoiceReport(iouReport ?? null); - const isWorkspaceActor = isAInvoiceReport || (isWorkspaceChat && (!actorAccountID || shouldDisplayAllActors)); - const shouldShowAllActors = shouldDisplayAllActors && !reportPreviewSenderID; - const shouldShowConvertedSubscriptAvatar = (shouldStackHorizontally || passedAccountIDs) && shouldShowSubscriptAvatar && !reportPreviewSenderID; - const isReportPreviewOrNoAction = !action || action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; - - const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ + + const { + avatarType, + icons: [primaryAvatar, secondaryAvatar], + delegateAccountID, + accountID, + } = getReportActionAvatars({ chatReport, iouReport, action, personalDetails, reportPreviewSenderID, policies, + + shouldStackHorizontally, + isReportArchived, + shouldUseCardFeed: !!subscriptCardFeed, + shouldUseAccountIDs: passedAccountIDs.length > 0, }); + const {fallbackIcon} = personalDetails?.[accountID] ?? {}; + const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( () => ({ [CONST.AVATAR_SIZE.SMALL]: { @@ -420,7 +229,7 @@ function ReportActionAvatars({ ); const sortedAvatars = useMemo(() => { - const avatarsForAccountIDs: IconType[] = (passedAccountIDs ?? []).map((id) => ({ + const avatarsForAccountIDs: IconType[] = passedAccountIDs.map((id) => ({ id, type: CONST.ICON_TYPE_AVATAR, source: personalDetails?.[id]?.avatar ?? FallbackAvatar, @@ -493,13 +302,7 @@ function ReportActionAvatars({ return [firstRow, secondRow]; }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - if ( - (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isTripPreview) && - !shouldStackHorizontally && - !isChatThreadOutsideTripRoom && - !shouldShowConvertedSubscriptAvatar) || - !!subscriptCardFeed - ) { + if (avatarType === 'subscript') { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const containerStyle = StyleUtils.getContainerStyles(size); @@ -586,8 +389,7 @@ function ReportActionAvatars({ ); } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (passedAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar) { + if (avatarType === 'multiple') { if (!icons.length) { return null; } @@ -791,7 +593,7 @@ function ReportActionAvatars({ return ( @@ -812,4 +614,3 @@ function ReportActionAvatars({ } export default ReportActionAvatars; -export {getPrimaryAndSecondaryAvatar}; diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index 1c91322aadb38..bf863ec29fd7a 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -110,6 +110,7 @@ function InviteMemberListItem({ isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} + singleAvatarContainerStyle={[styles.actionAvatar, styles.mr3]} reportID={item.reportID} accountIDs={!item.reportID && item.accountID ? [item.accountID] : undefined} /> diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a49704a1d43dd..3fd4b29b688de 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3312,6 +3312,149 @@ function getIcons( return getIconsForParticipants(participantAccountIDs, personalDetails); } +function getReportActionAvatars({ + iouReport, + action, + chatReport, + personalDetails, + policies, + reportPreviewSenderID, + + shouldStackHorizontally = false, + shouldUseCardFeed = false, + isReportArchived = false, + shouldUseAccountIDs = false, +}: { + iouReport: OnyxEntry; + action: OnyxEntry; + chatReport: OnyxEntry; + personalDetails: OnyxEntry; + policies: OnyxCollection; + reportPreviewSenderID: number | undefined; + + shouldStackHorizontally?: boolean; + shouldUseCardFeed?: boolean; + isReportArchived?: boolean; + shouldUseAccountIDs?: boolean; +}) { + /* Get avatar type */ + + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const isATripRoom = isTripRoom(chatReport); + const isWorkspaceWithoutChatReportProp = !chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL; + const isAWorkspaceChat = isPolicyExpenseChat(chatReport) || isWorkspaceWithoutChatReportProp; + const isATripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; + const isAReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + const isReportPreviewOrNoAction = !action || isAReportPreviewAction; + const isReportPreviewInTripRoom = isAReportPreviewAction && isATripRoom; + + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const displayAllActors = isAReportPreviewAction && !isATripRoom && !isAWorkspaceChat && !reportPreviewSenderID; + + const shouldShowAllActors = displayAllActors && !reportPreviewSenderID; + const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; + const shouldShowSubscriptAvatar = shouldReportShowSubscript(iouReport ?? chatReport, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; + const shouldShowConvertedSubscriptAvatar = (shouldStackHorizontally || shouldUseAccountIDs) && shouldShowSubscriptAvatar && !reportPreviewSenderID; + + const shouldUseSubscriptAvatar = + (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isATripPreview) && + !shouldStackHorizontally && + !isChatThreadOutsideTripRoom && + !shouldShowConvertedSubscriptAvatar) || + shouldUseCardFeed; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldUseMultipleAvatars = shouldUseAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar; + + let avatarType = 'single'; + + if (shouldUseSubscriptAvatar) { + avatarType = 'subscript'; + } else if (shouldUseMultipleAvatars) { + avatarType = 'multiple'; + } + + /* Get correct primary & secondary icon */ + + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); + const invoiceReceiverPolicy = + chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.invoiceReceiver.policyID}`] : undefined; + const {avatar, fallbackIcon, login} = personalDetails?.[accountID] ?? {}; + + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; + const isAInvoiceReport = isInvoiceReport(iouReport ?? null); + const isWorkspaceActor = isAInvoiceReport || (isAWorkspaceChat && (!actorAccountID || displayAllActors)); + const isChatReportOnlyProp = !iouReport && chatReport; + const isWorkspaceChatWithoutChatReport = !chatReport && isAWorkspaceChat; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const usePersonalDetailsAvatars = (isChatReportOnlyProp || isWorkspaceChatWithoutChatReport) && isReportPreviewOrNoAction && !isATripPreview; + + const getIconsWithDefaults = (report: OnyxInputOrEntry) => + getIcons(report, personalDetails, avatar ?? fallbackIcon ?? FallbackAvatar, defaultDisplayName, accountID, policy, invoiceReceiverPolicy); + + const reportIcons = getIconsWithDefaults(chatReport ?? iouReport); + + let primaryAvatar; + + if (isWorkspaceActor || usePersonalDetailsAvatars) { + primaryAvatar = reportIcons.at(0); + } else if (delegatePersonalDetails) { + primaryAvatar = getIconsWithDefaults(iouReport).at(0); + } else if (isAReportPreviewAction && isATripRoom) { + primaryAvatar = reportIcons.at(0); + } + + primaryAvatar ??= { + source: avatar ?? FallbackAvatar, + id: accountID, + name: defaultDisplayName, + type: CONST.ICON_TYPE_AVATAR, + fill: undefined, + fallbackIcon, + }; + + let secondaryAvatar; + + if (usePersonalDetailsAvatars) { + secondaryAvatar = reportIcons.at(1); + } else if (isATripPreview) { + secondaryAvatar = reportIcons.at(0); + } else if (isReportPreviewInTripRoom || displayAllActors) { + const iouReportIcons = getIconsWithDefaults(iouReport); + secondaryAvatar = iouReportIcons.at(iouReportIcons.at(1)?.id === primaryAvatar.id ? 0 : 1); + } else if (!isWorkspaceActor) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + secondaryAvatar = reportIcons.at(chatReport?.isOwnPolicyExpenseChat || isAWorkspaceChat ? 0 : 1); + } else if (isAInvoiceReport) { + secondaryAvatar = reportIcons.at(1); + } + + secondaryAvatar ??= { + name: '', + source: '', + type: CONST.ICON_TYPE_AVATAR, + id: 0, + fill: undefined, + fallbackIcon, + }; + + return { + icons: [primaryAvatar, secondaryAvatar], + delegateAccountID: !isWorkspaceActor && delegatePersonalDetails ? actorAccountID : undefined, + avatarType, + accountID, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + actorHint: String(isWorkspaceActor ? primaryAvatar.id : login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''), + isWorkspaceActor, + shouldDisplayAllActors: displayAllActors, + }; +} + function getDisplayNamesWithTooltips( personalDetailsList: PersonalDetails[] | PersonalDetailsList | OptionData[], shouldUseShortForm: boolean, @@ -11252,6 +11395,7 @@ export { getUpgradeWorkspaceMessage, getDowngradeWorkspaceMessage, getIcons, + getReportActionAvatars, getIconsForParticipants, getIndicatedMissingPaymentMethod, getLastVisibleMessage, diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 164227c5a7187..b4148d0394311 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import ReportActionAvatars, {getPrimaryAndSecondaryAvatar} from '@components/ReportActionAvatars'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; @@ -19,16 +19,7 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; -import { - getDisplayNameForParticipant, - getPolicyName, - getReportActionActorAccountID, - isInvoiceReport as isInvoiceReportUtils, - isOptimisticPersonalDetail, - isPolicyExpenseChat, - isTripRoom as isTripRoomReportUtils, - shouldReportShowSubscript, -} from '@libs/ReportUtils'; +import {getReportActionAvatars, isOptimisticPersonalDetail} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -114,38 +105,30 @@ function ReportActionItemSingle({ const isReportArchived = useReportIsArchived(iouReportID); - const [primaryAvatar, secondaryAvatar] = getPrimaryAndSecondaryAvatar({ + const { + avatarType, + icons: [primaryAvatar, secondaryAvatar], + delegateAccountID, + accountID, + actorHint, + shouldDisplayAllActors, + isWorkspaceActor, + } = getReportActionAvatars({ chatReport, iouReport, action, personalDetails, reportPreviewSenderID, policies, + isReportArchived, }); - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); - - const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const isTripRoom = isTripRoomReportUtils(chatReport); - const shouldDisplayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(chatReport) && !reportPreviewSenderID; - const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); - const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(chatReport) && (!actorAccountID || shouldDisplayAllActors)); - const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? iouReport?.policyID : chatReport?.policyID; - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const shouldShowSubscriptAvatar = shouldReportShowSubscript(chatReport, isReportArchived); - - const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const actorHint = isWorkspaceActor ? getPolicyName({report: chatReport, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); - const showMultipleUserAvatarPattern = shouldDisplayAllActors && !shouldShowSubscriptAvatar; - const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; + const headingText = avatarType === 'multiple' ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; // 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, @@ -237,13 +220,13 @@ function ReportActionItemSingle({ ))} From 4a3ad788b2c73bc1c9b6ed981c8935341f1da1c6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 29 Jul 2025 12:35:18 +0200 Subject: [PATCH 26/33] Fixes after merge --- src/components/AvatarWithDisplayName.tsx | 2 +- src/libs/ReportUtils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 2a69889565248..898fb8c1f2e6f 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -167,7 +167,7 @@ function AvatarWithDisplayName({ const parentReportActionParam = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const title = getReportName(report, undefined, parentReportActionParam, personalDetails, invoiceReceiverPolicy, reportAttributes); const subtitle = getChatRoomSubtitle(report, {isCreateExpenseFlow: true}); - const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy); + const parentNavigationSubtitleData = getParentNavigationSubtitle(report); const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report); const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fda67d504b0e3..598540a52178a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5535,7 +5535,7 @@ function getPendingChatMembers(accountIDs: number[], previousPendingChatMembers: /** * Gets the parent navigation subtitle for the report */ -function getParentNavigationSubtitle(report: OnyxEntry, policy?: Policy): ParentNavigationSummaryParams { +function getParentNavigationSubtitle(report: OnyxEntry): ParentNavigationSummaryParams { const parentReport = getParentReport(report); if (isEmptyObject(parentReport)) { const ownerAccountID = report?.ownerAccountID; @@ -5547,7 +5547,7 @@ function getParentNavigationSubtitle(report: OnyxEntry, policy?: Policy) if (isExpenseReport(report)) { return { reportName: translateLocal('workspace.common.policyExpenseChatName', {displayName: reportOwnerDisplayName ?? ''}), - workspaceName: policy?.name, + workspaceName: getPolicyName({report}), }; } if (isIOUReport(report)) { From 841c90de471f5f4ca3d87a7feefd378897e02e4a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 29 Jul 2025 15:09:25 +0200 Subject: [PATCH 27/33] Break ReportActionAvatars into UI components --- src/CONST/index.ts | 15 + src/components/ReportActionAvatars.tsx | 616 ------------------ .../ReportActionAvatar.tsx | 552 ++++++++++++++++ src/components/ReportActionAvatars/index.tsx | 184 ++++++ .../TransactionPreviewContent.tsx | 2 +- src/libs/ReportUtils.ts | 22 +- .../home/report/ReportActionItemCreated.tsx | 2 +- .../home/report/ReportActionItemSingle.tsx | 4 +- .../home/report/ReportActionItemThread.tsx | 2 +- tests/ui/ReportActionAvatarsTest.tsx | 4 +- 10 files changed, 772 insertions(+), 631 deletions(-) delete mode 100644 src/components/ReportActionAvatars.tsx create mode 100644 src/components/ReportActionAvatars/ReportActionAvatar.tsx create mode 100644 src/components/ReportActionAvatars/index.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 732b53c262440..b9e35dff3d315 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -292,6 +292,21 @@ const CONST = { AVATAR_MIN_WIDTH_PX: 80, AVATAR_MIN_HEIGHT_PX: 80, + REPORT_ACTION_AVATARS: { + TYPE: { + MULTIPLE: 'multiple', + MULTIPLE_DIAGONAL: 'multipleDiagonal', + MULTIPLE_HORIZONTAL: 'multipleHorizontal', + SUBSCRIPT: 'subscript', + SINGLE: 'single', + }, + SORT_BY: { + ID: 'id', + NAME: 'name', + REVERSE: 'reverse', + }, + }, + // Maximum width and height size in px for a selected image AVATAR_MAX_WIDTH_PX: 4096, AVATAR_MAX_HEIGHT_PX: 4096, diff --git a/src/components/ReportActionAvatars.tsx b/src/components/ReportActionAvatars.tsx deleted file mode 100644 index b03693a36633c..0000000000000 --- a/src/components/ReportActionAvatars.tsx +++ /dev/null @@ -1,616 +0,0 @@ -import lodashSortBy from 'lodash/sortBy'; -import React, {useMemo} from 'react'; -import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; -import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeIllustrations from '@hooks/useThemeIllustrations'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getCardFeedIcon} from '@libs/CardUtils'; -import localeCompare from '@libs/LocaleCompare'; -import {getReportAction} from '@libs/ReportActionsUtils'; -import {getReportActionAvatars, getUserDetailTooltipText, isChatReport} from '@libs/ReportUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList, ReportAction} from '@src/types/onyx'; -import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; -import Avatar from './Avatar'; -import Icon from './Icon'; -import {FallbackAvatar} from './Icon/Expensicons'; -import {WorkspaceBuilding} from './Icon/WorkspaceDefaultAvatars'; -import Text from './Text'; -import Tooltip from './Tooltip'; -import UserDetailsTooltip from './UserDetailsTooltip'; - -type SortingOptions = 'byID' | 'byName' | 'reverse'; - -/** Prop to identify if we should load avatars vertically instead of diagonally */ -type HorizontalStacking = - | Partial<{ - /** Prop to identify if we should display avatars in rows */ - displayInRows: boolean; - - /** Whether the avatars are hovered */ - isHovered: boolean; - - /** Whether the avatars are active */ - isActive: boolean; - - /** Whether the avatars are in an element being pressed */ - isPressed: boolean; - - /** Prop to limit the amount of avatars displayed horizontally */ - overlapDivider: number; - - /** Prop to limit the amount of avatars displayed horizontally */ - maxAvatarsInRow: number; - - /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ - useCardBG: boolean; - - /** Prop to sort the avatars */ - sort: SortingOptions | SortingOptions[]; - }> - | boolean; - -type ReportActionAvatarsProps = { - horizontalStacking?: HorizontalStacking; - - /** IOU Report ID for single avatar */ - reportID?: string; - - /** IOU Report ID for single avatar */ - action?: OnyxEntry; - - /** Single avatar container styles */ - singleAvatarContainerStyle?: ViewStyle[]; - - /** Border color for the subscript avatar */ - subscriptAvatarBorderColor?: ColorValue; - - /** Whether to show the subscript avatar without margin */ - noRightMarginOnSubscriptContainer?: boolean; - - /** Account IDs to display avatars for, it overrides the reportID and action props */ - accountIDs?: number[]; - - /** Set the size of avatars */ - size?: ValueOf; - - /** Style for Second Avatar */ - secondaryAvatarContainerStyle?: StyleProp; - - /** Whether #focus mode is on */ - useMidSubscriptSizeForMultipleAvatars?: boolean; - - /** Whether avatars are displayed within a reportAction */ - isInReportAction?: boolean; - - /** Whether to show the tooltip text */ - shouldShowTooltip?: boolean; - - /** Subscript card feed to display instead of the second avatar */ - subscriptCardFeed?: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK; -}; - -type AvatarStyles = { - singleAvatarStyle: ViewStyle & ImageStyle; - secondAvatarStyles: ViewStyle & ImageStyle; -}; - -type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; - -type AvatarSizeToStylesMap = Record; - -const getIconDisplayName = (icon: IconType, personalDetails: OnyxInputOrEntry) => - icon.id ? (personalDetails?.[icon.id]?.displayName ?? personalDetails?.[icon.id]?.login ?? '') : ''; - -function sortIconsByName(icons: IconType[], personalDetails: OnyxInputOrEntry) { - return icons.sort((first, second) => { - // First sort by displayName/login - const displayNameLoginOrder = localeCompare(getIconDisplayName(first, personalDetails), getIconDisplayName(second, personalDetails)); - if (displayNameLoginOrder !== 0) { - return displayNameLoginOrder; - } - - // Then fallback on accountID as the final sorting criteria. - // This will ensure that the order of avatars with same login/displayName - // stay consistent across all users and devices - return Number(first?.id) - Number(second?.id); - }); -} - -function ReportActionAvatars({ - reportID: potentialReportID, - action: passedAction, - accountIDs: unfilteredPassedAccountIDs = [], - size = CONST.AVATAR_SIZE.DEFAULT, - shouldShowTooltip = true, - horizontalStacking, - singleAvatarContainerStyle, - subscriptAvatarBorderColor, - noRightMarginOnSubscriptContainer = false, - subscriptCardFeed, - secondaryAvatarContainerStyle, - useMidSubscriptSizeForMultipleAvatars = false, - isInReportAction = false, -}: ReportActionAvatarsProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const illustrations = useThemeIllustrations(); - - const passedAccountIDs = unfilteredPassedAccountIDs.filter((accountID) => accountID !== CONST.DEFAULT_NUMBER_ID); - - const reportID = - potentialReportID ?? - ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === passedAction?.actionName) ? passedAction?.childReportID : undefined); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); - - const [potentialChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); - - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - canBeMissing: true, - }); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - - const iouReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? undefined : report; - const chatReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? report : potentialChatReport; - const subscriptAvatarSize = size === CONST.AVATAR_SIZE.X_LARGE ? CONST.AVATAR_SIZE.HEADER : CONST.AVATAR_SIZE.SUBSCRIPT; - - const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); - - const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); - - const { - displayInRows: shouldDisplayAvatarsInRows = false, - isHovered = false, - isActive = false, - isPressed = false, - overlapDivider = 3, - maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, - sort: sortAvatars = undefined, - useCardBG: shouldUseCardBackground = false, - } = typeof horizontalStacking === 'boolean' ? {} : (horizontalStacking ?? {}); - - const shouldStackHorizontally = !!horizontalStacking; - - // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const isReportArchived = useReportIsArchived(reportID); - - const { - avatarType, - icons: [primaryAvatar, secondaryAvatar], - delegateAccountID, - accountID, - } = getReportActionAvatars({ - chatReport, - iouReport, - action, - personalDetails, - reportPreviewSenderID, - policies, - - shouldStackHorizontally, - isReportArchived, - shouldUseCardFeed: !!subscriptCardFeed, - shouldUseAccountIDs: passedAccountIDs.length > 0, - }); - - const {fallbackIcon} = personalDetails?.[accountID] ?? {}; - - const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( - () => ({ - [CONST.AVATAR_SIZE.SMALL]: { - singleAvatarStyle: styles.singleAvatarSmall, - secondAvatarStyles: styles.secondAvatarSmall, - }, - [CONST.AVATAR_SIZE.LARGE]: { - singleAvatarStyle: styles.singleAvatarMedium, - secondAvatarStyles: styles.secondAvatarMedium, - }, - [CONST.AVATAR_SIZE.X_LARGE]: { - singleAvatarStyle: styles.singleAvatarLarge, - secondAvatarStyles: styles.secondAvatarLarge, - }, - [CONST.AVATAR_SIZE.DEFAULT]: { - singleAvatarStyle: styles.singleAvatar, - secondAvatarStyles: styles.secondAvatar, - }, - }), - [styles], - ); - - const sortedAvatars = useMemo(() => { - const avatarsForAccountIDs: IconType[] = passedAccountIDs.map((id) => ({ - id, - type: CONST.ICON_TYPE_AVATAR, - source: personalDetails?.[id]?.avatar ?? FallbackAvatar, - name: personalDetails?.[id]?.login ?? '', - })); - - const multipleAvatars = avatarsForAccountIDs.length > 0 ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar]; - - if (sortAvatars?.includes('byName')) { - return sortIconsByName(multipleAvatars, personalDetails); - } - return sortAvatars?.includes('byID') ? lodashSortBy(multipleAvatars, (icon) => icon.id) : multipleAvatars; - }, [passedAccountIDs, personalDetails, primaryAvatar, secondaryAvatar, sortAvatars]); - - const icons = sortAvatars?.includes('reverse') ? sortedAvatars.reverse() : sortedAvatars; - - const secondaryAvatarContainerStyles = secondaryAvatarContainerStyle ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; - - let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); - const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); - - const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); - - const avatarSize = useMemo(() => { - if (useMidSubscriptSizeForMultipleAvatars) { - return CONST.AVATAR_SIZE.MID_SUBSCRIPT; - } - - if (size === CONST.AVATAR_SIZE.LARGE) { - return CONST.AVATAR_SIZE.MEDIUM; - } - - if (size === CONST.AVATAR_SIZE.X_LARGE) { - return CONST.AVATAR_SIZE.LARGE; - } - - return CONST.AVATAR_SIZE.SMALLER; - }, [useMidSubscriptSizeForMultipleAvatars, size]); - - const subscriptAvatarStyle = useMemo(() => { - if (size === CONST.AVATAR_SIZE.SMALL) { - return styles.secondAvatarSubscriptCompact; - } - - if (size === CONST.AVATAR_SIZE.SMALL_NORMAL) { - return styles.secondAvatarSubscriptSmallNormal; - } - - if (size === CONST.AVATAR_SIZE.X_LARGE) { - return styles.secondAvatarSubscriptXLarge; - } - - return styles.secondAvatarSubscript; - }, [size, styles]); - - const avatarRows = useMemo(() => { - // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row - if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { - return [icons]; - } - - // Calculate the size of each row - const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); - - // Slice the icons array into two rows - const firstRow = icons.slice(0, rowSize); - const secondRow = icons.slice(rowSize); - - // Update the state with the two rows as an array - return [firstRow, secondRow]; - }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - - if (avatarType === 'subscript') { - const isSmall = size === CONST.AVATAR_SIZE.SMALL; - const containerStyle = StyleUtils.getContainerStyles(size); - - return ( - - - - - - - {!!secondaryAvatar && !subscriptCardFeed && ( - - - - - - )} - {!!subscriptCardFeed && ( - - - - )} - - ); - } - - if (avatarType === 'multiple') { - if (!icons.length) { - return null; - } - - if (icons.length === 1 && !shouldStackHorizontally) { - return ( - - - - - - ); - } - - const oneAvatarSize = StyleUtils.getAvatarStyle(size); - const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; - const overlapSize = oneAvatarSize.width / overlapDivider; - if (shouldStackHorizontally) { - // Height of one avatar + border space - const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; - avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); - } - const useHugeBottomMargin = icons.length === 2 && size === CONST.AVATAR_SIZE.X_LARGE; - - return shouldStackHorizontally ? ( - avatarRows.map((avatars, rowIndex) => ( - - {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( - - - - - - ))} - {avatars.length > maxAvatarsInRow && ( - - - - {`+${avatars.length - maxAvatarsInRow}`} - - - - )} - - )) - ) : ( - - - - {/* View is necessary for tooltip to show for multiple avatars in LHN */} - - - - - - {icons.length === 2 ? ( - - - - - - ) : ( - - - - {`+${icons.length - 1}`} - - - - )} - - - - ); - } - - return ( - - - - - - ); -} - -export default ReportActionAvatars; diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx new file mode 100644 index 0000000000000..563cba8d8ce15 --- /dev/null +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -0,0 +1,552 @@ +import lodashSortBy from 'lodash/sortBy'; +import React, {useMemo} from 'react'; +import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import Avatar from '@components/Avatar'; +import Icon from '@components/Icon'; +import {WorkspaceBuilding} from '@components/Icon/WorkspaceDefaultAvatars'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeIllustrations from '@hooks/useThemeIllustrations'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getCardFeedIcon} from '@libs/CardUtils'; +import localeCompare from '@libs/LocaleCompare'; +import {getUserDetailTooltipText} from '@libs/ReportUtils'; +import type {AvatarSource} from '@libs/UserUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList} from '@src/types/onyx'; +import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; + +type SortingOptions = ValueOf; + +type HorizontalStacking = Partial<{ + /** Prop to identify if we should display avatars in rows */ + displayInRows: boolean; + + /** Whether the avatars are hovered */ + isHovered: boolean; + + /** Whether the avatars are active */ + isActive: boolean; + + /** Whether the avatars are in an element being pressed */ + isPressed: boolean; + + /** Prop to limit the amount of avatars displayed horizontally */ + overlapDivider: number; + + /** Prop to limit the amount of avatars displayed horizontally */ + maxAvatarsInRow: number; + + /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ + useCardBG: boolean; + + /** Prop to sort the avatars */ + sort: SortingOptions | SortingOptions[]; +}>; + +type AvatarStyles = { + singleAvatarStyle: ViewStyle & ImageStyle; + secondAvatarStyles: ViewStyle & ImageStyle; +}; + +type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; + +type AvatarSizeToStylesMap = Record; + +function ReportActionAvatarSingle({ + avatar, + size, + containerStyles, + shouldShowTooltip, + delegateAccountID, + accountID, + fallbackIcon, + isInReportAction, +}: { + avatar: IconType | undefined; + size: ValueOf; + containerStyles?: StyleProp; + shouldShowTooltip: boolean; + accountID: number; + delegateAccountID?: number; + fallbackIcon?: AvatarSource; + isInReportAction?: boolean; +}) { + const StyleUtils = useStyleUtils(); + const avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); + + return ( + + + + + + ); +} + +function ReportActionAvatarSubscript({ + primaryAvatar, + secondaryAvatar, + size, + shouldShowTooltip, + noRightMarginOnContainer, + subscriptAvatarBorderColor, + subscriptCardFeed, +}: { + primaryAvatar: IconType; + secondaryAvatar: IconType; + size: ValueOf; + shouldShowTooltip: boolean; + noRightMarginOnContainer?: boolean; + subscriptAvatarBorderColor?: ColorValue; + subscriptCardFeed?: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK; +}) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const illustrations = useThemeIllustrations(); + + const isSmall = size === CONST.AVATAR_SIZE.SMALL; + const containerStyle = StyleUtils.getContainerStyles(size); + + const subscriptAvatarStyle = useMemo(() => { + if (size === CONST.AVATAR_SIZE.SMALL) { + return styles.secondAvatarSubscriptCompact; + } + + if (size === CONST.AVATAR_SIZE.SMALL_NORMAL) { + return styles.secondAvatarSubscriptSmallNormal; + } + + if (size === CONST.AVATAR_SIZE.X_LARGE) { + return styles.secondAvatarSubscriptXLarge; + } + + return styles.secondAvatarSubscript; + }, [size, styles]); + + const subscriptAvatarSize = size === CONST.AVATAR_SIZE.X_LARGE ? CONST.AVATAR_SIZE.HEADER : CONST.AVATAR_SIZE.SUBSCRIPT; + + return ( + + + + + + + {!!secondaryAvatar && !subscriptCardFeed && ( + + + + + + )} + {!!subscriptCardFeed && ( + + + + )} + + ); +} +const getIconDisplayName = (icon: IconType, personalDetails: OnyxInputOrEntry) => + icon.id ? (personalDetails?.[icon.id]?.displayName ?? personalDetails?.[icon.id]?.login ?? '') : ''; + +function sortIconsByName(icons: IconType[], personalDetails: OnyxInputOrEntry) { + return icons.sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = localeCompare(getIconDisplayName(first, personalDetails), getIconDisplayName(second, personalDetails)); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } + + // Then fallback on accountID as the final sorting criteria. + // This will ensure that the order of avatars with same login/displayName + // stay consistent across all users and devices + return Number(first?.id) - Number(second?.id); + }); +} + +function ReportActionAvatarMultipleHorizontal({ + isHovered = false, + isActive = false, + isPressed = false, + maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, + displayInRows: shouldDisplayAvatarsInRows = false, + useCardBG: shouldUseCardBackground = false, + overlapDivider = 3, + size, + shouldShowTooltip, + icons: unsortedIcons, + isInReportAction, + sort: sortAvatars, + personalDetails, +}: HorizontalStacking & { + size: ValueOf; + shouldShowTooltip: boolean; + icons: IconType[]; + isInReportAction: boolean; + personalDetails: OnyxEntry; +}) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const oneAvatarSize = StyleUtils.getAvatarStyle(size); + const overlapSize = oneAvatarSize.width / overlapDivider; + const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; + const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; + const avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); + + const icons = useMemo(() => { + let avatars: IconType[] = []; + + if (sortAvatars?.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME)) { + avatars = sortIconsByName(unsortedIcons, personalDetails); + } else if (sortAvatars?.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.ID)) { + avatars = lodashSortBy(unsortedIcons, (icon) => icon.id); + } + + return sortAvatars?.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.REVERSE) ? avatars.reverse() : avatars; + }, [unsortedIcons, personalDetails, sortAvatars]); + + const avatarRows = useMemo(() => { + // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row + if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { + return [icons]; + } + + // Calculate the size of each row + const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); + + // Slice the icons array into two rows + const firstRow = icons.slice(0, rowSize); + const secondRow = icons.slice(rowSize); + + // Update the state with the two rows as an array + return [firstRow, secondRow]; + }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); + + const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); + + return avatarRows.map((avatars, rowIndex) => ( + + {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( + + + + + + ))} + {avatars.length > maxAvatarsInRow && ( + + + + {`+${avatars.length - maxAvatarsInRow}`} + + + + )} + + )); +} + +function ReportActionAvatarMultipleDiagonal({ + size, + shouldShowTooltip, + icons, + isInReportAction, + useMidSubscriptSize, + secondaryAvatarContainerStyle, + isHovered = false, +}: { + size: ValueOf; + shouldShowTooltip: boolean; + icons: IconType[]; + isInReportAction: boolean; + useMidSubscriptSize: boolean; + secondaryAvatarContainerStyle?: StyleProp; + isHovered?: boolean; +}) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); + const useHugeBottomMargin = icons.length === 2 && size === CONST.AVATAR_SIZE.X_LARGE; + const avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); + + const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( + () => ({ + [CONST.AVATAR_SIZE.SMALL]: { + singleAvatarStyle: styles.singleAvatarSmall, + secondAvatarStyles: styles.secondAvatarSmall, + }, + [CONST.AVATAR_SIZE.LARGE]: { + singleAvatarStyle: styles.singleAvatarMedium, + secondAvatarStyles: styles.secondAvatarMedium, + }, + [CONST.AVATAR_SIZE.X_LARGE]: { + singleAvatarStyle: styles.singleAvatarLarge, + secondAvatarStyles: styles.secondAvatarLarge, + }, + [CONST.AVATAR_SIZE.DEFAULT]: { + singleAvatarStyle: styles.singleAvatar, + secondAvatarStyles: styles.secondAvatar, + }, + }), + [styles], + ); + + const avatarSize = useMemo(() => { + if (useMidSubscriptSize) { + return CONST.AVATAR_SIZE.MID_SUBSCRIPT; + } + + if (size === CONST.AVATAR_SIZE.LARGE) { + return CONST.AVATAR_SIZE.MEDIUM; + } + + if (size === CONST.AVATAR_SIZE.X_LARGE) { + return CONST.AVATAR_SIZE.LARGE; + } + + return CONST.AVATAR_SIZE.SMALLER; + }, [useMidSubscriptSize, size]); + + const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]); + const secondaryAvatarContainerStyles = secondaryAvatarContainerStyle ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; + + return ( + + + + {/* View is necessary for tooltip to show for multiple avatars in LHN */} + + + + + + {icons.length === 2 ? ( + + + + + + ) : ( + + + + {`+${icons.length - 1}`} + + + + )} + + + + ); +} + +export default { + Single: ReportActionAvatarSingle, + Subscript: ReportActionAvatarSubscript, + Multiple: { + Diagonal: ReportActionAvatarMultipleDiagonal, + Horizontal: ReportActionAvatarMultipleHorizontal, + }, +}; + +export type {HorizontalStacking}; diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx new file mode 100644 index 0000000000000..c59f225b2bb3c --- /dev/null +++ b/src/components/ReportActionAvatars/index.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; +import {getReportAction} from '@libs/ReportActionsUtils'; +import {getReportActionAvatars, isChatReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CompanyCardFeed, ReportAction} from '@src/types/onyx'; +import type {HorizontalStacking} from './ReportActionAvatar'; +import ReportActionAvatar from './ReportActionAvatar'; + +type ReportActionAvatarsProps = { + horizontalStacking?: HorizontalStacking | boolean; + + /** IOU Report ID for single avatar */ + reportID?: string; + + /** IOU Report ID for single avatar */ + action?: OnyxEntry; + + /** Single avatar container styles */ + singleAvatarContainerStyle?: ViewStyle[]; + + /** Account IDs to display avatars for, it overrides the reportID and action props */ + accountIDs?: number[]; + + /** Set the size of avatars */ + size?: ValueOf; + + /** Style for Second Avatar */ + secondaryAvatarContainerStyle?: StyleProp; + + /** Whether #focus mode is on */ + useMidSubscriptSizeForMultipleAvatars?: boolean; + + /** Whether avatars are displayed within a reportAction */ + isInReportAction?: boolean; + + /** Whether to show the tooltip text */ + shouldShowTooltip?: boolean; + + /** Whether to show the subscript avatar without margin */ + noRightMarginOnSubscriptContainer?: boolean; + + /** Border color for the subscript avatar */ + subscriptAvatarBorderColor?: ColorValue; + + /** Subscript card feed to display instead of the second avatar */ + subscriptCardFeed?: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK; +}; + +function ReportActionAvatars({ + reportID: potentialReportID, + action: passedAction, + accountIDs: passedAccountIDs = [], + size = CONST.AVATAR_SIZE.DEFAULT, + shouldShowTooltip = true, + horizontalStacking, + singleAvatarContainerStyle, + subscriptAvatarBorderColor, + noRightMarginOnSubscriptContainer = false, + subscriptCardFeed, + secondaryAvatarContainerStyle, + useMidSubscriptSizeForMultipleAvatars = false, + isInReportAction = false, +}: ReportActionAvatarsProps) { + const accountIDs = passedAccountIDs.filter((accountID) => accountID !== CONST.DEFAULT_NUMBER_ID); + + const reportID = + potentialReportID ?? + ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === passedAction?.actionName) ? passedAction?.childReportID : undefined); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); + + const [potentialChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + + const iouReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? undefined : report; + const chatReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? report : potentialChatReport; + + const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); + + const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); + + const shouldStackHorizontally = !!horizontalStacking; + const isHorizontalStackingAnObject = shouldStackHorizontally && typeof horizontalStacking !== 'boolean'; + const {isHovered = false} = isHorizontalStackingAnObject ? horizontalStacking : {}; + + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const isReportArchived = useReportIsArchived(reportID); + + const { + avatarType: notPreciseAvatarType, + icons, + delegateAccountID, + } = getReportActionAvatars({ + chatReport, + iouReport, + action, + personalDetails, + reportPreviewSenderID, + policies, + shouldStackHorizontally, + isReportArchived, + shouldUseCardFeed: !!subscriptCardFeed, + accountIDs, + }); + + let avatarType: ValueOf = notPreciseAvatarType; + + if (avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE && !icons.length) { + return null; + } + + if (avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE) { + avatarType = shouldStackHorizontally ? CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE_HORIZONTAL : CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE_DIAGONAL; + } + + const [primaryAvatar, secondaryAvatar] = icons; + + if (avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT) { + return ( + + ); + } + + if (avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE_HORIZONTAL) { + return ( + + ); + } + + if (avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE_DIAGONAL && icons.length !== 1) { + return ( + + ); + } + + return ( + + ); +} + +export default ReportActionAvatars; diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index a361b411a8717..12bb2564e5d69 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -245,7 +245,7 @@ function TransactionPreviewContent({ ; action: OnyxEntry; @@ -3335,7 +3335,7 @@ function getReportActionAvatars({ shouldStackHorizontally?: boolean; shouldUseCardFeed?: boolean; isReportArchived?: boolean; - shouldUseAccountIDs?: boolean; + accountIDs?: number[]; }) { /* Get avatar type */ @@ -3353,6 +3353,7 @@ function getReportActionAvatars({ // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. const displayAllActors = isAReportPreviewAction && !isATripRoom && !isAWorkspaceChat && !reportPreviewSenderID; + const shouldUseAccountIDs = accountIDs.length > 0; const shouldShowAllActors = displayAllActors && !reportPreviewSenderID; const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; const shouldShowSubscriptAvatar = shouldReportShowSubscript(iouReport ?? chatReport, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; @@ -3368,12 +3369,12 @@ function getReportActionAvatars({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldUseMultipleAvatars = shouldUseAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar; - let avatarType = 'single'; + let avatarType: ValueOf = CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE; if (shouldUseSubscriptAvatar) { - avatarType = 'subscript'; + avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT; } else if (shouldUseMultipleAvatars) { - avatarType = 'multiple'; + avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE; } /* Get correct primary & secondary icon */ @@ -3443,8 +3444,15 @@ function getReportActionAvatars({ fallbackIcon, }; + const avatarsForAccountIDs: IconType[] = accountIDs.map((id) => ({ + id, + type: CONST.ICON_TYPE_AVATAR, + source: personalDetails?.[id]?.avatar ?? FallbackAvatar, + name: personalDetails?.[id]?.login ?? '', + })); + return { - icons: [primaryAvatar, secondaryAvatar], + icons: avatarsForAccountIDs.length > 0 && avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar], delegateAccountID: !isWorkspaceActor && delegatePersonalDetails ? actorAccountID : undefined, avatarType, accountID, diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index ba15e8e9556c6..01e3d19d712bb 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -65,7 +65,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr displayInRows: shouldUseNarrowLayout, maxAvatarsInRow: shouldUseNarrowLayout ? CONST.AVATAR_ROW_SIZE.DEFAULT : CONST.AVATAR_ROW_SIZE.LARGE_SCREEN, overlapDivider: 4, - sort: isInvoiceRoom(report) && isCurrentUserInvoiceReceiver(report) ? 'reverse' : undefined, + sort: isInvoiceRoom(report) && isCurrentUserInvoiceReceiver(report) ? CONST.REPORT_ACTION_AVATARS.SORT_BY.REVERSE : undefined, }} /> diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index b4148d0394311..3f37984437218 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -128,7 +128,7 @@ function ReportActionItemSingle({ const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); - const headingText = avatarType === 'multiple' ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; + const headingText = avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; // 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, @@ -226,7 +226,7 @@ function ReportActionItemSingle({ isSingleLine actorIcon={primaryAvatar} moderationDecision={getReportActionMessage(action)?.moderationDecision?.decision} - shouldShowTooltip={avatarType !== 'multiple'} + shouldShowTooltip={avatarType !== CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE} /> ))} diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index 733ae4de8cd84..4b8ddf794a0b5 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -67,7 +67,7 @@ function ReportActionItemThread({numberOfReplies, accountIDs, mostRecentReply, r horizontalStacking={{ isHovered, isActive, - sort: 'byName', + sort: CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME, }} isInReportAction /> diff --git a/tests/ui/ReportActionAvatarsTest.tsx b/tests/ui/ReportActionAvatarsTest.tsx index a6915d7fea1e7..0324e7a1d005f 100644 --- a/tests/ui/ReportActionAvatarsTest.tsx +++ b/tests/ui/ReportActionAvatarsTest.tsx @@ -365,9 +365,7 @@ function isMultipleAvatarRendered({ } function isSingleAvatarRendered({images, negate = false, userAvatar}: {images: AvatarData[]; negate?: boolean; userAvatar?: string}) { - const isUserAvatarCorrect = images.some( - (image) => image.uri === (userAvatar ?? USER_AVATAR) && ['ReportActionAvatars-SingleAvatar', 'ReportActionAvatars-MultipleAvatars-OneIcon'].includes(image.parent), - ); + const isUserAvatarCorrect = images.some((image) => image.uri === (userAvatar ?? USER_AVATAR) && image.parent === 'ReportActionAvatars-SingleAvatar'); expect(isUserAvatarCorrect).toBe(!negate); } From f603a0337a37eecbf1692d5acb52ab9332caa4da Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 30 Jul 2025 09:52:20 +0200 Subject: [PATCH 28/33] Fix Horizontal avatars default icons --- src/components/ReportActionAvatars/ReportActionAvatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx index 563cba8d8ce15..2db040df09367 100644 --- a/src/components/ReportActionAvatars/ReportActionAvatar.tsx +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -284,7 +284,7 @@ function ReportActionAvatarMultipleHorizontal({ const avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); const icons = useMemo(() => { - let avatars: IconType[] = []; + let avatars: IconType[] = unsortedIcons; if (sortAvatars?.includes(CONST.REPORT_ACTION_AVATARS.SORT_BY.NAME)) { avatars = sortIconsByName(unsortedIcons, personalDetails); From c35bdc8085227d56638efdff76d363e7f21b1dc1 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 30 Jul 2025 11:53:03 +0200 Subject: [PATCH 29/33] Fix Pay Reports page & welcome text styles --- src/components/ReportActionAvatars/index.tsx | 2 +- src/libs/ReportUtils.ts | 9 +++++++-- src/pages/home/report/ReportActionItemSingle.tsx | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx index c59f225b2bb3c..8358e51d05b78 100644 --- a/src/components/ReportActionAvatars/index.tsx +++ b/src/components/ReportActionAvatars/index.tsx @@ -172,7 +172,7 @@ function ReportActionAvatars({ ) => getIcons(report, personalDetails, avatar ?? fallbackIcon ?? FallbackAvatar, defaultDisplayName, accountID, policy, invoiceReceiverPolicy); @@ -3407,7 +3408,9 @@ function getReportActionAvatars({ let primaryAvatar; - if (isWorkspaceActor || usePersonalDetailsAvatars) { + if (useNearestReportAvatars) { + primaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(0); + } else if (isWorkspaceActor || usePersonalDetailsAvatars) { primaryAvatar = reportIcons.at(0); } else if (delegatePersonalDetails) { primaryAvatar = getIconsWithDefaults(iouReport).at(0); @@ -3426,7 +3429,9 @@ function getReportActionAvatars({ let secondaryAvatar; - if (usePersonalDetailsAvatars) { + if (useNearestReportAvatars) { + secondaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(1); + } else if (usePersonalDetailsAvatars) { secondaryAvatar = reportIcons.at(1); } else if (isATripPreview) { secondaryAvatar = reportIcons.at(0); diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 3f37984437218..efc40f55f2f32 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -124,7 +124,6 @@ function ReportActionItemSingle({ }); const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); From dad65f717e617b874c8351503eeaa548f99a8184 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 30 Jul 2025 12:49:38 +0200 Subject: [PATCH 30/33] Change getReportActionAvatars to useReportActionAvatars --- .../ReportActionAvatar.tsx | 9 +- src/components/ReportActionAvatars/index.tsx | 43 +--- .../useReportActionAvatars.ts | 197 ++++++++++++++++++ .../useReportPreviewSenderID.ts | 4 +- src/libs/ReportUtils.ts | 159 +------------- .../home/report/ReportActionItemSingle.tsx | 82 ++------ tests/unit/useReportPreviewSenderIDTest.ts | 2 +- 7 files changed, 237 insertions(+), 259 deletions(-) create mode 100644 src/components/ReportActionAvatars/useReportActionAvatars.ts rename src/{hooks => components/ReportActionAvatars}/useReportPreviewSenderID.ts (96%) diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx index 2db040df09367..bce9683a547e6 100644 --- a/src/components/ReportActionAvatars/ReportActionAvatar.tsx +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -2,7 +2,6 @@ import lodashSortBy from 'lodash/sortBy'; import React, {useMemo} from 'react'; import type {ColorValue, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; @@ -10,6 +9,7 @@ import {WorkspaceBuilding} from '@components/Icon/WorkspaceDefaultAvatars'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; @@ -20,6 +20,7 @@ import {getUserDetailTooltipText} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {CompanyCardFeed, OnyxInputOrEntry, PersonalDetailsList} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; @@ -265,18 +266,20 @@ function ReportActionAvatarMultipleHorizontal({ icons: unsortedIcons, isInReportAction, sort: sortAvatars, - personalDetails, }: HorizontalStacking & { size: ValueOf; shouldShowTooltip: boolean; icons: IconType[]; isInReportAction: boolean; - personalDetails: OnyxEntry; }) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); + const oneAvatarSize = StyleUtils.getAvatarStyle(size); const overlapSize = oneAvatarSize.width / overlapDivider; const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx index 8358e51d05b78..cd5eca3d846b3 100644 --- a/src/components/ReportActionAvatars/index.tsx +++ b/src/components/ReportActionAvatars/index.tsx @@ -3,15 +3,12 @@ import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; -import {getReportAction} from '@libs/ReportActionsUtils'; -import {getReportActionAvatars, isChatReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CompanyCardFeed, ReportAction} from '@src/types/onyx'; import type {HorizontalStacking} from './ReportActionAvatar'; import ReportActionAvatar from './ReportActionAvatar'; +import useReportActionAvatars from './useReportActionAvatars'; type ReportActionAvatarsProps = { horizontalStacking?: HorizontalStacking | boolean; @@ -55,7 +52,7 @@ type ReportActionAvatarsProps = { function ReportActionAvatars({ reportID: potentialReportID, - action: passedAction, + action, accountIDs: passedAccountIDs = [], size = CONST.AVATAR_SIZE.DEFAULT, shouldShowTooltip = true, @@ -72,44 +69,23 @@ function ReportActionAvatars({ const reportID = potentialReportID ?? - ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === passedAction?.actionName) ? passedAction?.childReportID : undefined); + ([CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW].find((act) => act === action?.actionName) ? action?.childReportID : undefined); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); - const [potentialChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); - - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - canBeMissing: true, - }); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - - const iouReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? undefined : report; - const chatReport = isChatReport(report) && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM ? report : potentialChatReport; - - const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); - - const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, chatReport}); - const shouldStackHorizontally = !!horizontalStacking; const isHorizontalStackingAnObject = shouldStackHorizontally && typeof horizontalStacking !== 'boolean'; const {isHovered = false} = isHorizontalStackingAnObject ? horizontalStacking : {}; - // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const isReportArchived = useReportIsArchived(reportID); - const { avatarType: notPreciseAvatarType, - icons, - delegateAccountID, - } = getReportActionAvatars({ - chatReport, - iouReport, + avatars: icons, + details: {delegateAccountID}, + source, + } = useReportActionAvatars({ + report, action, - personalDetails, - reportPreviewSenderID, - policies, shouldStackHorizontally, - isReportArchived, shouldUseCardFeed: !!subscriptCardFeed, accountIDs, }); @@ -149,7 +125,6 @@ function ReportActionAvatars({ icons={icons} isInReportAction={isInReportAction} shouldShowTooltip={shouldShowTooltip} - personalDetails={personalDetails} /> ); } @@ -175,7 +150,7 @@ function ReportActionAvatars({ containerStyles={shouldStackHorizontally ? [] : singleAvatarContainerStyle} shouldShowTooltip={shouldShowTooltip} accountID={Number(delegateAccountID ?? primaryAvatar.id ?? CONST.DEFAULT_NUMBER_ID)} - delegateAccountID={action?.delegateAccountID} + delegateAccountID={source.action?.delegateAccountID} fallbackIcon={primaryAvatar.fallbackIcon} /> ); diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts new file mode 100644 index 0000000000000..0a11e857e2dfc --- /dev/null +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -0,0 +1,197 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {getReportAction} from '@libs/ReportActionsUtils'; +import { + getDisplayNameForParticipant, + getIcons, + getReportActionActorAccountID, + isChatThread, + isInvoiceReport, + isPolicyExpenseChat, + isTripRoom, + shouldReportShowSubscript, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxInputOrEntry, Report, ReportAction} from '@src/types/onyx'; +import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; +import useReportPreviewSenderID from './useReportPreviewSenderID'; + +function useReportActionAvatars({ + report, + action: passedAction, + shouldStackHorizontally = false, + shouldUseCardFeed = false, + accountIDs = [], +}: { + report: OnyxEntry; + action: OnyxEntry; + shouldStackHorizontally?: boolean; + shouldUseCardFeed?: boolean; + accountIDs?: number[]; +}) { + /* Get avatar type */ + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + + const isReportAChatReport = report?.type === CONST.REPORT.TYPE.CHAT && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM; + + const [reportChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + + const chatReport = isReportAChatReport ? report : reportChatReport; + const iouReport = isReportAChatReport ? undefined : report; + + const action = passedAction ?? (iouReport?.parentReportActionID ? getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID) : undefined); + + const isReportArchived = useReportIsArchived(iouReport?.reportID); + + const reportPreviewSenderID = useReportPreviewSenderID({ + iouReport, + action, + chatReport, + }); + + const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + const isATripRoom = isTripRoom(chatReport); + const isWorkspaceWithoutChatReportProp = !chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL; + const isAWorkspaceChat = isPolicyExpenseChat(chatReport) || isWorkspaceWithoutChatReportProp; + const isATripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; + const isAReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + const isReportPreviewOrNoAction = !action || isAReportPreviewAction; + const isReportPreviewInTripRoom = isAReportPreviewAction && isATripRoom; + + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const displayAllActors = isAReportPreviewAction && !isATripRoom && !isAWorkspaceChat && !reportPreviewSenderID; + + const shouldUseAccountIDs = accountIDs.length > 0; + const shouldShowAllActors = displayAllActors && !reportPreviewSenderID; + const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; + const shouldShowSubscriptAvatar = shouldReportShowSubscript(iouReport ?? chatReport, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; + const shouldShowConvertedSubscriptAvatar = (shouldStackHorizontally || shouldUseAccountIDs) && shouldShowSubscriptAvatar && !reportPreviewSenderID; + + const shouldUseSubscriptAvatar = + (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isATripPreview) && + !shouldStackHorizontally && + !isChatThreadOutsideTripRoom && + !shouldShowConvertedSubscriptAvatar) || + shouldUseCardFeed; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldUseMultipleAvatars = shouldUseAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar; + + let avatarType: ValueOf = CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE; + + if (shouldUseSubscriptAvatar) { + avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT; + } else if (shouldUseMultipleAvatars) { + avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE; + } + + /* Get correct primary & secondary icon */ + + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); + const invoiceReceiverPolicy = + chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.invoiceReceiver.policyID}`] : undefined; + const {avatar, fallbackIcon, login} = personalDetails?.[accountID] ?? {}; + + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; + const isAInvoiceReport = isInvoiceReport(iouReport ?? null); + const isWorkspaceActor = isAInvoiceReport || (isAWorkspaceChat && (!actorAccountID || displayAllActors)); + const isChatReportOnlyProp = !iouReport && chatReport; + const isWorkspaceChatWithoutChatReport = !chatReport && isAWorkspaceChat; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const usePersonalDetailsAvatars = (isChatReportOnlyProp || isWorkspaceChatWithoutChatReport) && isReportPreviewOrNoAction && !isATripPreview; + const useNearestReportAvatars = (!accountID || !action) && accountIDs.length === 0; + + const getIconsWithDefaults = (onyxReport: OnyxInputOrEntry) => + getIcons(onyxReport, personalDetails, avatar ?? fallbackIcon ?? FallbackAvatar, defaultDisplayName, accountID, policy, invoiceReceiverPolicy); + + const reportIcons = getIconsWithDefaults(chatReport ?? iouReport); + + let primaryAvatar; + + if (useNearestReportAvatars) { + primaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(0); + } else if (isWorkspaceActor || usePersonalDetailsAvatars) { + primaryAvatar = reportIcons.at(0); + } else if (delegatePersonalDetails) { + primaryAvatar = getIconsWithDefaults(iouReport).at(0); + } else if (isAReportPreviewAction && isATripRoom) { + primaryAvatar = reportIcons.at(0); + } + + primaryAvatar ??= { + source: avatar ?? FallbackAvatar, + id: accountID, + name: defaultDisplayName, + type: CONST.ICON_TYPE_AVATAR, + fill: undefined, + fallbackIcon, + }; + + let secondaryAvatar; + + if (useNearestReportAvatars) { + secondaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(1); + } else if (usePersonalDetailsAvatars) { + secondaryAvatar = reportIcons.at(1); + } else if (isATripPreview) { + secondaryAvatar = reportIcons.at(0); + } else if (isReportPreviewInTripRoom || displayAllActors) { + const iouReportIcons = getIconsWithDefaults(iouReport); + secondaryAvatar = iouReportIcons.at(iouReportIcons.at(1)?.id === primaryAvatar.id ? 0 : 1); + } else if (!isWorkspaceActor) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + secondaryAvatar = reportIcons.at(chatReport?.isOwnPolicyExpenseChat || isAWorkspaceChat ? 0 : 1); + } else if (isAInvoiceReport) { + secondaryAvatar = reportIcons.at(1); + } + + secondaryAvatar ??= { + name: '', + source: '', + type: CONST.ICON_TYPE_AVATAR, + id: 0, + fill: undefined, + fallbackIcon, + }; + + const avatarsForAccountIDs: IconType[] = accountIDs.map((id) => ({ + id, + type: CONST.ICON_TYPE_AVATAR, + source: personalDetails?.[id]?.avatar ?? FallbackAvatar, + name: personalDetails?.[id]?.login ?? '', + })); + + return { + avatars: avatarsForAccountIDs.length > 0 && avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar], + avatarType, + details: { + ...(personalDetails?.[accountID] ?? {}), + shouldDisplayAllActors: displayAllActors, + isWorkspaceActor, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + actorHint: String(isWorkspaceActor ? primaryAvatar.id : login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''), + accountID, + delegateAccountID: !isWorkspaceActor && delegatePersonalDetails ? actorAccountID : undefined, + }, + source: { + iouReport, + chatReport, + action, + }, + }; +} + +export default useReportActionAvatars; diff --git a/src/hooks/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts similarity index 96% rename from src/hooks/useReportPreviewSenderID.ts rename to src/components/ReportActionAvatars/useReportPreviewSenderID.ts index a707a1f58d534..72344b68b47c0 100644 --- a/src/hooks/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -1,5 +1,7 @@ import {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -7,8 +9,6 @@ import {isDM} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, Transaction} from '@src/types/onyx'; -import useOnyx from './useOnyx'; -import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; function getSplitAuthor(transaction: Transaction, splits?: Array>) { const {originalTransactionID, source} = transaction.comment ?? {}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d3c1ad6c19f52..75f324870292c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -57,7 +57,7 @@ import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import type {OriginalMessageExportedToIntegration} from '@src/types/onyx/OldDotAction'; import type Onboarding from '@src/types/onyx/Onboarding'; -import type {ErrorFields, Errors, Icon, Icon as IconType, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ErrorFields, Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type {AllConnectionName, ConnectionName} from '@src/types/onyx/Policy'; @@ -3317,162 +3317,6 @@ function getIcons( return getIconsForParticipants(participantAccountIDs, personalDetails); } -function getReportActionAvatars({ - iouReport, - action, - chatReport, - personalDetails, - policies, - reportPreviewSenderID, - - shouldStackHorizontally = false, - shouldUseCardFeed = false, - isReportArchived = false, - accountIDs = [], -}: { - iouReport: OnyxEntry; - action: OnyxEntry; - chatReport: OnyxEntry; - personalDetails: OnyxEntry; - policies: OnyxCollection; - reportPreviewSenderID: number | undefined; - - shouldStackHorizontally?: boolean; - shouldUseCardFeed?: boolean; - isReportArchived?: boolean; - accountIDs?: number[]; -}) { - /* Get avatar type */ - - const policyID = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID ? (iouReport?.policyID ?? chatReport?.policyID) : chatReport?.policyID; - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - - const isATripRoom = isTripRoom(chatReport); - const isWorkspaceWithoutChatReportProp = !chatReport && policy?.type !== CONST.POLICY.TYPE.PERSONAL; - const isAWorkspaceChat = isPolicyExpenseChat(chatReport) || isWorkspaceWithoutChatReportProp; - const isATripPreview = action?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIP_PREVIEW; - const isAReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const isReportPreviewOrNoAction = !action || isAReportPreviewAction; - const isReportPreviewInTripRoom = isAReportPreviewAction && isATripRoom; - - // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const displayAllActors = isAReportPreviewAction && !isATripRoom && !isAWorkspaceChat && !reportPreviewSenderID; - - const shouldUseAccountIDs = accountIDs.length > 0; - const shouldShowAllActors = displayAllActors && !reportPreviewSenderID; - const isChatThreadOutsideTripRoom = isChatThread(chatReport) && !isATripRoom; - const shouldShowSubscriptAvatar = shouldReportShowSubscript(iouReport ?? chatReport, isReportArchived) && policy?.type !== CONST.POLICY.TYPE.PERSONAL; - const shouldShowConvertedSubscriptAvatar = (shouldStackHorizontally || shouldUseAccountIDs) && shouldShowSubscriptAvatar && !reportPreviewSenderID; - - const shouldUseSubscriptAvatar = - (((shouldShowSubscriptAvatar && isReportPreviewOrNoAction) || isReportPreviewInTripRoom || isATripPreview) && - !shouldStackHorizontally && - !isChatThreadOutsideTripRoom && - !shouldShowConvertedSubscriptAvatar) || - shouldUseCardFeed; - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldUseMultipleAvatars = shouldUseAccountIDs || shouldShowAllActors || shouldShowConvertedSubscriptAvatar; - - let avatarType: ValueOf = CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE; - - if (shouldUseSubscriptAvatar) { - avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT; - } else if (shouldUseMultipleAvatars) { - avatarType = CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE; - } - - /* Get correct primary & secondary icon */ - - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, chatReport, delegatePersonalDetails); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); - const invoiceReceiverPolicy = - chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.invoiceReceiver.policyID}`] : undefined; - const {avatar, fallbackIcon, login} = personalDetails?.[accountID] ?? {}; - - const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}) ?? ''; - const isAInvoiceReport = isInvoiceReport(iouReport ?? null); - const isWorkspaceActor = isAInvoiceReport || (isAWorkspaceChat && (!actorAccountID || displayAllActors)); - const isChatReportOnlyProp = !iouReport && chatReport; - const isWorkspaceChatWithoutChatReport = !chatReport && isAWorkspaceChat; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const usePersonalDetailsAvatars = (isChatReportOnlyProp || isWorkspaceChatWithoutChatReport) && isReportPreviewOrNoAction && !isATripPreview; - const useNearestReportAvatars = (!accountID || !action) && accountIDs.length === 0; - - const getIconsWithDefaults = (report: OnyxInputOrEntry) => - getIcons(report, personalDetails, avatar ?? fallbackIcon ?? FallbackAvatar, defaultDisplayName, accountID, policy, invoiceReceiverPolicy); - - const reportIcons = getIconsWithDefaults(chatReport ?? iouReport); - - let primaryAvatar; - - if (useNearestReportAvatars) { - primaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(0); - } else if (isWorkspaceActor || usePersonalDetailsAvatars) { - primaryAvatar = reportIcons.at(0); - } else if (delegatePersonalDetails) { - primaryAvatar = getIconsWithDefaults(iouReport).at(0); - } else if (isAReportPreviewAction && isATripRoom) { - primaryAvatar = reportIcons.at(0); - } - - primaryAvatar ??= { - source: avatar ?? FallbackAvatar, - id: accountID, - name: defaultDisplayName, - type: CONST.ICON_TYPE_AVATAR, - fill: undefined, - fallbackIcon, - }; - - let secondaryAvatar; - - if (useNearestReportAvatars) { - secondaryAvatar = getIconsWithDefaults(iouReport ?? chatReport).at(1); - } else if (usePersonalDetailsAvatars) { - secondaryAvatar = reportIcons.at(1); - } else if (isATripPreview) { - secondaryAvatar = reportIcons.at(0); - } else if (isReportPreviewInTripRoom || displayAllActors) { - const iouReportIcons = getIconsWithDefaults(iouReport); - secondaryAvatar = iouReportIcons.at(iouReportIcons.at(1)?.id === primaryAvatar.id ? 0 : 1); - } else if (!isWorkspaceActor) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - secondaryAvatar = reportIcons.at(chatReport?.isOwnPolicyExpenseChat || isAWorkspaceChat ? 0 : 1); - } else if (isAInvoiceReport) { - secondaryAvatar = reportIcons.at(1); - } - - secondaryAvatar ??= { - name: '', - source: '', - type: CONST.ICON_TYPE_AVATAR, - id: 0, - fill: undefined, - fallbackIcon, - }; - - const avatarsForAccountIDs: IconType[] = accountIDs.map((id) => ({ - id, - type: CONST.ICON_TYPE_AVATAR, - source: personalDetails?.[id]?.avatar ?? FallbackAvatar, - name: personalDetails?.[id]?.login ?? '', - })); - - return { - icons: avatarsForAccountIDs.length > 0 && avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE ? avatarsForAccountIDs : [primaryAvatar, secondaryAvatar], - delegateAccountID: !isWorkspaceActor && delegatePersonalDetails ? actorAccountID : undefined, - avatarType, - accountID, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - actorHint: String(isWorkspaceActor ? primaryAvatar.id : login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''), - isWorkspaceActor, - shouldDisplayAllActors: displayAllActors, - }; -} - function getDisplayNamesWithTooltips( personalDetailsList: PersonalDetails[] | PersonalDetailsList | OptionData[], shouldUseShortForm: boolean, @@ -11488,7 +11332,6 @@ export { getUpgradeWorkspaceMessage, getDowngradeWorkspaceMessage, getIcons, - getReportActionAvatars, getIconsForParticipants, getIndicatedMissingPaymentMethod, getLastVisibleMessage, diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index efc40f55f2f32..c6f8626e951b6 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -5,12 +5,10 @@ import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionAvatars from '@components/ReportActionAvatars'; +import useReportActionAvatars from '@components/ReportActionAvatars/useReportActionAvatars'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,9 +17,8 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; -import {getReportActionAvatars, isOptimisticPersonalDetail} from '@libs/ReportUtils'; +import {isOptimisticPersonalDetail} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Report, ReportAction} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -81,51 +78,14 @@ function ReportActionItemSingle({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const isReportAChatReport = report?.type === CONST.REPORT.TYPE.CHAT; + const {avatarType, avatars, details, source} = useReportActionAvatars({report: potentialIOUReport ?? report, action}); - const [reportChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); + const reportID = source.chatReport?.reportID; + const iouReportID = source.iouReport?.reportID; - const chatReport = isReportAChatReport ? report : reportChatReport; - const iouReport = potentialIOUReport ?? (!isReportAChatReport ? report : undefined); + const [primaryAvatar, secondaryAvatar] = avatars; - const reportID = chatReport?.reportID; - const iouReportID = iouReport?.reportID; - - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - canBeMissing: true, - }); - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - - const reportPreviewSenderID = useReportPreviewSenderID({ - iouReport, - action, - chatReport, - }); - - const isReportArchived = useReportIsArchived(iouReportID); - - const { - avatarType, - icons: [primaryAvatar, secondaryAvatar], - delegateAccountID, - accountID, - actorHint, - shouldDisplayAllActors, - isWorkspaceActor, - } = getReportActionAvatars({ - chatReport, - iouReport, - action, - personalDetails, - reportPreviewSenderID, - policies, - isReportArchived, - }); - - const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; - - const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); + const accountOwnerDetails = getPersonalDetailByEmail(details.login ?? ''); const headingText = avatarType === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : primaryAvatar.name; @@ -142,23 +102,23 @@ function ReportActionItemSingle({ : action?.person; const showActorDetails = useCallback(() => { - if (isWorkspaceActor) { + if (details.isWorkspaceActor) { showWorkspaceDetails(reportID); } else { // Show participants page IOU report preview - if (iouReportID && shouldDisplayAllActors) { + if (iouReportID && details.shouldDisplayAllActors) { Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID, Navigation.getReportRHPActiveRoute())); return; } showUserDetails(Number(primaryAvatar.id)); } - }, [isWorkspaceActor, reportID, iouReportID, shouldDisplayAllActors, primaryAvatar.id]); + }, [details.isWorkspaceActor, reportID, iouReportID, details.shouldDisplayAllActors, primaryAvatar.id]); const shouldDisableDetailPage = useMemo( () => - CONST.RESTRICTED_ACCOUNT_IDS.includes(accountID ?? CONST.DEFAULT_NUMBER_ID) || - (!isWorkspaceActor && isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : (accountID ?? CONST.DEFAULT_NUMBER_ID))), - [action, isWorkspaceActor, accountID], + CONST.RESTRICTED_ACCOUNT_IDS.includes(details.accountID ?? CONST.DEFAULT_NUMBER_ID) || + (!details.isWorkspaceActor && isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : (details.accountID ?? CONST.DEFAULT_NUMBER_ID))), + [action, details.isWorkspaceActor, details.accountID], ); const getBackgroundColor = () => { @@ -171,9 +131,9 @@ function ReportActionItemSingle({ return theme.sidebar; }; - const hasEmojiStatus = !shouldDisplayAllActors && status?.emojiCode; - const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? ''); - const statusText = status?.text ?? ''; + const hasEmojiStatus = !details.shouldDisplayAllActors && details.status?.emojiCode; + const formattedDate = DateUtils.getStatusUntilDate(details.status?.clearAfter ?? ''); + const statusText = details.status?.text ?? ''; const statusTooltipText = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; return ( @@ -184,10 +144,10 @@ function ReportActionItemSingle({ onPressOut={ControlSelection.unblock} onPress={showActorDetails} disabled={shouldDisableDetailPage} - accessibilityLabel={actorHint} + accessibilityLabel={details.actorHint} role={CONST.ROLE.BUTTON} > - + {personArray?.map((fragment, index) => ( {`${status?.emojiCode}`} + >{`${details.status?.emojiCode}`} )} diff --git a/tests/unit/useReportPreviewSenderIDTest.ts b/tests/unit/useReportPreviewSenderIDTest.ts index 2caa63d72da72..95b81efc6a7e5 100644 --- a/tests/unit/useReportPreviewSenderIDTest.ts +++ b/tests/unit/useReportPreviewSenderIDTest.ts @@ -1,7 +1,7 @@ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import useReportPreviewSenderID from '@hooks/useReportPreviewSenderID'; +import useReportPreviewSenderID from '@components/ReportActionAvatars/useReportPreviewSenderID'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import CONST from '@src/CONST'; import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; From 8a3d48792735a92217c5870a35e8cd366ffbf917 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 30 Jul 2025 15:01:43 +0200 Subject: [PATCH 31/33] Change size of the avatars in details page --- src/CONST/index.ts | 1 + .../ReportActionAvatar.tsx | 10 +++++----- src/styles/index.ts | 20 +++++++++---------- src/styles/utils/index.ts | 4 ++++ src/styles/utils/types.ts | 1 + src/styles/variables.ts | 1 + 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b8ed1565202c0..92708d68d3e18 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3131,6 +3131,7 @@ const CONST = { SMALL_SUBSCRIPT: 'small-subscript', MID_SUBSCRIPT: 'mid-subscript', LARGE_BORDERED: 'large-bordered', + MEDIUM_LARGE: 'medium-large', HEADER: 'header', MENTION_ICON: 'mention-icon', SMALL_NORMAL: 'small-normal', diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx index bce9683a547e6..75e2b0fbf7559 100644 --- a/src/components/ReportActionAvatars/ReportActionAvatar.tsx +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -418,7 +418,7 @@ function ReportActionAvatarMultipleDiagonal({ const StyleUtils = useStyleUtils(); const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]); - const useHugeBottomMargin = icons.length === 2 && size === CONST.AVATAR_SIZE.X_LARGE; + const removeRightMargin = icons.length === 2 && size === CONST.AVATAR_SIZE.X_LARGE; const avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo( @@ -432,8 +432,8 @@ function ReportActionAvatarMultipleDiagonal({ secondAvatarStyles: styles.secondAvatarMedium, }, [CONST.AVATAR_SIZE.X_LARGE]: { - singleAvatarStyle: styles.singleAvatarLarge, - secondAvatarStyles: styles.secondAvatarLarge, + singleAvatarStyle: styles.singleAvatarMediumLarge, + secondAvatarStyles: styles.secondAvatarMediumLarge, }, [CONST.AVATAR_SIZE.DEFAULT]: { singleAvatarStyle: styles.singleAvatar, @@ -453,7 +453,7 @@ function ReportActionAvatarMultipleDiagonal({ } if (size === CONST.AVATAR_SIZE.X_LARGE) { - return CONST.AVATAR_SIZE.LARGE; + return CONST.AVATAR_SIZE.MEDIUM_LARGE; } return CONST.AVATAR_SIZE.SMALLER; @@ -463,7 +463,7 @@ function ReportActionAvatarMultipleDiagonal({ const secondaryAvatarContainerStyles = secondaryAvatarContainerStyle ?? [StyleUtils.getBackgroundAndBorderStyle(isHovered ? theme.activeComponentBG : theme.componentBG)]; return ( - + borderRadius: 52, }, - singleAvatarLarge: { - height: 80, - width: 80, + singleAvatarMediumLarge: { + height: 60, + width: 60, backgroundColor: theme.icon, - borderRadius: 52, + borderRadius: 80, }, secondAvatar: { @@ -2407,18 +2407,18 @@ const styles = (theme: ThemeColors) => secondAvatarMedium: { position: 'absolute', - right: -42, - bottom: -42, + right: -36, + bottom: -36, borderWidth: 3, borderRadius: 52, borderColor: 'transparent', }, - secondAvatarLarge: { + secondAvatarMediumLarge: { position: 'absolute', - right: -50, - bottom: -50, - borderWidth: 4, + right: -42, + bottom: -42, + borderWidth: 3, borderRadius: 80, borderColor: 'transparent', }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 4e89aafe6fa3d..0668df9095ec8 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -100,6 +100,7 @@ const avatarBorderSizes: Partial> = { [CONST.AVATAR_SIZE.MEDIUM]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.LARGE]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.X_LARGE]: variables.componentBorderRadiusLarge, + [CONST.AVATAR_SIZE.MEDIUM_LARGE]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.componentBorderRadiusRounded, [CONST.AVATAR_SIZE.SMALL_NORMAL]: variables.componentBorderRadiusMedium, }; @@ -115,6 +116,7 @@ const avatarSizes: Record = { [CONST.AVATAR_SIZE.X_LARGE]: variables.avatarSizeXLarge, [CONST.AVATAR_SIZE.MEDIUM]: variables.avatarSizeMedium, [CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.avatarSizeLargeBordered, + [CONST.AVATAR_SIZE.MEDIUM_LARGE]: variables.avatarSizeMediumLarge, [CONST.AVATAR_SIZE.HEADER]: variables.avatarSizeHeader, [CONST.AVATAR_SIZE.MENTION_ICON]: variables.avatarSizeMentionIcon, [CONST.AVATAR_SIZE.SMALL_NORMAL]: variables.avatarSizeSmallNormal, @@ -129,6 +131,7 @@ const avatarFontSizes: Partial> = { [CONST.AVATAR_SIZE.SMALL]: variables.fontSizeSmall, [CONST.AVATAR_SIZE.SMALLER]: variables.fontSizeExtraSmall, [CONST.AVATAR_SIZE.LARGE]: variables.fontSizeXLarge, + [CONST.AVATAR_SIZE.MEDIUM_LARGE]: variables.fontSizeXLarge, [CONST.AVATAR_SIZE.MEDIUM]: variables.fontSizeMedium, [CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.fontSizeXLarge, }; @@ -142,6 +145,7 @@ const avatarBorderWidths: Partial> = { [CONST.AVATAR_SIZE.SMALLER]: 2, [CONST.AVATAR_SIZE.HEADER]: 2, [CONST.AVATAR_SIZE.LARGE]: 4, + [CONST.AVATAR_SIZE.MEDIUM_LARGE]: 3, [CONST.AVATAR_SIZE.X_LARGE]: 4, [CONST.AVATAR_SIZE.MEDIUM]: 3, [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4, diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts index 89caae9ebeaaf..563d409beab69 100644 --- a/src/styles/utils/types.ts +++ b/src/styles/utils/types.ts @@ -22,6 +22,7 @@ type AvatarSizeValue = ValueOf< | 'avatarSizeXLarge' | 'avatarSizeLarge' | 'avatarSizeMedium' + | 'avatarSizeMediumLarge' | 'avatarSizeLargeBordered' | 'avatarSizeHeader' | 'avatarSizeMentionIcon' diff --git a/src/styles/variables.ts b/src/styles/variables.ts index d914ff4efe335..937486e2f2b80 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -35,6 +35,7 @@ export default { avatarSizeLargeBordered: 88, avatarSizeXLarge: 100, avatarSizeLarge: 80, + avatarSizeMediumLarge: 60, avatarSizeMedium: 52, avatarSizeHeader: 40, avatarSizeNormal: 40, From 95edb14ae60d38c306b085c5cf633d078afec3d3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 30 Jul 2025 15:15:06 +0200 Subject: [PATCH 32/33] Add ReportActionAvatars & ReportActionAvatar descriptions --- src/components/ReportActionAvatars/ReportActionAvatar.tsx | 4 ++++ src/components/ReportActionAvatars/index.tsx | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx index 75e2b0fbf7559..fececbe28857e 100644 --- a/src/components/ReportActionAvatars/ReportActionAvatar.tsx +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -543,6 +543,10 @@ function ReportActionAvatarMultipleDiagonal({ ); } +/** + * This component should not be used outside ReportActionAvatars; its sole purpose is to render the ReportActionAvatars UI. + * To render user avatars, use ReportActionAvatars. + */ export default { Single: ReportActionAvatarSingle, Subscript: ReportActionAvatarSubscript, diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx index cd5eca3d846b3..1d886378fbca0 100644 --- a/src/components/ReportActionAvatars/index.tsx +++ b/src/components/ReportActionAvatars/index.tsx @@ -50,6 +50,14 @@ type ReportActionAvatarsProps = { subscriptCardFeed?: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK; }; +/** + * The component that renders proper user avatars based on either: + * + * - accountIDs - if this is passed, it is prioritized and render even if report or action has different avatars attached, useful for option items, menu items etc. + * - action - this is useful when we want to display avatars of chat threads, messages, report/trip previews etc. + * - reportID - this can be passed without above props, when we want to display chat report avatars, DM chat avatars etc. + * + */ function ReportActionAvatars({ reportID: potentialReportID, action, From fb254918d6c539b5b19ee858608e389c7c981427 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 31 Jul 2025 10:19:45 +0200 Subject: [PATCH 33/33] Correct details page subscript border --- src/styles/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index db79892659ac1..210bc81cffb22 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -96,7 +96,7 @@ const avatarBorderSizes: Partial> = { [CONST.AVATAR_SIZE.SUBSCRIPT]: variables.componentBorderRadiusMedium, [CONST.AVATAR_SIZE.SMALLER]: variables.componentBorderRadiusMedium, [CONST.AVATAR_SIZE.SMALL]: variables.componentBorderRadiusMedium, - [CONST.AVATAR_SIZE.HEADER]: variables.componentBorderRadiusMedium, + [CONST.AVATAR_SIZE.HEADER]: variables.componentBorderRadiusNormal, [CONST.AVATAR_SIZE.DEFAULT]: variables.componentBorderRadiusNormal, [CONST.AVATAR_SIZE.MEDIUM]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.LARGE]: variables.componentBorderRadiusLarge,