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';