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/CONST/index.ts b/src/CONST/index.ts index cb9c0983faf74..85e0058c147ac 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, @@ -3124,6 +3139,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/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index f9c84039850d6..eaf8c93876102 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -2,8 +2,8 @@ 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'; import type {Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; @@ -23,8 +23,6 @@ function AnonymousReportFooter({isSmallSizeLayout = false, report}: AnonymousRep const styles = useThemeStyles(); const {translate} = useLocalize(); - const policy = usePolicy(report?.policyID); - return ( @@ -32,7 +30,6 @@ function AnonymousReportFooter({isSmallSizeLayout = false, report}: AnonymousRep report={report} isAnonymous shouldEnableDetailPageNavigation - policy={policy} /> diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index ecdf7832fc154..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(); @@ -103,7 +107,10 @@ function Avatar({ } return ( - + {typeof avatarSource === 'string' ? ( ; - /** The policy which the user has access to and which the report is tied to */ - policy?: OnyxEntry; - /** The size of the avatar */ size?: ValueOf; @@ -75,16 +64,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 = { - source: FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: '', - id: -1, }; function getCustomDisplayName( @@ -164,7 +143,6 @@ function getCustomDisplayName( } function AvatarWithDisplayName({ - policy, report, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, @@ -172,7 +150,6 @@ function AvatarWithDisplayName({ shouldEnableAvatarNavigation = true, shouldUseCustomSearchTitleName = false, transactions = [], - singleAvatarDetails, openParentReportInCurrentTab = false, avatarBorderColor: avatarBorderColorProp, }: AvatarWithDisplayNameProps) { @@ -190,13 +167,10 @@ 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 isReportArchived = useReportIsArchived(report?.reportID); - const icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy, isReportArchived); const ownerPersonalDetails = getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); - const shouldShowSubscriptAvatar = shouldReportShowSubscript(report, isReportArchived); const avatarBorderColor = avatarBorderColorProp ?? (isAnonymous ? theme.highlightBG : theme.componentBG); const actorAccountID = useRef(null); @@ -245,65 +219,34 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const getAvatar = useCallback(() => { - if (shouldShowSubscriptAvatar) { - return ( - - ); - } - - if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) { - return ( - - ); - } - - return ( - - ); - }, [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles]); - - const getWrappedAvatar = useCallback(() => { - const avatar = getAvatar(); - - if (!shouldEnableAvatarNavigation) { - return {avatar}; - } - - 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/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 dab4acb50170f..8123c494f0794 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -5,13 +5,12 @@ 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 SubscriptAvatar from '@components/SubscriptAvatar'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; @@ -260,28 +259,21 @@ function OptionRowLHN({ > - {!!optionItem.icons?.length && - firstIcon && - (optionItem.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!optionItem.icons?.length && !!firstIcon && ( + + )} ; - /** 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; - - /** 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; @@ -269,6 +255,10 @@ type MenuItemBaseProps = { /** Should we remove the hover background color of the menu item */ shouldRemoveHoverBackground?: boolean; + rightIconAccountID?: number | string; + + iconAccountID?: number; + /** Should we use default cursor for disabled content */ shouldUseDefaultCursorWhenDisabled?: boolean; @@ -381,6 +371,12 @@ 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; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -422,6 +418,8 @@ function MenuItem( fallbackIcon = Expensicons.FallbackAvatar, shouldShowTitleIcon = false, titleIcon, + rightIconAccountID, + iconAccountID, shouldShowRightIcon = false, iconRight = Expensicons.ArrowRight, furtherDetailsIcon, @@ -437,6 +435,7 @@ function MenuItem( shouldShowRedDotIndicator, hintText, success = false, + iconReportID, focused = false, disabled = false, title, @@ -455,10 +454,7 @@ function MenuItem( shouldShowDescriptionOnTop = false, shouldShowRightComponent = false, rightComponent, - floatRightAvatars = [], - floatRightAvatarSize, - shouldShowSubscriptRightAvatar = false, - shouldShowSubscriptAvatar: shouldShowSubscriptAvatarProp = false, + rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, isSmallAvatarSubscriptMenu = false, brickRoadIndicator, @@ -516,8 +512,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( [ @@ -535,9 +529,7 @@ 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 : {}, @@ -653,6 +645,8 @@ function MenuItem( onSecondaryInteraction?.(event); }; + const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; + return ( {!!label && !isLabelHoverable && ( @@ -710,32 +704,27 @@ function MenuItem( {!!label && isLabelHoverable && ( - + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + {label} )} - {shouldShowAvatar && !shouldShowSubscriptAvatar && ( - - )} - {shouldShowAvatar && shouldShowSubscriptAvatar && ( - )} {!icon && shouldPutLeftPaddingWhenNoIcon && ( @@ -910,26 +899,22 @@ function MenuItem( {subtitle} )} - {floatRightAvatars?.length > 0 && !!firstRightIcon && ( + {(!!rightIconAccountID || !!rightIconReportID) && ( - {shouldShowSubscriptRightAvatar ? ( - - ) : ( - - )} + 0 ? [Number(rightIconAccountID)] : undefined} + useMidSubscriptSizeForMultipleAvatars + /> )} {!!brickRoadIndicator && ( diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 8893f274f4b3f..bf1cdd57365ce 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -11,7 +11,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'; @@ -203,15 +202,6 @@ function MoneyReportHeader({ return getIntegrationNameFromExportMessageUtils(reportActions); }, [isExported, reportActions]); - 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); @@ -248,8 +238,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'); @@ -1053,9 +1041,7 @@ function MoneyReportHeader({ ; - - /** 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); diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 872fbf01f50a9..1ac506f78caea 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -16,11 +16,10 @@ 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 ReportActionAvatars from './ReportActionAvatars'; import SelectCircle from './SelectCircle'; -import SubscriptAvatar from './SubscriptAvatar'; import Text from './Text'; type OptionRowProps = { @@ -207,23 +206,15 @@ function OptionRow({ > - {!!option.icons?.length && - firstIcon && - (option.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!option.icons?.length && !!firstIcon && ( + + )} ; + +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, +}: HorizontalStacking & { + size: ValueOf; + shouldShowTooltip: boolean; + icons: IconType[]; + isInReportAction: boolean; +}) { + 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; + const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; + const avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); + + const icons = useMemo(() => { + let avatars: IconType[] = unsortedIcons; + + 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 removeRightMargin = 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.singleAvatarMediumLarge, + secondAvatarStyles: styles.secondAvatarMediumLarge, + }, + [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.MEDIUM_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}`} + + + + )} + + + + ); +} + +/** + * 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, + 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..1d886378fbca0 --- /dev/null +++ b/src/components/ReportActionAvatars/index.tsx @@ -0,0 +1,167 @@ +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 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; + + /** 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; +}; + +/** + * 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, + 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 === action?.actionName) ? action?.childReportID : undefined); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); + + const shouldStackHorizontally = !!horizontalStacking; + const isHorizontalStackingAnObject = shouldStackHorizontally && typeof horizontalStacking !== 'boolean'; + const {isHovered = false} = isHorizontalStackingAnObject ? horizontalStacking : {}; + + const { + avatarType: notPreciseAvatarType, + avatars: icons, + details: {delegateAccountID}, + source, + } = useReportActionAvatars({ + report, + action, + shouldStackHorizontally, + 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/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/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts new file mode 100644 index 0000000000000..72344b68b47c0 --- /dev/null +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -0,0 +1,75 @@ +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'; +import {isDM} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; + +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), + }); + + 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 + + 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 && (chatReport ? isDM(chatReport) : policy?.type === CONST.POLICY.TYPE.PERSONAL); + const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; + + return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; +} + +export default useReportPreviewSenderID; diff --git a/src/components/ReportActionItem/SingleReportAvatar.tsx b/src/components/ReportActionItem/SingleReportAvatar.tsx deleted file mode 100644 index 9a7c25d6808b3..0000000000000 --- a/src/components/ReportActionItem/SingleReportAvatar.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import type {ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import Avatar from '@components/Avatar'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; -import CONST from '@src/CONST'; -import type {PersonalDetailsList} from '@src/types/onyx'; - -function SingleReportAvatar({ - reportPreviewDetails, - personalDetails, - containerStyles, - actorAccountID, -}: { - reportPreviewDetails: ReportAvatarDetails; - personalDetails: PersonalDetailsList | undefined; - containerStyles: ViewStyle[]; - actorAccountID: number | null | undefined; -}) { - const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; - const delegatePersonalDetails = reportPreviewAction?.delegateAccountID ? personalDetails?.[reportPreviewAction?.delegateAccountID] : undefined; - - return ( - - - - - - ); -} - -export default SingleReportAvatar; diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 745f23e49a00c..1fbb6632af5e7 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(() => { @@ -248,11 +241,13 @@ function TransactionPreviewContent({ {previewHeaderText} {isBillSplit && ( - )} 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/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index 8bf0dd976446a..33dff5d23500e 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'; @@ -85,16 +84,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 userWalletTierName={userWalletTierName} diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index 07f965545c582..bf863ec29fd7a 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -1,12 +1,10 @@ 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 ReportActionAvatars from '@components/ReportActionAvatars'; import SelectCircle from '@components/SelectCircle'; -import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; @@ -18,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, @@ -111,25 +101,20 @@ function InviteMemberListItem({ wrapperStyle={styles.productTrainingTooltipWrapper} > - {!!item.icons && - (item.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!item.icons && ( + + )} = { /** The card currently being looked at */ @@ -39,27 +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 [memberAvatar, cardIcon] = useMemo(() => { - const avatar: Icon = { - source: cardItem.avatar, - type: CONST.ICON_TYPE_AVATAR, - name: formattedDisplayName, - id: cardItem.accountID, - }; - - const icon: SubIcon = { - source: getCardFeedIcon(cardItem.bank as CompanyCardFeed, illustrations), - width: variables.cardAvatarWidth, - height: variables.cardAvatarHeight, - }; - - return [avatar, icon]; - }, [formattedDisplayName, illustrations, cardItem]); - const backgroundColor = StyleUtils.getItemBackgroundColorStyle(!!cardItem.isSelected, !!isFocused, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? theme.highlightBG; @@ -78,11 +54,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, - isDisabled, - isFocused, - canSelectMultiple, -}: ReportListItemHeaderProps) { +function ReportListItemHeader({report: reportItem, onSelectRow, onCheckboxPress, isDisabled, isFocused, canSelectMultiple}: ReportListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -199,7 +181,6 @@ function ReportListItemHeader({ ({ ({ shouldSyncFocus, columns, 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(); @@ -96,7 +93,6 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.REPORTS]: ( ({ } return headers[groupBy]; - }, [groupItem, policy, onSelectRow, onCheckboxPress, isDisabledOrEmpty, isFocused, canSelectMultiple, groupBy]); + }, [groupItem, onSelectRow, onCheckboxPress, isDisabledOrEmpty, isFocused, canSelectMultiple, groupBy]); const StyleUtils = useStyleUtils(); const pressableRef = useRef(null); diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 709b95a50451f..12a9f3d774591 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 ReportActionAvatars from '@components/ReportActionAvatars'; import TextWithTooltip from '@components/TextWithTooltip'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -107,11 +107,11 @@ function TableListItem({ )} - {!!item.icons && ( - ({ item, isFocused, @@ -109,25 +99,20 @@ function UserListItem({ )} - {!!item.icons && - (item.shouldShowSubscript ? ( - - ) : ( - - ))} + {!!item.icons && ( + + )} = ListItemProps & { type TransactionGroupListItemProps = ListItemProps & { groupBy?: SearchGroupBy; - policies?: OnyxCollection; columns?: SortableColumnName[]; }; diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx deleted file mode 100644 index 992ea068129a2..0000000000000 --- a/src/components/SubscriptAvatar.tsx +++ /dev/null @@ -1,141 +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; -}; - -function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: 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 eedf582ff4471..0000000000000 --- a/src/hooks/useReportAvatarDetails.ts +++ /dev/null @@ -1,292 +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 useReportIsArchived from './useReportIsArchived'; -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, - isReportArchived = false, -}: AvatarDetailsProps & {reportPreviewSenderID: number | undefined; isReportArchived?: boolean}) { - 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, undefined, isReportArchived); - - 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 isReportArchived = useReportIsArchived(report?.reportID); - - 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, - isReportArchived, - }), - }; - } - - // 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, - isReportArchived, - }), - }; -} - -export default useReportAvatarDetails; -export type {ReportAvatarDetails}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 812c12e0b3fdd..8d40b62ecda54 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -463,30 +463,6 @@ Onyx.connect({ callback: (value) => (nvpDismissedProductTraining = value), }); -/** - * @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. @@ -2742,7 +2718,6 @@ export { formatSectionsFromSearchTerm, getAlternateText, getAttendeeOptions, - getAvatarsForAccountIDs, getCurrentUserSearchTerms, getEmptyOptions, getFirstKeyForList, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3ebb1197ba4cf..8264730b38c74 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5430,7 +5430,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; @@ -5442,7 +5442,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)) { diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index c1b058146e0ad..985076583df37 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -13,13 +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 RoomHeaderAvatars from '@components/RoomHeaderAvatars'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -571,50 +570,55 @@ 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]); + }, [ + 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/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index 0c32812810c0d..111153d44e394 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} /> ); + const multipleAvatars = ( + + ); + return ( <> - {shouldShowSubscript ? ( - - ) : ( - - - - )} + {shouldShowSubscript ? multipleAvatars : {multipleAvatars}} @@ -1361,7 +1356,6 @@ function PureReportActionItem({ ...(isOnSearch && styles.p0), ...(isWhisper && styles.pt1), }} - shouldShowSubscriptAvatar={shouldShowSubscriptAvatar} report={report} iouReport={iouReport} isHovered={hovered || isContextMenuActive} @@ -1369,7 +1363,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/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 2a03648e935db..01e3d19d712bb 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -1,16 +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 ReportActionAvatars from '@components/ReportActionAvatars'; import ReportWelcomeText from '@components/ReportWelcomeText'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; 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'; @@ -29,25 +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, - }); - const isReportArchived = useReportIsArchived(report?.reportID); if (!isChatReport(report)) { return null; } - let icons = getIcons(report, personalDetails, null, '', -1, policy, invoiceReceiverPolicy, isReportArchived); 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 ce5ffcd97bf1d..848cc94a122b3 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -1,18 +1,14 @@ 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 SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar'; -import SubscriptAvatar from '@components/SubscriptAvatar'; +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 usePolicy from '@hooks/usePolicy'; -import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,11 +17,10 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getManagerOnVacation, getReportActionMessage, getSubmittedTo, getVacationer} from '@libs/ReportActionsUtils'; -import {getReportActionActorAccountID, 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 {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'; @@ -46,9 +41,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; @@ -57,9 +49,6 @@ type ReportActionItemSingleProps = Partial & { /** If the action is active */ isActive?: boolean; - - /** Policies */ - policies?: OnyxCollection; }; const showUserDetails = (accountID: number | undefined) => { @@ -78,46 +67,25 @@ function ReportActionItemSingle({ children, wrapperStyle, showHeader = true, - shouldShowSubscriptAvatar = false, hasBeenFlagged = false, report, - iouReport, + iouReport: potentialIOUReport, isHovered = false, isActive = false, - policies, }: ReportActionItemSingleProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - canBeMissing: true, - }); - - const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - canBeMissing: true, - }); - const policy = usePolicy(report?.policyID); + const {avatarType, avatars, details, source} = useReportActionAvatars({report: potentialIOUReport ?? report, action}); - const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); + const reportID = source.chatReport?.reportID; + const iouReportID = source.iouReport?.reportID; - const reportPreviewDetails = useReportAvatarDetails({ - action, - report, - iouReport, - policies, - personalDetails, - innerPolicies, - policy, - }); + const [primaryAvatar, secondaryAvatar] = avatars; - const {primaryAvatar, secondaryAvatar, displayName, shouldDisplayAllActors, isWorkspaceActor, reportPreviewSenderID, actorHint} = reportPreviewDetails; - const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; - - const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; - const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); + const accountOwnerDetails = getPersonalDetailByEmail(details.login ?? ''); // Vacation delegate details for submitted action const vacationer = getVacationer(action); @@ -129,8 +97,7 @@ function ReportActionItemSingle({ const managerOnVacation = getManagerOnVacation(action); const vacationDelegateDetailsForApprove = getPersonalDetailByEmail(managerOnVacation ?? ''); - const showMultipleUserAvatarPattern = shouldDisplayAllActors && !shouldShowSubscriptAvatar; - const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : displayName; + 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, @@ -144,27 +111,24 @@ function ReportActionItemSingle({ ] : action?.person; - const reportID = report?.reportID; - const iouReportID = iouReport?.reportID; - 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(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(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 = () => { @@ -177,41 +141,9 @@ function ReportActionItemSingle({ return theme.sidebar; }; - const getAvatar = () => { - if (shouldShowSubscriptAvatar) { - return ( - - ); - } - if (shouldDisplayAllActors) { - return ( - - ); - } - - return ( - - ); - }; - - 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 ( @@ -222,10 +154,24 @@ function ReportActionItemSingle({ onPressOut={ControlSelection.unblock} onPress={showActorDetails} disabled={shouldDisableDetailPage} - accessibilityLabel={actorHint} + accessibilityLabel={details.actorHint} role={CONST.ROLE.BUTTON} > - {getAvatar()} + + + {showHeader ? ( @@ -236,20 +182,20 @@ function ReportActionItemSingle({ onPressOut={ControlSelection.unblock} onPress={showActorDetails} disabled={shouldDisableDetailPage} - accessibilityLabel={actorHint} + accessibilityLabel={details.actorHint} role={CONST.ROLE.BUTTON} > {personArray?.map((fragment, index) => ( ))} @@ -258,7 +204,7 @@ function ReportActionItemSingle({ {`${status?.emojiCode}`} + >{`${details.status?.emojiCode}`} )} diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index 46e19c646e086..4b8ddf794a0b5 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 ReportActionAvatars from '@components/ReportActionAvatars'; 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,12 +61,14 @@ function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, report onSecondaryInteraction={onSecondaryInteraction} > - diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index e782a185c7c6d..d05f1e98dea3a 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 {PersonalDetailsList, Policy, Report, ReportAction, ReportActionReactions, ReportActionsDrafts, Transaction} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -238,16 +238,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/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index 6e85f0f3315b3..8443de826fe75 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -317,8 +317,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT label: translate('quickAction.header'), labelStyle: [styles.pt3, styles.pb2], isLabelHoverable: false, - floatRightAvatars: quickActionAvatars, - floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL, numberOfLinesDescription: 1, tooltipAnchorAlignment: { vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, @@ -345,10 +343,11 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT ...baseQuickAction, icon: getQuickActionIcon(quickAction?.action), text: quickActionTitle, + rightIconAccountID: quickActionAvatars.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID, description: quickActionSubtitle, onSelected, shouldCallAfterModalHide: shouldUseNarrowLayout, - shouldShowSubscriptRightAvatar: isPolicyExpenseChat(quickActionReport), + rightIconReportID: quickActionReport?.reportID, }, ]; } @@ -373,7 +372,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT description: getReportName(policyChatForActivePolicy), shouldCallAfterModalHide: shouldUseNarrowLayout, onSelected, - shouldShowSubscriptRightAvatar: true, + rightIconReportID: policyChatForActivePolicy?.reportID, }, ]; } diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index f4be2a5c33ab3..8c67675dbd931 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -346,9 +346,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr badgeStyle={item.badgeStyle} fallbackIcon={item.fallbackIcon} brickRoadIndicator={item.brickRoadIndicator} - floatRightAvatars={item.floatRightAvatars} shouldStackHorizontally={item.shouldStackHorizontally} - floatRightAvatarSize={item.avatarSize} ref={popoverAnchor} shouldBlockSelection={!!item.link} onSecondaryInteraction={item.link ? (event) => openPopover(item.link, event) : undefined} diff --git a/src/pages/tasks/NewTaskPage.tsx b/src/pages/tasks/NewTaskPage.tsx index 3e2808292ea1a..86cd7ce2d41f1 100644 --- a/src/pages/tasks/NewTaskPage.tsx +++ b/src/pages/tasks/NewTaskPage.tsx @@ -151,7 +151,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} @@ -160,7 +160,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 45a2c9bd7ae58..a73787d36455b 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -36,7 +36,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; -import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -61,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'; @@ -113,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); @@ -129,7 +127,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); const currentUserPolicyExpenseChatReportID = getPolicyExpenseChat(accountID, policy?.id, allReports)?.reportID; const [currentUserPolicyExpenseChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentUserPolicyExpenseChatReportID}`, {canBeMissing: true}); - const isUserPolicyExpenseChatArchived = useReportIsArchived(currentUserPolicyExpenseChatReportID); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(currentUserPolicyExpenseChat); const isPolicyExpenseChatEnabled = !!policy?.isPolicyExpenseChatEnabled; const prevPendingFields = usePrevious(policy?.pendingFields); @@ -497,11 +494,10 @@ 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]} - shouldShowSubscriptAvatar + iconReportID={currentUserPolicyExpenseChatReportID} /> diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index a1b8b40e90f15..83a144684149a 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 ReportActionAvatars from '@components/ReportActionAvatars'; 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( @@ -194,12 +192,13 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: } > - 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/src/styles/index.ts b/src/styles/index.ts index bf1a4d4ee0f26..a45bf8781bb85 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2385,6 +2385,13 @@ const styles = (theme: ThemeColors) => borderRadius: 52, }, + singleAvatarMediumLarge: { + height: 60, + width: 60, + backgroundColor: theme.icon, + borderRadius: 80, + }, + secondAvatar: { position: 'absolute', right: -18, @@ -2412,12 +2419,27 @@ const styles = (theme: ThemeColors) => borderColor: 'transparent', }, + secondAvatarMediumLarge: { + position: 'absolute', + right: -42, + bottom: -42, + borderWidth: 3, + borderRadius: 80, + borderColor: 'transparent', + }, + secondAvatarSubscript: { position: 'absolute', right: -6, bottom: -6, }, + secondAvatarSubscriptXLarge: { + position: 'absolute', + right: -10, + bottom: -10, + }, + secondAvatarSubscriptCompact: { position: 'absolute', bottom: -4, @@ -2486,6 +2508,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 3d61389816755..5bcb698485755 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -96,11 +96,12 @@ 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, [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, }; @@ -116,6 +117,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, @@ -130,6 +132,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, }; @@ -141,7 +144,9 @@ 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.MEDIUM_LARGE]: 3, [CONST.AVATAR_SIZE.X_LARGE]: 4, [CONST.AVATAR_SIZE.MEDIUM]: 3, [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4, @@ -1774,6 +1779,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]; } 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 cbec992f81045..cc8af3d3b6977 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, 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/ReportActionAvatarsTest.tsx b/tests/ui/ReportActionAvatarsTest.tsx new file mode 100644 index 0000000000000..0324e7a1d005f --- /dev/null +++ b/tests/ui/ReportActionAvatarsTest.tsx @@ -0,0 +1,446 @@ +import {render, screen} from '@testing-library/react-native'; +import {View as MockedAvatarData} from 'react-native'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ReportActionAvatars from '@components/ReportActionAvatars'; +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 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} from '../../__mocks__/reportData/transactions'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type AvatarProps = { + source?: AvatarSource; + name?: string; + avatarID?: number | string; + testID?: string; +}; + +type AvatarData = { + uri: string; + avatarID?: number; + name?: string; + parent: string; +}; + +/* --- 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'; +}; + +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 ( + + ); + }; +}); + +/* --- 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 = { + [`${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 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( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + , + ); +} + +async function retrieveDataFromAvatarView(props: Parameters[0]) { + renderAvatar(props); + + await waitForBatchedUpdatesWithAct(); + + const images = screen.queryAllByTestId('MockedAvatarData'); + const icons = screen.queryAllByTestId('MockedIconData'); + const reportAvatarFragments = screen.queryAllByTestId('ReportActionAvatars-', { + 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('ReportActionAvatars-Subscript')) && fragments.length !== 0; + const isUserAvatarCorrect = images.some( + (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 === `ReportActionAvatars-Subscript-${workspaceIconAsPrimaryAvatar ? 'MainAvatar' : 'SecondaryAvatar'}`, + ); + + expect(isEveryAvatarFragmentASubscript).toBe(!negate); + expect(isWorkspaceAvatarCorrect).toBe(!negate); + expect(isUserAvatarCorrect).toBe(!negate); +} + +function isMultipleAvatarRendered({ + images, + fragments, + workspaceIconAsPrimaryAvatar, + negate = false, + secondUserAvatar, + stacked, +}: { + images: AvatarData[]; + fragments: string[]; + workspaceIconAsPrimaryAvatar?: boolean; + negate?: boolean; + secondUserAvatar?: string; + stacked?: boolean; +}) { + const isEveryAvatarFragmentAMultiple = fragments.every((fragment) => fragment.startsWith('ReportActionAvatars-MultipleAvatars')) && fragments.length !== 0; + + const isUserAvatarCorrect = images.some( + (image) => + image.uri === USER_AVATAR && + image.parent === + (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 + ? 'ReportActionAvatars-MultipleAvatars-StackedHorizontally-Avatar' + : `ReportActionAvatars-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) && image.parent === 'ReportActionAvatars-SingleAvatar'); + + expect(isUserAvatarCorrect).toBe(!negate); +} + +describe('ReportActionAvatars', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: onyxState, + }); + initOnyxDerivedValues(); + return waitForBatchedUpdates(); + }); + + 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); + }); + + 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}); + }); + + 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, stacked: true}); + }); + }); + + 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], + }); + 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/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index 7040569ab3d72..2bc48714db83f 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -99,7 +99,6 @@ const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemTy { }); render( - {}} - parentReportAction={null} - reportID={report.reportID} - />, + + {}} + parentReportAction={null} + reportID={report.reportID} + /> + , ); await waitForBatchedUpdates(); diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index 9b32ec46634c1..02e5ba0f5aaaa 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; @@ -52,20 +51,28 @@ 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, }), ) .then(() => { - LHNTestUtils.getDefaultRenderedReportActionItemSingle(shouldShowSubscriptAvatar, fakeReport, fakeReportAction); + LHNTestUtils.getDefaultRenderedReportActionItemSingle(fakeReport, fakeReportAction); }); } - it('renders secondary Avatar properly', async () => { - const expectedSecondaryIconTestId = 'SvgDefaultAvatar_w Icon'; + it('renders avatar properly', async () => { + const expectedIconTestID = 'ReportActionAvatars-SingleAvatar'; await setup(); await waitFor(() => { - expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeOnTheScreen(); + expect(screen.getByTestId(expectedIconTestID)).toBeOnTheScreen(); }); }); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 2e42cdbd1a802..81758e4d0b53d 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1264,7 +1264,6 @@ describe('SidebarUtils', () => { iouReportR14932.reportID = '5'; chatReportR14932.reportID = '6'; - iouReportR14932.lastActorAccountID = undefined; const report: Report = { ...createRandomReport(1), @@ -1311,7 +1310,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/useReportAvatarDetailsTest.ts b/tests/unit/useReportPreviewSenderIDTest.ts similarity index 67% rename from tests/unit/useReportAvatarDetailsTest.ts rename to tests/unit/useReportPreviewSenderIDTest.ts index a202a3e6d847a..95b81efc6a7e5 100644 --- a/tests/unit/useReportAvatarDetailsTest.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 useReportAvatarDetails from '@hooks/useReportAvatarDetails'; +import useReportPreviewSenderID from '@components/ReportActionAvatars/useReportPreviewSenderID'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import CONST from '@src/CONST'; import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; @@ -9,7 +9,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; import personalDetails from '../../__mocks__/reportData/personalDetails'; -import {policy420A} from '../../__mocks__/reportData/policies'; import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; import {transactionR14932} from '../../__mocks__/reportData/transactions'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -30,25 +29,9 @@ const validAction = { childManagerAccountID: iouReportR14932.managerID, }; -describe('useReportAvatarDetails', () => { - 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); }); }); 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 ( ,