diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4840da1ed82fd..aa03621ea412d 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3281,6 +3281,7 @@ const CONST = { }, AVATAR_SIZE: { + X_X_LARGE: 'xxlarge', X_LARGE: 'xlarge', LARGE: 'large', MEDIUM: 'medium', diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index a22fb01ca7d5a..59df4d2cd615f 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -114,6 +114,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) { errorText: error ?? '', shouldShowRedDotIndicator: !!error, errorTextStyle: styles.mt2, + iconFileName: personalDetails?.originalFileName, ...additionalProps, }; }; @@ -197,6 +198,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) { avatarID={currentUserPersonalDetails?.accountID} source={currentUserPersonalDetails?.avatar} fallbackIcon={currentUserPersonalDetails.fallbackIcon} + originalFileName={currentUserPersonalDetails.originalFileName} /> diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 653aacb8387c8..c981092d1292a 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -55,6 +55,9 @@ type AvatarProps = { /** Optional account id if it's user avatar or policy id if it's workspace avatar */ avatarID?: number | string; + /** Used for recognizing and display optimization of letter avatars. */ + originalFileName?: string; + /** Test ID for the Avatar component */ testID?: string; }; @@ -72,6 +75,7 @@ function Avatar({ name = '', avatarID, testID = 'Avatar', + originalFileName, }: AvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -86,8 +90,8 @@ function Avatar({ const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE; const userAccountID = isWorkspace ? undefined : (avatarID as number); + const source = isWorkspace ? originalSource : getAvatar({avatarSource: originalSource, accountID: userAccountID, originalFileName, size}); - const source = isWorkspace ? originalSource : getAvatar({avatarSource: originalSource, accountID: userAccountID}); let optimizedSource = source; const maybeDefaultAvatarName = getPresetAvatarNameFromURL(source); diff --git a/src/components/AvatarButtonWithIcon.tsx b/src/components/AvatarButtonWithIcon.tsx index 4be2d86ad9c06..c7d17017c87c2 100644 --- a/src/components/AvatarButtonWithIcon.tsx +++ b/src/components/AvatarButtonWithIcon.tsx @@ -64,6 +64,9 @@ type AvatarButtonWithIconProps = { /** The name associated with avatar */ name?: string; + + /** Original file name. Used for recognizing and display optimization of letter avatars. */ + originalFileName?: string; }; /** @@ -86,6 +89,7 @@ function AvatarButtonWithIcon({ editIcon = Expensicons.Pencil, anchorRef, name = '', + originalFileName, }: AvatarButtonWithIconProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -115,6 +119,7 @@ function AvatarButtonWithIcon({ size={size} type={type} name={name} + originalFileName={originalFileName} /> ) : ( diff --git a/src/components/AvatarWithIndicator.tsx b/src/components/AvatarWithIndicator.tsx index 4545706634571..0717b3d0cb37e 100644 --- a/src/components/AvatarWithIndicator.tsx +++ b/src/components/AvatarWithIndicator.tsx @@ -25,9 +25,12 @@ type AvatarWithIndicatorProps = { /** Indicates whether the avatar is loaded or not */ isLoading?: boolean; + + /** Used for recognizing and display optimization of letter avatars. */ + originalFileName?: string; }; -function AvatarWithIndicator({source, accountID, tooltipText = '', fallbackIcon = Expensicons.FallbackAvatar, isLoading = true}: AvatarWithIndicatorProps) { +function AvatarWithIndicator({source, accountID, tooltipText = '', fallbackIcon = Expensicons.FallbackAvatar, isLoading = true, originalFileName}: AvatarWithIndicatorProps) { const styles = useThemeStyles(); return ( @@ -43,6 +46,7 @@ function AvatarWithIndicator({source, accountID, tooltipText = '', fallbackIcon fallbackIcon={fallbackIcon} avatarID={accountID} type={CONST.ICON_TYPE_AVATAR} + originalFileName={originalFileName} /> diff --git a/src/components/ColoredLetterAvatar.tsx b/src/components/ColoredLetterAvatar.tsx index 35af6616274b2..b206913c40de2 100644 --- a/src/components/ColoredLetterAvatar.tsx +++ b/src/components/ColoredLetterAvatar.tsx @@ -26,7 +26,7 @@ function ColoredLetterAvatar({component, backgroundColor, fillColor, size = CONS const avatarSize = StyleUtils.getAvatarSize(size); return ( {!!label && !isLabelHoverable && ( @@ -799,6 +802,7 @@ function MenuItem({ fallbackIcon={fallbackIcon} size={avatarSize} type={CONST.ICON_TYPE_AVATAR} + originalFileName={iconFileName} /> )} {iconType === CONST.ICON_TYPE_PLAID && !!plaidUrl && } diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 9c89f275a2f56..a0622a4ab35a3 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -367,8 +367,7 @@ function BasePopoverMenu({ }; const renderedMenuItems = currentMenuItems.map((item, menuIndex) => { - const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, testID: menuItemTestID, shouldShowLoadingSpinnerIcon, ...menuItemProps} = item; - + const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, testID: menuItemTestID, shouldShowLoadingSpinnerIcon, iconFileName, ...menuItemProps} = item; return ( { if (!shouldUpdateFocusedIndex) { return; diff --git a/src/components/ReportActionAvatars/ReportActionAvatar.tsx b/src/components/ReportActionAvatars/ReportActionAvatar.tsx index 9c0c1fa4e24eb..08f657dd711a2 100644 --- a/src/components/ReportActionAvatars/ReportActionAvatar.tsx +++ b/src/components/ReportActionAvatars/ReportActionAvatar.tsx @@ -154,6 +154,7 @@ function ReportActionAvatarSingle({ fallbackIcon={fallbackIcon} testID="ReportActionAvatars-SingleAvatar" reportID={reportID} + originalFileName={avatar?.originalFileName} /> @@ -235,6 +236,7 @@ function ReportActionAvatarSubscript({ fallbackIcon={primaryAvatar.fallbackIcon} testID="ReportActionAvatars-Subscript-MainAvatar" reportID={reportID} + originalFileName={primaryAvatar.originalFileName} /> @@ -265,6 +267,7 @@ function ReportActionAvatarSubscript({ fallbackIcon={secondaryAvatar.fallbackIcon} testID="ReportActionAvatars-Subscript-SecondaryAvatar" reportID={reportID} + originalFileName={secondaryAvatar.originalFileName} /> @@ -408,6 +411,7 @@ function ReportActionAvatarMultipleHorizontal({ fallbackIcon={icon.fallbackIcon} testID="ReportActionAvatars-MultipleAvatars-StackedHorizontally-Avatar" reportID={reportID} + originalFileName={icon?.originalFileName} /> @@ -550,6 +554,7 @@ function ReportActionAvatarMultipleDiagonal({ fallbackIcon={icons.at(0)?.fallbackIcon} testID="ReportActionAvatars-MultipleAvatars-MainAvatar" reportID={reportID} + originalFileName={icons.at(0)?.originalFileName} /> @@ -582,6 +587,7 @@ function ReportActionAvatarMultipleDiagonal({ fallbackIcon={icons.at(1)?.fallbackIcon} testID="ReportActionAvatars-MultipleAvatars-SecondaryAvatar" reportID={reportID} + originalFileName={icons.at(1)?.originalFileName} /> diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index b41359724f76d..8f3696a498cfe 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -119,6 +119,7 @@ function useReportActionAvatars({ source: personalDetails?.[id]?.avatar ?? FallbackAvatar, name: personalDetails?.[id]?.[shouldUseActorAccountID ? 'displayName' : 'login'] ?? invitedEmail ?? '', fallbackIcon: shouldUseCustomFallbackAvatar ? RandomAvatarUtils.getAvatarForContact(String(id)) : undefined, + originalFileName: personalDetails?.[id]?.originalFileName, }; }); @@ -221,6 +222,7 @@ function useReportActionAvatars({ type: CONST.ICON_TYPE_AVATAR, fill: undefined, fallbackIcon, + originalFileName: delegatePersonalDetails.displayName, } : undefined; diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx index 380149c1235d8..0a2482c98846c 100644 --- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx +++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx @@ -63,6 +63,7 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA type={icon?.type ?? CONST.ICON_TYPE_AVATAR} name={icon?.name ?? userLogin} fallbackIcon={icon?.fallbackIcon} + originalFileName={icon?.originalFileName} /> {title} diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 687e1e5e2b52c..c56b1f0462448 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3019,6 +3019,7 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxIn type: CONST.ICON_TYPE_AVATAR, name: displayNameLogin ?? '', fallbackIcon: personalDetails?.[accountID]?.fallbackIcon ?? '', + originalFileName: personalDetails?.[accountID]?.originalFileName, }; avatars.push(userIcon); } @@ -3345,6 +3346,7 @@ function getParticipantIcon(accountID: number | undefined, personalDetails: Onyx type: CONST.ICON_TYPE_AVATAR, name: displayName, fallbackIcon: details?.fallbackIcon, + originalFileName: details?.originalFileName, }; } @@ -3390,6 +3392,7 @@ function getIconsForExpenseRequest(report: OnyxInputOrEntry, personalDet type: CONST.ICON_TYPE_AVATAR, name: actorDetails?.displayName ?? '', fallbackIcon: actorDetails?.fallbackIcon, + originalFileName: actorDetails?.originalFileName, }; return [memberIcon, workspaceIcon]; } @@ -3411,6 +3414,7 @@ function getIconsForChatThread(report: OnyxInputOrEntry, personalDetails name: formatPhoneNumber(actorDisplayName), type: CONST.ICON_TYPE_AVATAR, fallbackIcon: actorDetails?.fallbackIcon, + originalFileName: actorDetails?.originalFileName, }; if (isWorkspaceThread(report)) { @@ -3506,6 +3510,7 @@ function getIconsForIOUReport(report: OnyxInputOrEntry, personalDetails: type: CONST.ICON_TYPE_AVATAR, name: managerDetails?.displayName ?? '', fallbackIcon: managerDetails?.fallbackIcon, + originalFileName: managerDetails?.originalFileName, }; const ownerIcon = { id: report?.ownerAccountID, @@ -3513,6 +3518,7 @@ function getIconsForIOUReport(report: OnyxInputOrEntry, personalDetails: type: CONST.ICON_TYPE_AVATAR, name: ownerDetails?.displayName ?? '', fallbackIcon: ownerDetails?.fallbackIcon, + originalFileName: ownerDetails?.originalFileName, }; const isManager = currentUserAccountID === report?.managerID; diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.tsx similarity index 82% rename from src/libs/UserAvatarUtils.ts rename to src/libs/UserAvatarUtils.tsx index 429e30654c0dd..e6b297bdeff13 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.tsx @@ -1,8 +1,18 @@ import {md5} from 'expensify-common'; +import React from 'react'; +import type {SvgProps} from 'react-native-svg'; +import ColoredLetterAvatar from '@components/ColoredLetterAvatar'; import {ConciergeAvatar, NotificationsAvatar} from '@components/Icon/Expensicons'; +import type {AvatarSizeName} from '@styles/utils'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; -import {getAvatarLocal as avatarCatalogGetAvatarLocal, getAvatarURL as avatarCatalogGetAvatarURL, DEFAULT_AVATAR_PREFIX, PRESET_AVATAR_CATALOG} from './Avatars/PresetAvatarCatalog'; +import { + getAvatarLocal as avatarCatalogGetAvatarLocal, + getAvatarURL as avatarCatalogGetAvatarURL, + getLetterAvatar as avatarCatalogGetLetterAvatar, + DEFAULT_AVATAR_PREFIX, + PRESET_AVATAR_CATALOG, +} from './Avatars/PresetAvatarCatalog'; import type {DefaultAvatarIDs, PresetAvatarID} from './Avatars/PresetAvatarCatalog.types'; type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24; @@ -39,6 +49,12 @@ type DefaultAvatarArgsType = CommonAvatarArgsType & { type GetAvatarArgsType = CommonAvatarArgsType & { /** The avatar source - can be a URL string or SVG component */ avatarSource?: AvatarSource; + + /** Name of the original file - used for recognizing letter avatars */ + originalFileName?: string; + + /** Desired avatar size for letter avatars */ + size?: AvatarSizeName; }; /** @@ -192,6 +208,33 @@ function isLetterAvatar(originalFileName?: string): boolean { return !!(originalFileName && LETTER_AVATAR_NAME_REGEX.test(originalFileName)); } +/** + * Extracts the letter and colors from a letter avatar filename. + * Letter avatars follow the pattern: letter-avatar-#RRGGBB-#RRGGBB-X.png + * where the first RRGGBB is the background color, second is the text color, and X is the letter. + * + * @param originalFileName - The original filename of the avatar + * @returns An object containing the letter and colors, or undefined if not a valid letter avatar + */ +function extractLetterAvatarData(originalFileName?: string): {letter: string; backgroundColor: string; textColor: string} | undefined { + if (!isLetterAvatar(originalFileName)) { + return undefined; + } + + // Extract components using regex groups + const match = originalFileName?.match(/^letter-avatar-(#[0-9A-F]{6})-(#[0-9A-F]{6})-([A-Z])\.png$/); + + if (!match) { + return undefined; + } + + return { + backgroundColor: match[1], + textColor: match[2], + letter: match[3], + }; +} + /** * Returns the appropriate avatar source (SVG asset or URL) for rendering in React components. * @@ -209,13 +252,43 @@ function isLetterAvatar(originalFileName?: string): boolean { * @param args.accountID - The user's account ID * @param args.accountEmail - The user's email address (for consistency with backend logic, used for avatar calculation if provided) * @param args.avatarSource - The avatar source (URL or SVG component) + * @param args.originalFileName - The original filename of the avatar (used to identify letter avatars) + * @param args.size - Desired avatar size (used for letter avatars) * @returns The avatar source ready for rendering (SVG component for defaults, URL string for uploads) * */ -function getAvatar({avatarSource, accountID = CONST.DEFAULT_NUMBER_ID, accountEmail}: GetAvatarArgsType): AvatarSource | undefined { +function getAvatar({ + avatarSource, + accountID = CONST.DEFAULT_NUMBER_ID, + accountEmail, + originalFileName, + size = CONST.AVATAR_SIZE.DEFAULT, +}: GetAvatarArgsType): AvatarSource | React.FC | undefined { if (isDefaultAvatar(avatarSource)) { return getDefaultAvatar({accountID, accountEmail, avatarURL: avatarSource}); } + if (isLetterAvatar(originalFileName)) { + function StyledLetterAvatar() { + const letterAvatarData = extractLetterAvatarData(originalFileName); + if (!letterAvatarData) { + return null; + } + const avatarComponent = avatarCatalogGetLetterAvatar(letterAvatarData.letter); + if (!avatarComponent) { + return null; + } + + return ( + + ); + } + return StyledLetterAvatar ?? avatarSource; + } const maybePresetAvatarName = getPresetAvatarNameFromURL(avatarSource); if (maybePresetAvatarName) { @@ -259,7 +332,7 @@ function getAvatarURL({accountID = CONST.DEFAULT_NUMBER_ID, avatarSource, accoun * @returns The full-size avatar source */ function getFullSizeAvatar(args: GetAvatarArgsType): AvatarSource | undefined { - const source = getAvatar(args); + const source = getAvatar({...args, size: CONST.AVATAR_SIZE.X_X_LARGE}); if (typeof source !== 'string') { return source; } @@ -306,5 +379,6 @@ export { isPresetAvatar, isDefaultAvatar, isLetterAvatar, + extractLetterAvatarData, }; export type {AvatarSource}; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index c14252210698d..b99f3a182b370 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -198,6 +198,7 @@ function ProfilePage({route}: ProfilePageProps) { type={CONST.ICON_TYPE_AVATAR} size={CONST.AVATAR_SIZE.X_LARGE} fallbackIcon={fallbackIcon} + originalFileName={details?.originalFileName} /> diff --git a/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx b/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx index fc29205afed2a..c52a5231299ac 100644 --- a/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx +++ b/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx @@ -37,6 +37,7 @@ function ProfileAvatarWithIndicator({isSelected = false, containerStyles}: Profi accountID={currentUserPersonalDetails.accountID} fallbackIcon={currentUserPersonalDetails.fallbackIcon} isLoading={!!(isLoading && !currentUserPersonalDetails.avatar)} + originalFileName={currentUserPersonalDetails?.originalFileName} /> diff --git a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx index 833f10431a601..43d01bdd1aa75 100644 --- a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx @@ -36,9 +36,9 @@ function ProfileAvatarModalContent({navigation, route}: AttachmentModalScreenPro // Temp variables are coming as '' therefore || is needed // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const source = tempSource || getFullSizeAvatar({avatarSource: avatarURL, accountID}); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const originalFileName = tempOriginalFileName || (personalDetail?.originalFileName ?? ''); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const source = tempSource || getFullSizeAvatar({avatarSource: avatarURL, accountID, originalFileName}); const headerTitle = formatPhoneNumber(displayName); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !avatarURL; diff --git a/src/pages/settings/Profile/Avatar/AvatarCapture/index.native.tsx b/src/pages/settings/Profile/Avatar/AvatarCapture/index.native.tsx index 787742b7d3a64..2163a8acee8b8 100644 --- a/src/pages/settings/Profile/Avatar/AvatarCapture/index.native.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarCapture/index.native.tsx @@ -1,5 +1,6 @@ import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import ViewShot from 'react-native-view-shot'; +import variables from '@styles/variables'; import type {AvatarCaptureHandle, AvatarCaptureProps} from './types'; /** @@ -28,7 +29,7 @@ function AvatarCapture({children, fileName}: AvatarCaptureProps, ref: React.Forw return ( {children} diff --git a/src/pages/settings/Profile/Avatar/AvatarCapture/index.tsx b/src/pages/settings/Profile/Avatar/AvatarCapture/index.tsx index e3728f8b10f64..2eef2a5255a73 100644 --- a/src/pages/settings/Profile/Avatar/AvatarCapture/index.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarCapture/index.tsx @@ -1,5 +1,6 @@ import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import type {View as RNView} from 'react-native'; +import variables from '@styles/variables'; import type {AvatarCaptureHandle, AvatarCaptureProps} from './types'; /** @@ -33,17 +34,14 @@ function AvatarCapture({children, fileName: name}: AvatarCaptureProps, ref: Reac return; } - // Get dimensions and background color - const bbox = coloredAvatarElement.getBoundingClientRect(); - const width = bbox.width; - const height = bbox.height; + // Get background color and set fixed dimensions + const width = variables.avatarSizeXXLarge; + const height = variables.avatarSizeXXLarge; const backgroundColor = globalThis.getComputedStyle(coloredAvatarElement).backgroundColor; - // Create canvas with 2x resolution for better quality const canvas = document.createElement('canvas'); - const scale = 2; - canvas.width = width * scale; - canvas.height = height * scale; + canvas.width = width; + canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { @@ -51,8 +49,6 @@ function AvatarCapture({children, fileName: name}: AvatarCaptureProps, ref: Reac return; } - ctx.scale(scale, scale); - // Draw circular background ctx.fillStyle = backgroundColor; ctx.beginPath(); diff --git a/src/pages/settings/Profile/Avatar/AvatarPage.tsx b/src/pages/settings/Profile/Avatar/AvatarPage.tsx index b4d6a3a61b7e8..0511068eb1691 100644 --- a/src/pages/settings/Profile/Avatar/AvatarPage.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarPage.tsx @@ -149,7 +149,6 @@ function ProfileAvatar() { const onPress = useCallback(() => { isSavingRef.current = true; - if (imageData.file) { updateAvatar(imageData.file, { avatar: currentUserPersonalDetails?.avatar, @@ -214,6 +213,7 @@ function ProfileAvatar() { fileName={selected ?? 'avatar'} > )} diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index 3b1d8a792f091..5a59dd702fd30 100644 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -248,6 +248,7 @@ function WorkspacesListRow({ type={CONST.ICON_TYPE_AVATAR} size={CONST.AVATAR_SIZE.SMALL} containerStyles={styles.workspaceOwnerAvatarWrapper} + originalFileName={ownerDetails.originalFileName} /> > = { [CONST.AVATAR_SIZE.MEDIUM]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.LARGE]: variables.componentBorderRadiusLarge, [CONST.AVATAR_SIZE.X_LARGE]: variables.componentBorderRadiusLarge, + [CONST.AVATAR_SIZE.X_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, @@ -99,6 +100,7 @@ const avatarSizes: Record = { [CONST.AVATAR_SIZE.SMALLER]: variables.avatarSizeSmaller, [CONST.AVATAR_SIZE.LARGE]: variables.avatarSizeLarge, [CONST.AVATAR_SIZE.X_LARGE]: variables.avatarSizeXLarge, + [CONST.AVATAR_SIZE.X_X_LARGE]: variables.avatarSizeXXLarge, [CONST.AVATAR_SIZE.MEDIUM]: variables.avatarSizeMedium, [CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.avatarSizeLargeBordered, [CONST.AVATAR_SIZE.MEDIUM_LARGE]: variables.avatarSizeMediumLarge, diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts index cfd11eb587cdf..925767d6b3a1c 100644 --- a/src/styles/utils/types.ts +++ b/src/styles/utils/types.ts @@ -21,6 +21,7 @@ type AvatarSizeValue = ValueOf< | 'avatarSizeSmall' | 'avatarSizeSmaller' | 'avatarSizeXLarge' + | 'avatarSizeXXLarge' | 'avatarSizeLarge' | 'avatarSizeMedium' | 'avatarSizeMediumLarge' diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ad067ed20605d..a06a429d9dfcd 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -35,6 +35,7 @@ export default { buttonBorderRadius: 100, avatarSizeLargeBordered: 88, avatarSizeXLarge: 100, + avatarSizeXXLarge: 720, avatarSizeLarge: 80, avatarSizeMediumLarge: 60, avatarSizeMedium: 52, diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 3ad1e2c0a4a1f..fae5533a76be6 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -68,6 +68,9 @@ type Icon = { /** Fill color of the icon */ fill?: string; + + /** Name of the original file - used for recognizing letter avatars */ + originalFileName?: string; }; export type {Icon, PendingAction, PendingFields, ErrorFields, Errors, AvatarType, OnyxValueWithOfflineFeedback, TranslationKeyError, TranslationKeyErrors}; diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.tsx similarity index 83% rename from tests/unit/UserAvatarUtilsTest.ts rename to tests/unit/UserAvatarUtilsTest.tsx index 8d5274e8c5c27..0f74ffb08f912 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.tsx @@ -39,6 +39,29 @@ describe('UserAvatarUtils', () => { expect(avatarByEmail).toBe(defaultAvatars.Avatar5); expect(avatarById).toBe(defaultAvatars.Avatar5); }); + + describe('letter avatar case', () => { + it('should return a function component for valid letter avatar filename', () => { + const result = UserAvatarUtils.getAvatar({ + avatarSource: 'https://example.com/avatar.png', + originalFileName: 'letter-avatar-#FF0000-#00FF00-A.png', + accountID: 123, + }); + + expect(typeof result).toBe('function'); + }); + + it('should return avatarSource as fallback when extractLetterAvatarData returns undefined', () => { + const avatarSource = 'https://example.com/avatar.png'; + const result = UserAvatarUtils.getAvatar({ + avatarSource, + originalFileName: 'invalid-letter-avatar.png', + accountID: 123, + }); + + expect(result).toBe(avatarSource); + }); + }); }); describe('getAvatarUrl', () => { @@ -178,6 +201,47 @@ describe('UserAvatarUtils', () => { }); }); + describe('extractLetterAvatarData', () => { + describe('valid letter avatar filenames', () => { + it.each([ + ['letter-avatar-#FF0000-#00FF00-A.png', '#FF0000', '#00FF00', 'A'], + ['letter-avatar-#123456-#ABCDEF-Z.png', '#123456', '#ABCDEF', 'Z'], + ['letter-avatar-#0123AB-#CDEF89-M.png', '#0123AB', '#CDEF89', 'M'], + ['letter-avatar-#000000-#FFFFFF-B.png', '#000000', '#FFFFFF', 'B'], + ])('should extract data from %s', (fileName, backgroundColor, textColor, letter) => { + const result = UserAvatarUtils.extractLetterAvatarData(fileName); + expect(result).toEqual({ + backgroundColor, + textColor, + letter, + }); + }); + + it('should handle all uppercase letters A-Z', () => { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + letters.forEach((letter) => { + const result = UserAvatarUtils.extractLetterAvatarData(`letter-avatar-#FF0000-#00FF00-${letter}.png`); + expect(result).toEqual({ + backgroundColor: '#FF0000', + textColor: '#00FF00', + letter, + }); + }); + }); + }); + + describe('invalid letter avatar filenames', () => { + it.each([ + ['undefined input', undefined], + ['empty string', ''], + ['random string', 'random-filename.png'], + ])('should return undefined for %s', (_description, fileName) => { + const result = UserAvatarUtils.extractLetterAvatarData(fileName); + expect(result).toBeUndefined(); + }); + }); + }); + describe('getSmallSizeAvatar', () => { it('should add _128 suffix to CloudFront avatars', () => { const source = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatar.png';