diff --git a/src/components/Avatar/connected/AccountAvatar.tsx b/src/components/Avatar/connected/AccountAvatar.tsx new file mode 100644 index 000000000000..3ffb1593905e --- /dev/null +++ b/src/components/Avatar/connected/AccountAvatar.tsx @@ -0,0 +1,55 @@ +import SingleAvatar from '@components/Avatar/layouts/SingleAvatar'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; + +import useDefaultAvatars from '@hooks/useDefaultAvatars'; +import useStyleUtils from '@hooks/useStyleUtils'; + +import {buildUserIcon} from '@libs/UserAvatarUtils'; + +import CONST from '@src/CONST'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React from 'react'; + +type AccountAvatarProps = { + /** Account ID of the user to display the avatar for */ + accountID: number; + + /** Size of the avatar */ + size?: ValueOf; + + /** Whether to show the tooltip on hover */ + shouldShowTooltip?: boolean; + + /** Display name used as a fallback for the avatar tooltip */ + fallbackDisplayName?: string; + + /** Container styles for the avatar. Replaces the size-derived default container styles when provided */ + containerStyle?: StyleProp; +}; + +/** + * Renders a single account's avatar, resolving the icon from the personal-details context (zero Onyx subscriptions). + * Use whenever exactly one account is rendered. Pass `Avatar/UserAvatar` a `source` instead when the avatar is already resolved. + */ +function AccountAvatar({accountID, size = CONST.AVATAR_SIZE.DEFAULT, shouldShowTooltip = true, fallbackDisplayName, containerStyle}: AccountAvatarProps) { + const personalDetails = usePersonalDetails(); + const defaultAvatars = useDefaultAvatars(); + const StyleUtils = useStyleUtils(); + + const icon = buildUserIcon({accountID, personalDetails, defaultAvatars}); + + return ( + + ); +} + +export default AccountAvatar; diff --git a/src/components/Avatar/layouts/SingleAvatar.tsx b/src/components/Avatar/layouts/SingleAvatar.tsx index 57ee6fe1af1b..797e0b5cf365 100644 --- a/src/components/Avatar/layouts/SingleAvatar.tsx +++ b/src/components/Avatar/layouts/SingleAvatar.tsx @@ -45,7 +45,7 @@ function SingleAvatar({avatar, size, containerStyles, shouldShowTooltip, fallbac fallbackIcon={avatar.fallbackIcon} fill={avatar.fill} size={size} - testID="ReportActionAvatars-SingleAvatar" + testID="SingleAvatar" /> diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx index 2a25998d2d5c..0cb4adc56170 100644 --- a/src/components/ReportActionAvatars/index.tsx +++ b/src/components/ReportActionAvatars/index.tsx @@ -86,12 +86,9 @@ type ReportActionAvatarsProps = { /** Display name used as a fallback for avatar tooltip */ fallbackDisplayName?: string; - /** Invited emails to account IDs */ + /** Invited emails to account IDs. Also seeds a deterministic fallback avatar for each invited account */ invitedEmailsToAccountIDs?: InvitedEmailsToAccountIDs; - /** Whether to use custom fallback avatar */ - shouldUseCustomFallbackAvatar?: boolean; - /** chatReportID needed for the avatars logic. When provided, this will be used as a fallback if the snapshot is undefined */ chatReportID?: string; @@ -128,7 +125,6 @@ function ReportActionAvatars({ isInReportAction = false, fallbackDisplayName, invitedEmailsToAccountIDs, - shouldUseCustomFallbackAvatar = false, chatReportID, shouldUseRealActor = false, }: ReportActionAvatarsProps) { @@ -168,7 +164,6 @@ function ReportActionAvatars({ policy: policyProp, fallbackDisplayName, invitedEmailsToAccountIDs, - shouldUseCustomFallbackAvatar, chatReportID, shouldUseRealActor, }); diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index d605dd4ac511..9f9c7516facf 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -7,7 +7,6 @@ import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import { getDelegateAccountIDFromReportAction, getHumanAgentAccountIDFromReportAction, @@ -28,7 +27,7 @@ import { isTripRoom, shouldReportShowSubscript, } from '@libs/ReportUtils'; -import {getDefaultAvatar} from '@libs/UserAvatarUtils'; +import {buildUserIcon} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,7 +50,6 @@ function useReportActionAvatars({ policy: policyProp, fallbackDisplayName = '', invitedEmailsToAccountIDs, - shouldUseCustomFallbackAvatar = false, chatReportID: passedChatReportID, shouldUseRealActor = false, }: { @@ -64,7 +62,6 @@ function useReportActionAvatars({ policy?: OnyxInputOrEntry; fallbackDisplayName?: string; invitedEmailsToAccountIDs?: InvitedEmailsToAccountIDs; - shouldUseCustomFallbackAvatar?: boolean; chatReportID?: string; /** When true, returns the action's real author instead of the Concierge display override used in inbox timelines. */ shouldUseRealActor?: boolean; @@ -137,13 +134,14 @@ function useReportActionAvatars({ const avatarsForAccountIDs: IconType[] = accountIDsToMap.map((id) => { const invitedEmail = invitedEmailsToAccountIDs ? Object.keys(invitedEmailsToAccountIDs).find((email) => invitedEmailsToAccountIDs[email] === id) : undefined; - return { - id, - type: CONST.ICON_TYPE_AVATAR, - source: personalDetails?.[id]?.avatar ?? defaultAvatars.FallbackAvatar, - name: personalDetails?.[id]?.[shouldUseActorAccountID ? 'displayName' : 'login'] ?? invitedEmail ?? '', - fallbackIcon: shouldUseCustomFallbackAvatar ? getDefaultAvatar({accountID: id, accountEmail: addSMSDomainIfPhoneNumber(invitedEmail ?? ''), defaultAvatars}) : undefined, - }; + return buildUserIcon({ + accountID: id, + personalDetails, + defaultAvatars, + invitedEmail, + // Invoice actors are named after their display name; every other icon falls back to the login inside `buildUserIcon`. + name: shouldUseActorAccountID ? (personalDetails?.[id]?.displayName ?? invitedEmail ?? '') : undefined, + }); }); const fallbackWorkspaceAvatar: IconType = { diff --git a/src/components/SelectionList/ListItem/TableListItem.tsx b/src/components/SelectionList/ListItem/TableListItem.tsx index 0048fdf71b2e..426a7d191981 100644 --- a/src/components/SelectionList/ListItem/TableListItem.tsx +++ b/src/components/SelectionList/ListItem/TableListItem.tsx @@ -1,8 +1,7 @@ -import ReportActionAvatars from '@components/ReportActionAvatars'; +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; import TextWithTooltip from '@components/TextWithTooltip'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,7 +42,6 @@ function TableListItem({ }: TableListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); - const StyleUtils = useStyleUtils(); const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: styles.selectionListPressableItemWrapper.borderRadius, @@ -52,21 +50,13 @@ function TableListItem({ backgroundColor: theme.highlightBG, }); - const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; - - const rowContent = (hovered: boolean) => ( + const rowContent = () => ( <> {!!item.accountID && ( - )} diff --git a/src/components/Tables/AgentsTable/AgentsTableRow.tsx b/src/components/Tables/AgentsTable/AgentsTableRow.tsx index 6e49233b10d6..844e8e90cf98 100644 --- a/src/components/Tables/AgentsTable/AgentsTableRow.tsx +++ b/src/components/Tables/AgentsTable/AgentsTableRow.tsx @@ -1,6 +1,6 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; -import ReportActionAvatars from '@components/ReportActionAvatars'; import type {TableRow} from '@components/Table'; import Table from '@components/Table'; import {getCellAccessibilityProps, shouldUseTableSemantics} from '@components/Table/tableAccessibility'; @@ -8,7 +8,6 @@ import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,7 +34,6 @@ type AgentsTableRowProps = { export default function AgentsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: AgentsTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); - const styleUtils = useStyleUtils(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'ChatBubble']); @@ -48,11 +46,6 @@ export default function AgentsTableRow({item, rowIndex, shouldUseNarrowTableLayo const accessibilityLabel = [item.displayName, item.login].filter(Boolean).join(', '); const selectedButtonInnerStyle = item.selected ? styles.buttonDefaultHovered : undefined; - const getSecondaryAvatarContainerStyle = (hovered: boolean) => [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( - [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( - [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( - [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( - [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( ( <> - [ - styleUtils.getBackgroundAndBorderStyle(theme.sidebar), - hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined, - ]; - return ( - ; + type DefaultAvatarsType = { - defaultAvatars: Record<'ConciergeAvatar' | 'NotificationsAvatar', IconAsset>; + defaultAvatars: DefaultAvatars; }; /** @@ -488,6 +497,37 @@ function getSmallSizeAvatar(args: GetAvatarArgsType & DefaultAvatarsType): Avata return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; } +type BuildUserIconArgsType = DefaultAvatarsType & { + /** Account ID whose avatar is being resolved */ + accountID: number; + + /** Personal details from the `OnyxListItemProvider` context */ + personalDetails: OnyxEntry; + + /** + * Email tied to an invited (not-yet-registered) account. Its presence also seeds a deterministic fallback avatar, + * so an invited account keeps the same avatar before and after it registers. + */ + invitedEmail?: string; + + /** Overrides the icon name, which otherwise comes from the account's login. Pass `''` to leave it blank. */ + name?: string; +}; + +/** + * Resolves a single account ID into an avatar {@link Icon} using the personal-details context and the default-avatar set. + * Shared by `AccountAvatar` and `useReportActionAvatars` so the resolution stays in one place. + */ +function buildUserIcon({accountID, personalDetails, defaultAvatars, invitedEmail, name}: BuildUserIconArgsType): Icon { + return { + id: accountID, + type: CONST.ICON_TYPE_AVATAR, + source: personalDetails?.[accountID]?.avatar ?? defaultAvatars.FallbackAvatar, + name: name ?? personalDetails?.[accountID]?.login ?? invitedEmail ?? '', + fallbackIcon: invitedEmail ? getDefaultAvatar({accountID, accountEmail: addSMSDomainIfPhoneNumber(invitedEmail), defaultAvatars}) : undefined, + }; +} + /** * Swaps a catalog-backed avatar URL for its bundled local SVG so it renders without a network request. * Non-catalog sources (uploaded image URLs, SVG components, or undefined) are returned unchanged. @@ -497,6 +537,7 @@ function optimizeAvatarSource(source?: AvatarSource): AvatarSource | undefined { } export { + buildUserIcon, getAccountIDFromAvatarID, getAvatar, getAvatarURL, diff --git a/src/pages/home/report/ConciergeThinkingMessage.tsx b/src/pages/home/report/ConciergeThinkingMessage.tsx index 84bb2e30125b..2c2c93a5ae0c 100644 --- a/src/pages/home/report/ConciergeThinkingMessage.tsx +++ b/src/pages/home/report/ConciergeThinkingMessage.tsx @@ -1,8 +1,8 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithoutFeedback} from '@components/Pressable'; import RenderHTML from '@components/RenderHTML'; -import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; @@ -11,7 +11,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -88,13 +87,11 @@ function ConciergeThinkingBubble({reportID, agentAccountID}: {reportID: string; function ConciergeThinkingMessageContent({accountID, reasoningHistory, statusLabel}: {accountID: number; reasoningHistory: ReasoningEntry[]; statusLabel: string}) { const styles = useThemeStyles(); const theme = useTheme(); - const StyleUtils = useStyleUtils(); const {datetimeToCalendarTime, translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const hasReasoningHistory = useMemo(() => !!reasoningHistory && reasoningHistory.length > 0, [reasoningHistory]); const [manuallyCollapsed, setManuallyCollapsed] = useState(true); const isExpanded = hasReasoningHistory && !manuallyCollapsed; - const [isHovered, setIsHovered] = useState(false); const historyLength = (reasoningHistory ?? [])?.length; const currentTimestamp = DateUtils.getDBTime(); @@ -163,11 +160,7 @@ function ConciergeThinkingMessageContent({accountID, reasoningHistory, statusLab return ( {/* Avatar */} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > + {accountID === CONST.ACCOUNT_ID.CONCIERGE ? ( @@ -184,17 +177,10 @@ function ConciergeThinkingMessageContent({accountID, reasoningHistory, statusLab ) : ( - )} diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index af4ad9395533..9d3aed68d9a2 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -299,7 +299,6 @@ function WorkspaceInviteMessageComponent({ }} secondaryAvatarContainerStyle={styles.secondAvatarInline} invitedEmailsToAccountIDs={invitedEmailsToAccountIDsDraft} - shouldUseCustomFallbackAvatar shouldShowTooltip={shouldShowTooltip} /> diff --git a/tests/ui/ReportActionAvatarsTest.tsx b/tests/ui/ReportActionAvatarsTest.tsx index f7e4333f1fc4..6cb56abb46a8 100644 --- a/tests/ui/ReportActionAvatarsTest.tsx +++ b/tests/ui/ReportActionAvatarsTest.tsx @@ -384,7 +384,7 @@ function isMultipleAvatarRendered({ } function isSingleAvatarRendered({images, negate = false, userAvatar}: {images: AvatarData[]; negate?: boolean; userAvatar?: string}) { - const isUserAvatarCorrect = images.some((image) => image.uri === (userAvatar ?? USER_AVATAR) && image.parent === 'ReportActionAvatars-SingleAvatar'); + const isUserAvatarCorrect = images.some((image) => image.uri === (userAvatar ?? USER_AVATAR) && image.parent === 'SingleAvatar'); expect(isUserAvatarCorrect).toBe(!negate); } diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index 620ba1885549..f24aa65b79d2 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -391,7 +391,7 @@ describe('LHNOptionsList', () => { // Then it should render a single avatar, not a diagonal (multiple) avatar await waitFor(() => { - expect(screen.getByTestId('ReportActionAvatars-SingleAvatar')).toBeTruthy(); + expect(screen.getByTestId('SingleAvatar')).toBeTruthy(); expect(screen.queryByTestId('ReportActionAvatars-MultipleAvatars')).toBeNull(); }); }); @@ -646,7 +646,7 @@ describe('LHNOptionsList', () => { // Then it should render diagonal (multiple) avatars await waitFor(() => { expect(screen.getByTestId('ReportActionAvatars-MultipleAvatars')).toBeTruthy(); - expect(screen.queryByTestId('ReportActionAvatars-SingleAvatar')).toBeNull(); + expect(screen.queryByTestId('SingleAvatar')).toBeNull(); expect(screen.queryByTestId('ReportActionAvatars-Subscript')).toBeNull(); }); }); diff --git a/tests/ui/components/Search/ExpenseReportListItemAvatarTest.tsx b/tests/ui/components/Search/ExpenseReportListItemAvatarTest.tsx index b769bfd2168c..92e9290316a5 100644 --- a/tests/ui/components/Search/ExpenseReportListItemAvatarTest.tsx +++ b/tests/ui/components/Search/ExpenseReportListItemAvatarTest.tsx @@ -318,7 +318,7 @@ describe('ExpenseReportListItemAvatar', () => { expect(item.avatarType).toBe(CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE); - const singleAvatar = images.find((img) => img.parent === 'ReportActionAvatars-SingleAvatar'); + const singleAvatar = images.find((img) => img.parent === 'SingleAvatar'); expect(singleAvatar).toBeDefined(); expect(singleAvatar?.uri).toBe(USER_AVATAR); }); @@ -368,7 +368,7 @@ describe('ExpenseReportListItemAvatar', () => { const subscriptFragments = fragments.filter((f) => f.startsWith('ReportActionAvatars-Subscript')); expect(subscriptFragments).toHaveLength(0); - const singleAvatar = images.find((img) => img.parent === 'ReportActionAvatars-SingleAvatar'); + const singleAvatar = images.find((img) => img.parent === 'SingleAvatar'); expect(singleAvatar).toBeDefined(); expect(singleAvatar?.uri).toBe(USER_AVATAR); }); diff --git a/tests/unit/ConciergeThinkingMessageAvatarTest.tsx b/tests/unit/ConciergeThinkingMessageAvatarTest.tsx index 5e44cc36ebbb..710dde929461 100644 --- a/tests/unit/ConciergeThinkingMessageAvatarTest.tsx +++ b/tests/unit/ConciergeThinkingMessageAvatarTest.tsx @@ -10,19 +10,19 @@ import Onyx from 'react-native-onyx'; import {createAdminRoom, createAnnounceRoom} from '../utils/collections/reports'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -// Capture props passed to ReportActionAvatars (only rendered for non-Concierge agents). +// Capture props passed to AccountAvatar (only rendered for non-Concierge agents). let mockCapturedAvatarProps: Record = {}; -jest.mock('@components/ReportActionAvatars', () => { +jest.mock('@components/Avatar/connected/AccountAvatar', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); return (props: Record) => { mockCapturedAvatarProps = props; - return ; + return ; }; }); -// Concierge renders a branded Lottie animation instead of ReportActionAvatars; stub it so the test +// Concierge renders a branded Lottie animation instead of AccountAvatar; stub it so the test // doesn't pull in Lottie and so we can assert it rendered. jest.mock('@pages/home/report/ConciergeAnimatedAvatar', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -110,18 +110,18 @@ afterEach(() => { describe('ConciergeThinkingMessage avatar prop integration', () => { describe('Concierge bubble', () => { - test('should render the animated avatar (not ReportActionAvatars) in admin room', () => { + test('should render the animated avatar (not AccountAvatar) in admin room', () => { render(); expect(screen.getByTestId('MockedConciergeAnimatedAvatar')).toBeTruthy(); - expect(screen.queryByTestId('MockedReportActionAvatars')).toBeNull(); + expect(screen.queryByTestId('MockedAccountAvatar')).toBeNull(); }); - test('should render the animated avatar (not ReportActionAvatars) in announce room', () => { + test('should render the animated avatar (not AccountAvatar) in announce room', () => { render(); expect(screen.getByTestId('MockedConciergeAnimatedAvatar')).toBeTruthy(); - expect(screen.queryByTestId('MockedReportActionAvatars')).toBeNull(); + expect(screen.queryByTestId('MockedAccountAvatar')).toBeNull(); }); }); @@ -130,38 +130,17 @@ describe('ConciergeThinkingMessage avatar prop integration', () => { mockCandidateAgentIDs = [customAgentAccountID]; }); - test('should render ReportActionAvatars (not the Concierge animation)', () => { + test('should render AccountAvatar (not the Concierge animation)', () => { render(); - expect(screen.getByTestId('MockedReportActionAvatars')).toBeTruthy(); + expect(screen.getByTestId('MockedAccountAvatar')).toBeTruthy(); expect(screen.queryByTestId('MockedConciergeAnimatedAvatar')).toBeNull(); }); - test('should pass accountIDs=[agentAccountID] to ReportActionAvatars', () => { + test('should pass accountID=agentAccountID to AccountAvatar', () => { render(); - expect(mockCapturedAvatarProps.accountIDs).toEqual([customAgentAccountID]); - }); - - test('should pass exactly the agent account ID, not an empty array', () => { - render(); - - expect(mockCapturedAvatarProps.accountIDs).toBeDefined(); - expect((mockCapturedAvatarProps.accountIDs as number[]).length).toBe(1); - expect((mockCapturedAvatarProps.accountIDs as number[]).at(0)).toBe(customAgentAccountID); - }); - - test('should not pass policyID to ReportActionAvatars (would force workspace avatar)', () => { - render(); - - expect(mockCapturedAvatarProps.policyID).toBeUndefined(); - }); - - test('should not pass reportID/chatReportID to ReportActionAvatars (report context would override the agent avatar with the report-preview sender)', () => { - render(); - - expect(mockCapturedAvatarProps.reportID).toBeUndefined(); - expect(mockCapturedAvatarProps.chatReportID).toBeUndefined(); + expect(mockCapturedAvatarProps.accountID).toBe(customAgentAccountID); }); }); }); diff --git a/tests/unit/ReportActionItemSingleTest.tsx b/tests/unit/ReportActionItemSingleTest.tsx index 6c1369e637ea..4759cf734364 100644 --- a/tests/unit/ReportActionItemSingleTest.tsx +++ b/tests/unit/ReportActionItemSingleTest.tsx @@ -82,7 +82,7 @@ describe('ReportActionItemSingle', () => { } it('renders avatar properly', async () => { - const expectedIconTestID = 'ReportActionAvatars-SingleAvatar'; + const expectedIconTestID = 'SingleAvatar'; await setup(); await waitFor(() => { diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index b5087daf579d..d9334a695161 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -7,6 +7,7 @@ import useDefaultAvatars from '@hooks/useDefaultAvatars'; import CONST from '@src/CONST'; import * as UserAvatarUtils from '@src/libs/UserAvatarUtils'; +import type {PersonalDetailsList} from '@src/types/onyx'; describe('UserAvatarUtils', () => { describe('getAccountIDFromAvatarID', () => { @@ -491,6 +492,80 @@ describe('UserAvatarUtils', () => { }); }); + describe('buildUserIcon', () => { + const ACCOUNT_ID = 42; + const AVATAR_URL = 'https://example.com/uploaded-avatar.png'; + const personalDetails: PersonalDetailsList = { + [ACCOUNT_ID]: { + accountID: ACCOUNT_ID, + login: 'john@example.com', + displayName: 'John Doe', + avatar: AVATAR_URL, + }, + }; + + it('should resolve the avatar and login from personal details when available', () => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const icon = UserAvatarUtils.buildUserIcon({accountID: ACCOUNT_ID, personalDetails, defaultAvatars: avatars.current}); + + expect(icon).toEqual({ + id: ACCOUNT_ID, + type: CONST.ICON_TYPE_AVATAR, + source: AVATAR_URL, + name: 'john@example.com', + fallbackIcon: undefined, + }); + }); + + it.each([ + ['the account has no avatar', {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: 'john@example.com'}}], + ['the account is missing from personal details', {}], + ])('should fall back to the default fallback avatar when %s', (_case, details: PersonalDetailsList) => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const icon = UserAvatarUtils.buildUserIcon({accountID: ACCOUNT_ID, personalDetails: details, defaultAvatars: avatars.current}); + + expect(icon.source).toBe(avatars.current.FallbackAvatar); + }); + + it.each([ + ['the login by default', personalDetails, {}, 'john@example.com'], + ['the passed name when one is given', personalDetails, {name: 'John Doe'}, 'John Doe'], + ['nothing when the passed name is blank, rather than falling back to the login', personalDetails, {name: ''}, ''], + ['the invited email when the account has no personal details', {}, {invitedEmail: 'invited@example.com'}, 'invited@example.com'], + ['an empty string when nothing is known about the account', {}, {}, ''], + ])('should name the icon after %s', (_case, details: PersonalDetailsList, options: {invitedEmail?: string; name?: string}, expectedName: string) => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const icon = UserAvatarUtils.buildUserIcon({accountID: ACCOUNT_ID, personalDetails: details, defaultAvatars: avatars.current, ...options}); + + expect(icon.name).toBe(expectedName); + }); + + it('should not compute a custom fallback icon for a registered account', () => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const icon = UserAvatarUtils.buildUserIcon({accountID: ACCOUNT_ID, personalDetails, defaultAvatars: avatars.current}); + + expect(icon.fallbackIcon).toBeUndefined(); + }); + + it('should compute a deterministic custom fallback icon for an invited account', () => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const icon = UserAvatarUtils.buildUserIcon({ + accountID: ACCOUNT_ID, + personalDetails: {}, + defaultAvatars: avatars.current, + invitedEmail: 'invited@example.com', + }); + + expect(icon.fallbackIcon).toBe( + UserAvatarUtils.getDefaultAvatar({ + accountID: ACCOUNT_ID, + accountEmail: 'invited@example.com', + defaultAvatars: avatars.current, + }), + ); + }); + }); + describe('getLetterAvatarURL (golden parity)', () => { // Frozen golden cases shared with the Auth and Web getLetterAvatarURL tests. All three // implementations must produce these exact URLs. Colors come from md5(login) % palette size. diff --git a/tests/unit/components/Avatar/connected/AccountAvatarTest.tsx b/tests/unit/components/Avatar/connected/AccountAvatarTest.tsx new file mode 100644 index 000000000000..aca2973abede --- /dev/null +++ b/tests/unit/components/Avatar/connected/AccountAvatarTest.tsx @@ -0,0 +1,140 @@ +import {render} from '@testing-library/react-native'; + +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; + +import CONST from '@src/CONST'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import React from 'react'; + +const ACCOUNT_ID = 42; +const AVATAR_URL = 'https://example.com/uploaded-avatar.png'; +const LOGIN = 'john@example.com'; +const CUSTOM_CONTAINER_STYLE = {borderRadius: 20}; + +// Stands in for the bundled fallback SVG so the resolved icon can be asserted by identity. +function MockFallbackAvatar() { + return null; +} + +// Captures the props `AccountAvatar` hands to the layout primitive, which is the whole contract of this component. +let mockCapturedSingleAvatarProps: Record = {}; + +const mockGetContainerStyles = jest.fn((size: string) => [{marginRight: 12, size}]); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + ConciergeAvatar: MockFallbackAvatar, + NotificationsAvatar: MockFallbackAvatar, + FallbackAvatar: MockFallbackAvatar, + }), +})); + +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getContainerStyles: mockGetContainerStyles}))); + +let mockPersonalDetails: PersonalDetailsList = {}; + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => mockPersonalDetails, +})); + +jest.mock('@components/Avatar/layouts/SingleAvatar', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return (props: Record) => { + mockCapturedSingleAvatarProps = props; + return ; + }; +}); + +describe('AccountAvatar (connected)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCapturedSingleAvatarProps = {}; + mockPersonalDetails = { + [ACCOUNT_ID]: { + accountID: ACCOUNT_ID, + login: LOGIN, + avatar: AVATAR_URL, + }, + }; + }); + + it('should resolve the icon for the account from the personal-details context', () => { + render(); + + expect(mockCapturedSingleAvatarProps.avatar).toEqual({ + id: ACCOUNT_ID, + type: CONST.ICON_TYPE_AVATAR, + source: AVATAR_URL, + name: LOGIN, + fallbackIcon: undefined, + }); + }); + + it('should fall back to the default fallback avatar for an account with no personal details', () => { + mockPersonalDetails = {}; + + render(); + + expect(mockCapturedSingleAvatarProps.avatar).toEqual({ + id: ACCOUNT_ID, + type: CONST.ICON_TYPE_AVATAR, + source: MockFallbackAvatar, + name: '', + fallbackIcon: undefined, + }); + }); + + it.each([ + ['the default size when none is passed', undefined, CONST.AVATAR_SIZE.DEFAULT], + ['the passed size', CONST.AVATAR_SIZE.SMALL, CONST.AVATAR_SIZE.SMALL], + ])('should derive the container styles from %s', (_case, size, expectedSize) => { + render( + , + ); + + expect(mockGetContainerStyles).toHaveBeenCalledWith(expectedSize); + expect(mockCapturedSingleAvatarProps.size).toBe(expectedSize); + expect(mockCapturedSingleAvatarProps.containerStyles).toEqual(mockGetContainerStyles(expectedSize)); + }); + + it('should replace the derived container styles when containerStyle is passed', () => { + render( + , + ); + + expect(mockCapturedSingleAvatarProps.containerStyles).toBe(CUSTOM_CONTAINER_STYLE); + }); + + it.each([ + ['default to showing the tooltip', undefined, true], + ['forward an explicit tooltip opt-out', false, false], + ])('should %s', (_case, shouldShowTooltip, expected) => { + render( + , + ); + + expect(mockCapturedSingleAvatarProps.shouldShowTooltip).toBe(expected); + }); + + it('should forward the tooltip fallback display name', () => { + render( + , + ); + + expect(mockCapturedSingleAvatarProps.fallbackDisplayName).toBe('John Doe'); + }); +}); diff --git a/tests/unit/pages/settings/AgentsTableRowTest.tsx b/tests/unit/pages/settings/AgentsTableRowTest.tsx index 7d4c0f82c197..68720e86797e 100644 --- a/tests/unit/pages/settings/AgentsTableRowTest.tsx +++ b/tests/unit/pages/settings/AgentsTableRowTest.tsx @@ -32,12 +32,6 @@ jest.mock('@hooks/useThemeStyles', () => ), ); -jest.mock('@hooks/useStyleUtils', () => - jest.fn(() => ({ - getBackgroundAndBorderStyle: jest.fn(() => ({})), - })), -); - jest.mock('@hooks/useTheme', () => jest.fn(() => ({ sidebar: '#fff', @@ -72,11 +66,11 @@ jest.mock('@components/Table/TableContext', () => ({ })), })); -jest.mock('@components/ReportActionAvatars', () => { - function MockReportActionAvatars() { +jest.mock('@components/Avatar/connected/AccountAvatar', () => { + function MockAccountAvatar() { return null; } - return MockReportActionAvatars; + return MockAccountAvatar; }); jest.mock('@components/Icon', () => {