diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
index 3b906ee36b13..63a828826995 100644
--- a/src/components/Avatar.tsx
+++ b/src/components/Avatar.tsx
@@ -6,10 +6,10 @@ import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import {getAvatarLocal} from '@libs/Avatars/PresetAvatarCatalog';
+import {findLocalAvatarForURL} from '@libs/Avatars/AvatarLookup';
import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils';
import type {AvatarSource} from '@libs/UserAvatarUtils';
-import {getAvatar, getPresetAvatarNameFromURL} from '@libs/UserAvatarUtils';
+import {getAvatar} from '@libs/UserAvatarUtils';
import type {AvatarSizeName} from '@styles/utils';
import CONST from '@src/CONST';
import type {AvatarType} from '@src/types/onyx/OnyxCommon';
@@ -87,10 +87,10 @@ function Avatar({
const source = isWorkspace ? originalSource : getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars});
let optimizedSource = source;
- const maybeDefaultAvatarName = getPresetAvatarNameFromURL(source);
+ const localFromCatalog = findLocalAvatarForURL(source);
- if (maybeDefaultAvatarName) {
- optimizedSource = getAvatarLocal(maybeDefaultAvatarName);
+ if (localFromCatalog) {
+ optimizedSource = localFromCatalog;
}
const useFallBackAvatar = imageError || !source || source === defaultAvatars.FallbackAvatar;
const fallbackAvatar = isWorkspace ? getDefaultWorkspaceAvatar(name) : (fallbackIcon ?? defaultAvatars.FallbackAvatar) || defaultAvatars.FallbackAvatar;
diff --git a/src/components/AvatarSelector.tsx b/src/components/AvatarSelector.tsx
index 0a2e8718cee1..8fee658c1412 100644
--- a/src/components/AvatarSelector.tsx
+++ b/src/components/AvatarSelector.tsx
@@ -4,7 +4,7 @@ import useLetterAvatars from '@hooks/useLetterAvatars';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
-import {PRESET_AVATAR_CATALOG_ORDERED} from '@libs/Avatars/PresetAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
import type {AvatarSizeName} from '@styles/utils';
import CONST from '@src/CONST';
import Avatar from './Avatar';
@@ -47,7 +47,7 @@ function AvatarSelector({selectedID, onSelect, label, name, size = CONST.AVATAR_
{label}
)}
- {PRESET_AVATAR_CATALOG_ORDERED.map(({id, local}) => {
+ {USER_AVATARS.ordered.map(({id, local}) => {
const isSelected = selectedID === id;
return (
diff --git a/src/components/Icon/DefaultBotAvatars.ts b/src/components/Icon/DefaultBotAvatars.ts
index bc10344ff420..f75e238cb0c3 100644
--- a/src/components/Icon/DefaultBotAvatars.ts
+++ b/src/components/Icon/DefaultBotAvatars.ts
@@ -1,4 +1,3 @@
-import type {TupleToUnion} from 'type-fest';
import BotAvatarBlue from '@assets/images/avatars/bots/bot-avatar--blue.svg';
import BotAvatarGreen from '@assets/images/avatars/bots/bot-avatar--green.svg';
import BotAvatarIce from '@assets/images/avatars/bots/bot-avatar--ice.svg';
@@ -6,18 +5,4 @@ import BotAvatarPink from '@assets/images/avatars/bots/bot-avatar--pink.svg';
import BotAvatarTangerine from '@assets/images/avatars/bots/bot-avatar--tangerine.svg';
import BotAvatarYellow from '@assets/images/avatars/bots/bot-avatar--yellow.svg';
-const botAvatars = [BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow] as const;
-
-type BotAvatar = TupleToUnion;
-
-const botAvatarIDs = new Map([
- [BotAvatarBlue, 'bot-avatar--blue'],
- [BotAvatarGreen, 'bot-avatar--green'],
- [BotAvatarIce, 'bot-avatar--ice'],
- [BotAvatarPink, 'bot-avatar--pink'],
- [BotAvatarTangerine, 'bot-avatar--tangerine'],
- [BotAvatarYellow, 'bot-avatar--yellow'],
-]);
-
-export {botAvatars, botAvatarIDs, BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow};
-export type {BotAvatar};
+export {BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow};
diff --git a/src/hooks/useLetterAvatars.tsx b/src/hooks/useLetterAvatars.tsx
index cd02a3585312..0eef58277c49 100644
--- a/src/hooks/useLetterAvatars.tsx
+++ b/src/hooks/useLetterAvatars.tsx
@@ -1,7 +1,7 @@
import React, {useMemo} from 'react';
import type {SvgProps} from 'react-native-svg';
import ColoredLetterAvatar from '@components/ColoredLetterAvatar';
-import {getLetterAvatar, LETTER_AVATAR_COLOR_OPTIONS} from '@libs/Avatars/PresetAvatarCatalog';
+import {getLetterAvatar, LETTER_AVATAR_COLOR_OPTIONS} from '@libs/Avatars/UserAvatarCatalog';
import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter';
import type {AvatarSizeName} from '@styles/utils';
diff --git a/src/libs/Avatars/AgentAvatarCatalog.ts b/src/libs/Avatars/AgentAvatarCatalog.ts
new file mode 100644
index 000000000000..882303c9aff8
--- /dev/null
+++ b/src/libs/Avatars/AgentAvatarCatalog.ts
@@ -0,0 +1,26 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow} from '@components/Icon/DefaultBotAvatars';
+import CONST from '@src/CONST';
+import {createAvatarCatalog} from './AvatarCatalog';
+import type {AvatarEntry} from './AvatarCatalog';
+
+type AgentAvatarID = 'bot-avatar--blue' | 'bot-avatar--green' | 'bot-avatar--ice' | 'bot-avatar--pink' | 'bot-avatar--tangerine' | 'bot-avatar--yellow';
+
+const CDN_BOT_AVATARS = `${CONST.CLOUDFRONT_URL}/images/avatars/bots`;
+
+const AGENT_AVATAR_ENTRIES: Record = {
+ 'bot-avatar--blue': {local: BotAvatarBlue, url: `${CDN_BOT_AVATARS}/bot-avatar--blue.png`},
+ 'bot-avatar--green': {local: BotAvatarGreen, url: `${CDN_BOT_AVATARS}/bot-avatar--green.png`},
+ 'bot-avatar--ice': {local: BotAvatarIce, url: `${CDN_BOT_AVATARS}/bot-avatar--ice.png`},
+ 'bot-avatar--pink': {local: BotAvatarPink, url: `${CDN_BOT_AVATARS}/bot-avatar--pink.png`},
+ 'bot-avatar--tangerine': {local: BotAvatarTangerine, url: `${CDN_BOT_AVATARS}/bot-avatar--tangerine.png`},
+ 'bot-avatar--yellow': {local: BotAvatarYellow, url: `${CDN_BOT_AVATARS}/bot-avatar--yellow.png`},
+};
+
+const AGENT_AVATARS = createAvatarCatalog(
+ AGENT_AVATAR_ENTRIES,
+ (Object.keys(AGENT_AVATAR_ENTRIES) as AgentAvatarID[]).map((id) => ({id, ...AGENT_AVATAR_ENTRIES[id]})),
+);
+
+export {AGENT_AVATARS};
+export type {AgentAvatarID};
diff --git a/src/libs/Avatars/AvatarCatalog.ts b/src/libs/Avatars/AvatarCatalog.ts
new file mode 100644
index 000000000000..76df58fbb189
--- /dev/null
+++ b/src/libs/Avatars/AvatarCatalog.ts
@@ -0,0 +1,45 @@
+import type {SvgProps} from 'react-native-svg';
+
+type AvatarEntry = {local: React.FC; url: string};
+
+// Base shape for cross-catalog iteration. Specific catalogs widen `string` to their own ID literal union.
+type AvatarCatalogBase = {
+ entries: Record;
+ ordered: ReadonlyArray<{id: string} & AvatarEntry>;
+ getLocal: (id: string) => React.FC | undefined;
+ getURL: (id: string) => string | undefined;
+ isAvatarID: (value: unknown) => boolean;
+ resolveURI: (id: string) => string;
+ getNameFromURL: (url: string | undefined) => string | undefined;
+};
+
+type AvatarCatalog = {
+ entries: Record;
+ ordered: Array<{id: ID} & AvatarEntry>;
+ getLocal: (id: ID) => React.FC | undefined;
+ getURL: (id: ID) => string | undefined;
+ isAvatarID: (value: unknown) => value is ID;
+ resolveURI: (id: string) => string;
+ getNameFromURL: (url: string | undefined) => ID | undefined;
+};
+
+function createAvatarCatalog(entries: Record, ordered: Array<{id: ID} & AvatarEntry>): AvatarCatalog {
+ return {
+ entries,
+ ordered,
+ getLocal: (id) => entries[id]?.local,
+ getURL: (id) => entries[id]?.url,
+ isAvatarID: (value): value is ID => typeof value === 'string' && value in entries,
+ resolveURI: (id) => (id in entries ? entries[id as ID].url : id),
+ getNameFromURL: (url) => {
+ if (!url || typeof url !== 'string') {
+ return undefined;
+ }
+ const name = (url.split('/').at(-1)?.split('.').at(0) ?? '') as ID;
+ return name in entries ? name : undefined;
+ },
+ };
+}
+
+export {createAvatarCatalog};
+export type {AvatarEntry, AvatarCatalog, AvatarCatalogBase};
diff --git a/src/libs/Avatars/AvatarLookup.ts b/src/libs/Avatars/AvatarLookup.ts
new file mode 100644
index 000000000000..6ebaebc126e6
--- /dev/null
+++ b/src/libs/Avatars/AvatarLookup.ts
@@ -0,0 +1,46 @@
+import type {SvgProps} from 'react-native-svg';
+import {AGENT_AVATARS} from './AgentAvatarCatalog';
+import type {AvatarCatalogBase} from './AvatarCatalog';
+import {USER_AVATARS} from './UserAvatarCatalog';
+
+// Catalogs whose entries have CDN-backed URLs (i.e. not letter avatars).
+// Keep this list in sync when adding a new catalog with `url` entries.
+// Narrow catalogs are widened to AvatarCatalogBase for cross-catalog iteration.
+const CATALOGS_WITH_CDN_URLS = [USER_AVATARS, AGENT_AVATARS] as unknown as AvatarCatalogBase[];
+
+function findLocalAvatarForURL(source: unknown): React.FC | undefined {
+ if (typeof source !== 'string') {
+ return undefined;
+ }
+ for (const catalog of CATALOGS_WITH_CDN_URLS) {
+ const name = catalog.getNameFromURL(source);
+ if (name) {
+ return catalog.getLocal(name);
+ }
+ }
+ return undefined;
+}
+
+function findAvatarIDFromURL(source: unknown): string | undefined {
+ if (typeof source !== 'string') {
+ return undefined;
+ }
+ for (const catalog of CATALOGS_WITH_CDN_URLS) {
+ const name = catalog.getNameFromURL(source);
+ if (name) {
+ return name;
+ }
+ }
+ return undefined;
+}
+
+function findCatalogURLForID(id: string): string | undefined {
+ for (const catalog of CATALOGS_WITH_CDN_URLS) {
+ if (catalog.isAvatarID(id)) {
+ return catalog.getURL(id);
+ }
+ }
+ return undefined;
+}
+
+export {findLocalAvatarForURL, findAvatarIDFromURL, findCatalogURLForID};
diff --git a/src/libs/Avatars/PresetAvatarCatalog.ts b/src/libs/Avatars/UserAvatarCatalog.ts
similarity index 83%
rename from src/libs/Avatars/PresetAvatarCatalog.ts
rename to src/libs/Avatars/UserAvatarCatalog.ts
index 390ab4d7033e..c833b680a622 100644
--- a/src/libs/Avatars/PresetAvatarCatalog.ts
+++ b/src/libs/Avatars/UserAvatarCatalog.ts
@@ -2,16 +2,16 @@
import type {SvgProps} from 'react-native-svg';
import * as SeasonF1 from '@components/Icon/CustomAvatars/SeasonF1';
import * as DefaultAvatars from '@components/Icon/DefaultAvatars';
-import {BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow} from '@components/Icon/DefaultBotAvatars';
import * as LetterDefaultAvatars from '@components/Icon/WorkspaceDefaultAvatars';
import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter';
import colors from '@styles/theme/colors';
import CONST from '@src/CONST';
-import type {AvatarEntry, BotAvatarIDs, DefaultAvatarIDs, LetterAvatarColorStyle, LetterAvatarIDs, PresetAvatarID, SeasonF1AvatarIDs} from './PresetAvatarCatalog.types';
+import {createAvatarCatalog} from './AvatarCatalog';
+import type {AvatarEntry} from './AvatarCatalog';
+import type {DefaultAvatarIDs, LetterAvatarColorStyle, LetterAvatarIDs, SeasonF1AvatarIDs, UserAvatarID} from './UserAvatarCatalog.types';
const CDN_DEFAULT_AVATARS = `${CONST.CLOUDFRONT_URL}/images/avatars`;
const CDN_SEASON_F1 = `${CONST.CLOUDFRONT_URL}/images/avatars/custom-avatars/season-f1`;
-const CDN_BOT_AVATARS = `${CONST.CLOUDFRONT_URL}/images/avatars/bots`;
const DEFAULT_AVATAR_PREFIX = `default-avatar`;
@@ -90,15 +90,6 @@ const SEASON_F1: Record = {
'wrenches-pink600': {local: SeasonF1.WrenchesPink600, url: `${CDN_SEASON_F1}/wrenches-pink600.png`},
};
-const BOT_AVATARS: Record = {
- 'bot-avatar--blue': {local: BotAvatarBlue, url: `${CDN_BOT_AVATARS}/bot-avatar--blue.png`},
- 'bot-avatar--green': {local: BotAvatarGreen, url: `${CDN_BOT_AVATARS}/bot-avatar--green.png`},
- 'bot-avatar--ice': {local: BotAvatarIce, url: `${CDN_BOT_AVATARS}/bot-avatar--ice.png`},
- 'bot-avatar--pink': {local: BotAvatarPink, url: `${CDN_BOT_AVATARS}/bot-avatar--pink.png`},
- 'bot-avatar--tangerine': {local: BotAvatarTangerine, url: `${CDN_BOT_AVATARS}/bot-avatar--tangerine.png`},
- 'bot-avatar--yellow': {local: BotAvatarYellow, url: `${CDN_BOT_AVATARS}/bot-avatar--yellow.png`},
-};
-
const LETTER_DEFAULTS: Record> = {
'letter-default-avatar_0': {local: LetterDefaultAvatars.Workspace0},
'letter-default-avatar_1': {local: LetterDefaultAvatars.Workspace1},
@@ -187,26 +178,27 @@ const DISPLAY_ORDER = [
'speedometer-ice400',
'stopwatch-ice600',
'default-avatar_24',
-] as const satisfies readonly PresetAvatarID[];
+] as const satisfies readonly UserAvatarID[];
-const PRESET_AVATAR_CATALOG: Record = {
+const USER_AVATAR_ENTRIES: Record = {
...DEFAULTS,
...SEASON_F1,
- ...BOT_AVATARS,
};
-const buildOrderedAvatars = (): Array<{id: PresetAvatarID} & AvatarEntry> => {
- const allIDS = Object.keys(PRESET_AVATAR_CATALOG) as PresetAvatarID[];
- const explicit = DISPLAY_ORDER.filter((id) => id in PRESET_AVATAR_CATALOG);
- const explicitSet = new Set(explicit);
+const buildOrderedAvatars = (): Array<{id: UserAvatarID} & AvatarEntry> => {
+ const allIDS = Object.keys(USER_AVATAR_ENTRIES) as UserAvatarID[];
+ const explicit = DISPLAY_ORDER.filter((id) => id in USER_AVATAR_ENTRIES);
+ const explicitSet = new Set(explicit);
const leftovers = allIDS.filter((id) => !explicitSet.has(id)).sort();
const finalIDOrder = [...explicit, ...leftovers];
return finalIDOrder.map((id) => ({
id,
- ...PRESET_AVATAR_CATALOG[id],
+ ...USER_AVATAR_ENTRIES[id],
}));
};
+const USER_AVATARS = createAvatarCatalog(USER_AVATAR_ENTRIES, buildOrderedAvatars());
+
/**
* Returns a letter avatar component based on the first letter of the provided name.
* @param name - The name to extract first letter/character from. (Expected 0-9, A-Z)
@@ -226,33 +218,4 @@ function getLetterAvatar(name?: string): React.FC | null {
return LETTER_DEFAULTS[workspaceKey].local;
}
-const PRESET_AVATAR_CATALOG_ORDERED = buildOrderedAvatars();
-
-const getAvatarLocal = (id: PresetAvatarID) => PRESET_AVATAR_CATALOG[id]?.local;
-const getAvatarURL = (id: PresetAvatarID) => PRESET_AVATAR_CATALOG[id]?.url;
-
-/**
- * Type guard to check if a value is a valid PresetAvatarID
- * @param value - The value to check
- * @returns True if the value is a valid PresetAvatarID
- */
-function isPresetAvatarID(value: unknown): value is PresetAvatarID {
- return typeof value === 'string' && value in PRESET_AVATAR_CATALOG;
-}
-
-function resolveAvatarURI(id: string): string {
- return isPresetAvatarID(id) ? (getAvatarURL(id) ?? id) : id;
-}
-
-export {
- PRESET_AVATAR_CATALOG,
- PRESET_AVATAR_CATALOG_ORDERED,
- LETTER_AVATAR_COLOR_OPTIONS,
- LETTER_DEFAULTS,
- DEFAULT_AVATAR_PREFIX,
- getAvatarLocal,
- getAvatarURL,
- getLetterAvatar,
- isPresetAvatarID,
- resolveAvatarURI,
-};
+export {USER_AVATARS, LETTER_AVATAR_COLOR_OPTIONS, LETTER_DEFAULTS, DEFAULT_AVATAR_PREFIX, getLetterAvatar};
diff --git a/src/libs/Avatars/PresetAvatarCatalog.types.ts b/src/libs/Avatars/UserAvatarCatalog.types.ts
similarity index 84%
rename from src/libs/Avatars/PresetAvatarCatalog.types.ts
rename to src/libs/Avatars/UserAvatarCatalog.types.ts
index a1ce60d128da..275139480fd6 100644
--- a/src/libs/Avatars/PresetAvatarCatalog.types.ts
+++ b/src/libs/Avatars/UserAvatarCatalog.types.ts
@@ -1,5 +1,3 @@
-import type {SvgProps} from 'react-native-svg';
-
type DefaultAvatarIDs =
| 'default-avatar_1'
| 'default-avatar_2'
@@ -90,10 +88,7 @@ type LetterAvatarIDs =
| 'letter-default-avatar_y'
| 'letter-default-avatar_z';
-type BotAvatarIDs = 'bot-avatar--blue' | 'bot-avatar--green' | 'bot-avatar--ice' | 'bot-avatar--pink' | 'bot-avatar--tangerine' | 'bot-avatar--yellow';
-
type LetterAvatarColorStyle = {backgroundColor: string; fillColor: string};
-type AvatarEntry = {local: React.FC; url: string};
-type PresetAvatarID = DefaultAvatarIDs | SeasonF1AvatarIDs | BotAvatarIDs;
+type UserAvatarID = DefaultAvatarIDs | SeasonF1AvatarIDs;
-export type {DefaultAvatarIDs, SeasonF1AvatarIDs, BotAvatarIDs, LetterAvatarIDs, PresetAvatarID, LetterAvatarColorStyle, AvatarEntry};
+export type {DefaultAvatarIDs, SeasonF1AvatarIDs, LetterAvatarIDs, UserAvatarID, LetterAvatarColorStyle};
diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts
index 84802d0322b5..835eb4e4ee77 100644
--- a/src/libs/UserAvatarUtils.ts
+++ b/src/libs/UserAvatarUtils.ts
@@ -1,8 +1,9 @@
import {md5} from 'expensify-common';
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 type {DefaultAvatarIDs, PresetAvatarID} from './Avatars/PresetAvatarCatalog.types';
+import {findAvatarIDFromURL, findCatalogURLForID, findLocalAvatarForURL} from './Avatars/AvatarLookup';
+import {DEFAULT_AVATAR_PREFIX, USER_AVATARS} from './Avatars/UserAvatarCatalog';
+import type {DefaultAvatarIDs} from './Avatars/UserAvatarCatalog.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;
@@ -12,15 +13,15 @@ const DEFAULT_AVATAR_URL_PATTERNS = ['images/avatars/avatar_', 'images/avatars/d
const LETTER_AVATAR_NAME_REGEX = /^letter-avatar-#[0-9A-F]{6}-#[0-9A-F]{6}-[A-Z]\.png$/;
/**
- * User avatars naming convention
+ * Avatar naming convention
*
- * Default Avatar - avatar auto generated based on users accountID or email. Default Avatars are a subset of Preset Avatars
- * Preset Avatar - pre-designed avatar from PresetAvatarCatalog.PRESET_AVATAR_CATALOG (includes default avatars & themed avatars like Season F1)
- * Letter Avatar - avatar with users' displayName first letter and color from PresetAvatarCatalog.LETTER_AVATAR_COLOR_OPTIONS
- * Uploaded Avatar - avatar uploaded by user
+ * Default Avatar - auto generated from accountID or email. A subset of User Avatars.
+ * User Avatar - pre-designed avatar from UserAvatarCatalog.USER_AVATARS (defaults + Season F1).
+ * Agent Avatar - bot avatar from AgentAvatarCatalog.AGENT_AVATARS, assigned to agent accounts.
+ * Letter Avatar - first-letter avatar with color from UserAvatarCatalog.LETTER_AVATAR_COLOR_OPTIONS.
+ * Uploaded Avatar - user-uploaded image.
*
- * When dealing with Default & Preset avatars we want to serve them as local SVGs for better user experience.
- * In that case `AvatarSource` will be an SVG, otherwise it's an url (string).
+ * Catalog-backed avatars (User + Agent) resolve to local SVGs at render time via AvatarLookup.findLocalAvatarForURL.
*/
type CommonAvatarArgsType = {
@@ -93,12 +94,12 @@ function getDefaultAvatar({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail, av
return defaultAvatars.NotificationsAvatar;
}
- return avatarCatalogGetAvatarLocal(getDefaultAvatarName({accountID, accountEmail, avatarURL}));
+ return USER_AVATARS.getLocal(getDefaultAvatarName({accountID, accountEmail, avatarURL}));
}
/**
- * Returns the custom avatar name (e.g., "default-avatar_5") associated with an account.
- * This name corresponds to assets in the PresetAvatarCatalog.
+ * Returns the user avatar name (e.g., "default-avatar_5") associated with an account.
+ * This name corresponds to assets in the UserAvatarCatalog.
*
* @param args - Object containing avatar parameters
* @param args.accountID - The user's account ID
@@ -127,26 +128,20 @@ function getDefaultAvatarURL({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail,
return CONST.CONCIERGE_ICON_URL;
}
- return avatarCatalogGetAvatarURL(getDefaultAvatarName({accountID, accountEmail, avatarURL}));
+ return USER_AVATARS.getURL(getDefaultAvatarName({accountID, accountEmail, avatarURL})) ?? '';
}
/**
- * Extracts the custom avatar name from a CloudFront avatar URL.
- * Useful for identifying which default avatar a URL points to.
+ * Extracts the catalog avatar name (user or agent) from a CloudFront avatar URL.
*
* @param avatarURL - The avatar URL
- * @returns The avatar name (e.g., 'default-avatar_5') or undefined if not a valid custom avatar URL
+ * @returns The avatar name (e.g., 'default-avatar_5', 'bot-avatar--blue') or undefined if not a valid catalog URL
*/
-function getPresetAvatarNameFromURL(avatarURL?: AvatarSource): PresetAvatarID | undefined {
+function getCatalogAvatarNameFromURL(avatarURL?: AvatarSource): string | undefined {
if (!avatarURL || typeof avatarURL !== 'string' || avatarURL === CONST.CONCIERGE_ICON_URL) {
return undefined;
}
-
- // Extract avatar name from CloudFront URL and make sure it's one of defaults
- const match = (avatarURL.split('/').at(-1)?.split('.')?.[0] ?? '') as PresetAvatarID;
- if (PRESET_AVATAR_CATALOG[match]) {
- return match;
- }
+ return findAvatarIDFromURL(avatarURL);
}
/**
@@ -173,14 +168,13 @@ function isDefaultAvatar(avatarSource?: AvatarSource): avatarSource is string |
}
/**
- * Determines if an avatar source is a custom avatar from the PresetAvatarCatalog.
- * Custom avatars are a specific set of default avatars that can be identified by their URL.
+ * Determines if an avatar source is a catalog-backed avatar (user or agent).
*
* @param avatarSource - The avatar source to check
- * @returns True if the avatar is a custom avatar from the catalog
+ * @returns True if the avatar URL maps to a known catalog entry
*/
-function isPresetAvatar(avatarSource?: AvatarSource): avatarSource is string {
- return !!getPresetAvatarNameFromURL(avatarSource);
+function isCatalogAvatar(avatarSource?: AvatarSource): avatarSource is string {
+ return !!getCatalogAvatarNameFromURL(avatarSource);
}
/**
@@ -220,9 +214,9 @@ function getAvatar({avatarSource, accountID = CONST.DEFAULT_NUMBER_ID, accountEm
return getDefaultAvatar({accountID, accountEmail, avatarURL: avatarSource, defaultAvatars});
}
- const maybePresetAvatarName = getPresetAvatarNameFromURL(avatarSource);
- if (maybePresetAvatarName) {
- return avatarCatalogGetAvatarLocal(maybePresetAvatarName);
+ const localFromCatalog = findLocalAvatarForURL(avatarSource);
+ if (localFromCatalog) {
+ return localFromCatalog;
}
return avatarSource;
@@ -243,9 +237,9 @@ function getAvatarURL({accountID = CONST.DEFAULT_NUMBER_ID, avatarSource, accoun
if (isDefaultAvatar(avatarSource)) {
return getDefaultAvatarURL({accountID, accountEmail, avatarURL: avatarSource});
}
- const maybePresetAvatarName = getPresetAvatarNameFromURL(avatarSource);
- if (maybePresetAvatarName) {
- return avatarCatalogGetAvatarURL(maybePresetAvatarName);
+ const idFromCatalog = findAvatarIDFromURL(avatarSource);
+ if (idFromCatalog) {
+ return findCatalogURLForID(idFromCatalog);
}
return avatarSource;
}
@@ -304,10 +298,10 @@ export {
getDefaultAvatar,
getDefaultAvatarName,
getDefaultAvatarURL,
- getPresetAvatarNameFromURL,
+ getCatalogAvatarNameFromURL,
getFullSizeAvatar,
getSmallSizeAvatar,
- isPresetAvatar,
+ isCatalogAvatar,
isDefaultAvatar,
isLetterAvatar,
};
diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts
index 23f58d159ea8..e9a7ef3ec4c7 100644
--- a/src/libs/actions/Agent.ts
+++ b/src/libs/actions/Agent.ts
@@ -1,7 +1,7 @@
import Onyx from 'react-native-onyx';
import {read, write} from '@libs/API';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
-import {resolveAvatarURI} from '@libs/Avatars/PresetAvatarCatalog';
+import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -24,7 +24,7 @@ function createAgent(firstName: string | undefined, prompt: string, customExpens
let avatarURI: string | undefined;
if (customExpensifyAvatarID) {
- avatarURI = resolveAvatarURI(customExpensifyAvatarID);
+ avatarURI = AGENT_AVATARS.resolveURI(customExpensifyAvatarID);
} else {
avatarURI = optimisticAvatarURI;
}
diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx
index 2b8b0ae52f3e..074e7bcc136b 100644
--- a/src/pages/settings/Agents/AddAgentPage.tsx
+++ b/src/pages/settings/Agents/AddAgentPage.tsx
@@ -6,8 +6,6 @@ import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import type {BotAvatar} from '@components/Icon/DefaultBotAvatars';
-import {botAvatarIDs, botAvatars} from '@components/Icon/DefaultBotAvatars';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
@@ -16,6 +14,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
+import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog';
import {isMobile} from '@libs/Browser';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import Navigation from '@libs/Navigation/Navigation';
@@ -38,7 +38,8 @@ function AddAgentPage() {
const defaultPrompt = translate('addAgentPage.defaultPrompt');
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Pencil']);
const avatarStyle = [styles.avatarXLarge, styles.alignSelfCenter];
- const [avatarSource, setAvatarSource] = useState(() => botAvatars[Math.floor(Math.random() * botAvatars.length)]);
+ const [selectedPresetID, setSelectedPresetID] = useState(() => AGENT_AVATARS.ordered.at(Math.floor(Math.random() * AGENT_AVATARS.ordered.length))?.id ?? null);
+ const [uploadedURI, setUploadedURI] = useState(null);
const pendingFileRef = useRef<{file: File | CustomRNImageManipulatorResult; uri: string} | null>(null);
useFocusEffect(
@@ -49,22 +50,22 @@ function AddAgentPage() {
}
clearPendingAvatar();
- if (pending.type === 'preset') {
- const matchingAvatar = botAvatars.find((av) => botAvatarIDs.get(av) === pending.id);
- if (matchingAvatar) {
- setAvatarSource(() => matchingAvatar);
- }
+ if (pending.type === 'preset' && AGENT_AVATARS.isAvatarID(pending.id)) {
+ setSelectedPresetID(pending.id);
+ setUploadedURI(null);
pendingFileRef.current = null;
- } else {
- setAvatarSource(pending.uri);
+ } else if (pending.type !== 'preset') {
+ setSelectedPresetID(null);
+ setUploadedURI(pending.uri);
pendingFileRef.current = {file: pending.file, uri: pending.uri};
}
}, []),
);
+ const avatarSource: AvatarSource = selectedPresetID ? (AGENT_AVATARS.getLocal(selectedPresetID) ?? '') : (uploadedURI ?? '');
+
const handleAvatarPress = () => {
- const presetID = botAvatarIDs.get(avatarSource as BotAvatar);
- setInitialPresetID(presetID);
+ setInitialPresetID(selectedPresetID ?? undefined);
setNavigationToken();
Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD_AVATAR);
};
@@ -85,7 +86,7 @@ function AddAgentPage() {
if (pendingFile) {
createAgent(firstName, prompt, undefined, pendingFile.file, pendingFile.uri);
} else {
- createAgent(firstName, prompt, botAvatarIDs.get(avatarSource as BotAvatar));
+ createAgent(firstName, prompt, selectedPresetID ?? undefined);
}
Navigation.goBack();
diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx
index c2c112163905..e78f1d0d2645 100644
--- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx
+++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx
@@ -7,8 +7,6 @@ import Button from '@components/Button';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import type {BotAvatar} from '@components/Icon/DefaultBotAvatars';
-import {botAvatarIDs, botAvatars} from '@components/Icon/DefaultBotAvatars';
import {PressableWithFeedback} from '@components/Pressable';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
@@ -18,7 +16,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
-import {resolveAvatarURI} from '@libs/Avatars/PresetAvatarCatalog';
+import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
+import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog';
import {validateAvatarImage} from '@libs/AvatarUtils';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import Navigation from '@libs/Navigation/Navigation';
@@ -45,7 +44,7 @@ type ImageData = {
const EMPTY_IMAGE_DATA: ImageData = {uri: '', name: '', type: '', file: null};
-type OnSaveParams = {file: File | CustomRNImageManipulatorResult; uri: string} | {customExpensifyAvatarID: string};
+type OnSaveParams = {file: File | CustomRNImageManipulatorResult; uri: string} | {customExpensifyAvatarID: AgentAvatarID};
type EditAgentAvatarContentProps = {
accountID: number;
@@ -61,14 +60,14 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]});
- const initialBotAvatar = useMemo(() => {
- if (!initialPresetID) {
+ const initialBotAvatar = useMemo(() => {
+ if (!initialPresetID || !AGENT_AVATARS.isAvatarID(initialPresetID)) {
return null;
}
- return botAvatars.find((av) => botAvatarIDs.get(av) === initialPresetID) ?? null;
+ return initialPresetID;
}, [initialPresetID]);
- const [selectedBotAvatar, setSelectedBotAvatar] = useState(() => initialBotAvatar);
+ const [selectedBotAvatar, setSelectedBotAvatar] = useState(() => initialBotAvatar);
const [imageData, setImageData] = useState(EMPTY_IMAGE_DATA);
const [cropImageData, setCropImageData] = useState(EMPTY_IMAGE_DATA);
const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
@@ -83,7 +82,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset
let previewSource: AvatarSource = personalDetails?.avatar ?? '';
if (selectedBotAvatar) {
- previewSource = selectedBotAvatar;
+ previewSource = AGENT_AVATARS.getLocal(selectedBotAvatar) ?? '';
} else if (imageData.uri) {
previewSource = imageData.uri;
}
@@ -136,16 +135,14 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset
}
if (selectedBotAvatar) {
- const customExpensifyAvatarID = botAvatarIDs.get(selectedBotAvatar);
- if (customExpensifyAvatarID) {
- const uri = resolveAvatarURI(customExpensifyAvatarID);
- if (onSave) {
- onSave({customExpensifyAvatarID});
- return;
- }
- updateAgentAvatar(accountID, {customExpensifyAvatarID, uri}, personalDetails?.avatar);
- Navigation.goBack(fallbackRoute);
+ const customExpensifyAvatarID = selectedBotAvatar;
+ const uri = AGENT_AVATARS.resolveURI(customExpensifyAvatarID);
+ if (onSave) {
+ onSave({customExpensifyAvatarID});
+ return;
}
+ updateAgentAvatar(accountID, {customExpensifyAvatarID, uri}, personalDetails?.avatar);
+ Navigation.goBack(fallbackRoute);
}
};
@@ -195,25 +192,24 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset
{translate('avatarPage.choosePresetAvatar')}
- {botAvatars.map((botAvatar) => {
- const botAvatarID = botAvatarIDs.get(botAvatar);
- const isSelected = selectedBotAvatar === botAvatar;
+ {AGENT_AVATARS.ordered.map(({id, local}) => {
+ const isSelected = selectedBotAvatar === id;
return (
{
- setSelectedBotAvatar(() => botAvatar);
+ setSelectedBotAvatar(() => id);
setImageData(EMPTY_IMAGE_DATA);
}}
style={[styles.avatarSelectorWrapper, isSelected && styles.avatarSelected]}
>
diff --git a/src/pages/settings/Profile/Avatar/AvatarPage.tsx b/src/pages/settings/Profile/Avatar/AvatarPage.tsx
index 72a5ec141afd..aa63960a39d3 100644
--- a/src/pages/settings/Profile/Avatar/AvatarPage.tsx
+++ b/src/pages/settings/Profile/Avatar/AvatarPage.tsx
@@ -13,7 +13,8 @@ import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import {getAvatarURL, isPresetAvatarID, resolveAvatarURI} from '@libs/Avatars/PresetAvatarCatalog';
+import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import Navigation from '@libs/Navigation/Navigation';
import {useIsAgentAccount} from '@libs/SessionUtils';
@@ -82,10 +83,10 @@ function ProfileAvatar() {
return;
}
- if (selected && isPresetAvatarID(selected)) {
+ if (selected && USER_AVATARS.isAvatarID(selected)) {
updateAvatar(
{
- uri: getAvatarURL(selected),
+ uri: USER_AVATARS.getURL(selected) ?? '',
name: selected,
customExpensifyAvatarID: selected,
},
@@ -130,7 +131,7 @@ function ProfileAvatar() {
});
} else {
const {customExpensifyAvatarID} = params;
- const uri = resolveAvatarURI(customExpensifyAvatarID);
+ const uri = AGENT_AVATARS.resolveURI(customExpensifyAvatarID);
updateAvatar(
{
uri,
diff --git a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx
index 7b5c374ad5a5..98653dd09418 100644
--- a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx
+++ b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx
@@ -10,11 +10,11 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLetterAvatars from '@hooks/useLetterAvatars';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import {getAvatarLocal, isPresetAvatarID} from '@libs/Avatars/PresetAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
import {validateAvatarImage} from '@libs/AvatarUtils';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import type {AvatarSource} from '@libs/UserAvatarUtils';
-import {getDefaultAvatarName, isLetterAvatar, isPresetAvatar} from '@libs/UserAvatarUtils';
+import {getDefaultAvatarName, isCatalogAvatar, isLetterAvatar} from '@libs/UserAvatarUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {FileObject} from '@src/types/utils/Attachment';
@@ -64,8 +64,8 @@ function AvatarPreview({selected, avatarCaptureRef, setSelected, isAvatarCropMod
const accountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID;
let avatarURL: AvatarSource = '';
- if (selected && isPresetAvatarID(selected)) {
- avatarURL = getAvatarLocal(selected);
+ if (selected && USER_AVATARS.isAvatarID(selected)) {
+ avatarURL = USER_AVATARS.getLocal(selected) ?? '';
} else if (selected) {
avatarURL = avatars[selected];
} else if (imageData.uri) {
@@ -74,7 +74,7 @@ function AvatarPreview({selected, avatarCaptureRef, setSelected, isAvatarCropMod
avatarURL = currentUserPersonalDetails?.avatar ?? '';
}
// Weather avatar view & edit options should be hidden. False if user uploaded their own avatar.
- const shouldHideAvatarEdit = (!imageData.uri && (isPresetAvatar(currentUserPersonalDetails?.avatar) || isLetterAvatar(currentUserPersonalDetails?.originalFileName))) || !!selected;
+ const shouldHideAvatarEdit = (!imageData.uri && (isCatalogAvatar(currentUserPersonalDetails?.avatar) || isLetterAvatar(currentUserPersonalDetails?.originalFileName))) || !!selected;
/**
* Validates an image and opens avatar crop modal if valid
diff --git a/src/stories/Avatar.stories.tsx b/src/stories/Avatar.stories.tsx
index dd04c647b792..8fd17a69ec00 100644
--- a/src/stories/Avatar.stories.tsx
+++ b/src/stories/Avatar.stories.tsx
@@ -4,10 +4,10 @@ import {View} from 'react-native';
import type {AvatarProps} from '@components/Avatar';
import Avatar from '@components/Avatar';
import {getExpensifyIcon} from '@components/Icon/chunks/expensify-icons.chunk';
-import {PRESET_AVATAR_CATALOG} from '@libs/Avatars/PresetAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
import CONST from '@src/CONST';
-const AVATAR_URL = PRESET_AVATAR_CATALOG['car-blue100'].url;
+const AVATAR_URL = USER_AVATARS.entries['car-blue100'].url;
type AvatarStory = StoryFn;
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 322377895fef..9ffd0758cd58 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -5,7 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {EdgeInsets} from 'react-native-safe-area-context';
import type {ValueOf} from 'type-fest';
import type ImageSVGProps from '@components/ImageSVG/types';
-import {LETTER_AVATAR_COLOR_OPTIONS} from '@libs/Avatars/PresetAvatarCatalog';
+import {LETTER_AVATAR_COLOR_OPTIONS} from '@libs/Avatars/UserAvatarCatalog';
import {isMobile, isMobileChrome} from '@libs/Browser';
import getPlatform from '@libs/getPlatform';
import {hashText} from '@libs/UserUtils';
diff --git a/tests/ui/AvatarSelector.test.tsx b/tests/ui/AvatarSelector.test.tsx
index 62a2ae66155b..6026d74ea1a8 100644
--- a/tests/ui/AvatarSelector.test.tsx
+++ b/tests/ui/AvatarSelector.test.tsx
@@ -5,7 +5,7 @@ import AvatarSelector from '@components/AvatarSelector';
import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
-import {PRESET_AVATAR_CATALOG} from '@libs/Avatars/PresetAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter';
import ONYXKEYS from '@src/ONYXKEYS';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
@@ -76,24 +76,31 @@ describe('AvatarSelector', () => {
});
});
- describe('PRESET_AVATAR_CATALOG_ORDERED avatars', () => {
- it('renders all avatars from custom catalog', async () => {
+ describe('USER_AVATARS grid', () => {
+ it('renders all user catalog avatars', async () => {
renderAvatarSelector();
await waitForBatchedUpdates();
- // Check that all custom avatars are rendered
- const avatars = Object.keys(PRESET_AVATAR_CATALOG);
- for (const id of avatars) {
+ for (const {id} of USER_AVATARS.ordered) {
expect(screen.getByTestId(`AvatarSelector_${id}`)).toBeOnTheScreen();
}
});
+ it('does not render agent (bot) avatars', async () => {
+ renderAvatarSelector();
+ await waitForBatchedUpdates();
+
+ const agentAvatarIds = ['bot-avatar--blue', 'bot-avatar--green', 'bot-avatar--ice', 'bot-avatar--pink', 'bot-avatar--tangerine', 'bot-avatar--yellow'];
+ for (const id of agentAvatarIds) {
+ expect(screen.queryByTestId(`AvatarSelector_${id}`)).not.toBeOnTheScreen();
+ }
+ });
+
it('calls onSelect when custom avatar is pressed', async () => {
renderAvatarSelector();
await waitForBatchedUpdates();
- const avatars = Object.keys(PRESET_AVATAR_CATALOG);
- const firstAvatarId = avatars.at(0);
+ const firstAvatarId = USER_AVATARS.ordered.at(0)?.id;
const firstAvatar = screen.getByTestId(`AvatarSelector_${firstAvatarId}`);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain
@@ -105,8 +112,7 @@ describe('AvatarSelector', () => {
});
it('shows selected custom avatar with border styling', async () => {
- const avatars = Object.keys(PRESET_AVATAR_CATALOG);
- const selectedId = avatars.at(1) as keyof typeof PRESET_AVATAR_CATALOG;
+ const selectedId = USER_AVATARS.ordered.at(1)?.id;
renderAvatarSelector({selectedID: selectedId});
await waitForBatchedUpdates();
@@ -187,8 +193,8 @@ describe('AvatarSelector', () => {
renderAvatarSelector({name: mockName});
await waitForBatchedUpdates();
- const presetAvatars = Object.keys(PRESET_AVATAR_CATALOG);
- expect(screen.getByTestId(`AvatarSelector_${presetAvatars.at(0)}`)).toBeOnTheScreen();
+ const firstUserAvatarId = USER_AVATARS.ordered.at(0)?.id;
+ expect(screen.getByTestId(`AvatarSelector_${firstUserAvatarId}`)).toBeOnTheScreen();
const allAvatars = screen.queryAllByTestId(/^AvatarSelector_/);
const letterAvatars = allAvatars.filter((node) => (node.props.testID as string)?.includes('letter-avatar'));
diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts
index 3eb29edb20c0..9381a3790d85 100644
--- a/tests/unit/UserAvatarUtilsTest.ts
+++ b/tests/unit/UserAvatarUtilsTest.ts
@@ -145,22 +145,27 @@ describe('UserAvatarUtils', () => {
});
});
- describe('isPresetAvatar', () => {
- it('should return true for custom avatar URLs in catalog', () => {
+ describe('isCatalogAvatar', () => {
+ it('should return true for user catalog avatar URLs', () => {
const customURL = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png';
- expect(UserAvatarUtils.isPresetAvatar(customURL)).toBe(true);
+ expect(UserAvatarUtils.isCatalogAvatar(customURL)).toBe(true);
});
- it('should return false for non-custom URLs', () => {
- expect(UserAvatarUtils.isPresetAvatar('https://example.com/random.png')).toBe(false);
+ it('should return true for agent catalog avatar URLs', () => {
+ const botURL = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/bots/bot-avatar--blue.png';
+ expect(UserAvatarUtils.isCatalogAvatar(botURL)).toBe(true);
+ });
+
+ it('should return false for non-catalog URLs', () => {
+ expect(UserAvatarUtils.isCatalogAvatar('https://example.com/random.png')).toBe(false);
});
it('should return false for Concierge URL', () => {
- expect(UserAvatarUtils.isPresetAvatar(CONST.CONCIERGE_ICON_URL)).toBe(false);
+ expect(UserAvatarUtils.isCatalogAvatar(CONST.CONCIERGE_ICON_URL)).toBe(false);
});
it('should return false for undefined', () => {
- expect(UserAvatarUtils.isPresetAvatar(undefined)).toBe(false);
+ expect(UserAvatarUtils.isCatalogAvatar(undefined)).toBe(false);
});
});
@@ -239,26 +244,32 @@ describe('UserAvatarUtils', () => {
});
});
- describe('getPresetAvatarNameFromURL', () => {
- it('should extract custom avatar name from CloudFront URL', () => {
+ describe('getCatalogAvatarNameFromURL', () => {
+ it('should extract user catalog avatar name from CloudFront URL', () => {
const url = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png';
- const result = UserAvatarUtils.getPresetAvatarNameFromURL(url);
+ const result = UserAvatarUtils.getCatalogAvatarNameFromURL(url);
expect(result).toBe('default-avatar_5');
});
- it('should return undefined for non-custom avatar URLs', () => {
+ it('should extract agent catalog avatar name from CloudFront URL', () => {
+ const url = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/bots/bot-avatar--blue.png';
+ const result = UserAvatarUtils.getCatalogAvatarNameFromURL(url);
+ expect(result).toBe('bot-avatar--blue');
+ });
+
+ it('should return undefined for non-catalog avatar URLs', () => {
const url = 'https://example.com/custom-upload.png';
- const result = UserAvatarUtils.getPresetAvatarNameFromURL(url);
+ const result = UserAvatarUtils.getCatalogAvatarNameFromURL(url);
expect(result).toBeUndefined();
});
it('should return undefined for Concierge URL', () => {
- const result = UserAvatarUtils.getPresetAvatarNameFromURL(CONST.CONCIERGE_ICON_URL);
+ const result = UserAvatarUtils.getCatalogAvatarNameFromURL(CONST.CONCIERGE_ICON_URL);
expect(result).toBeUndefined();
});
it('should return undefined for undefined input', () => {
- const result = UserAvatarUtils.getPresetAvatarNameFromURL(undefined);
+ const result = UserAvatarUtils.getCatalogAvatarNameFromURL(undefined);
expect(result).toBeUndefined();
});
});
diff --git a/tests/unit/hooks/useLetterAvatars.test.tsx b/tests/unit/hooks/useLetterAvatars.test.tsx
index 36f4690c2fe5..6244adfc8d94 100644
--- a/tests/unit/hooks/useLetterAvatars.test.tsx
+++ b/tests/unit/hooks/useLetterAvatars.test.tsx
@@ -2,7 +2,7 @@ import {renderHook} from '@testing-library/react-native';
import React from 'react';
import type {SvgProps} from 'react-native-svg';
import useLetterAvatars from '@hooks/useLetterAvatars';
-import * as PresetAvatarCatalog from '@libs/Avatars/PresetAvatarCatalog';
+import * as UserAvatarCatalog from '@libs/Avatars/UserAvatarCatalog';
const mockAvatarComponent: React.FC = React.memo((props: SvgProps) =>
React.createElement('svg', {
@@ -18,7 +18,7 @@ describe('useLetterAvatars', () => {
describe('basic functionality', () => {
it('should return the expected structure', () => {
- jest.spyOn(PresetAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
+ jest.spyOn(UserAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
const {result} = renderHook(() => useLetterAvatars('John'));
@@ -32,7 +32,7 @@ describe('useLetterAvatars', () => {
});
it('should create unique IDs for each avatar variant', () => {
- jest.spyOn(PresetAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
+ jest.spyOn(UserAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
const {result} = renderHook(() => useLetterAvatars('Bob'));
@@ -45,7 +45,7 @@ describe('useLetterAvatars', () => {
});
it('should include the StyledLetterAvatar component in each item', () => {
- jest.spyOn(PresetAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
+ jest.spyOn(UserAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
const {result} = renderHook(() => useLetterAvatars('Charlie'));
@@ -63,7 +63,7 @@ describe('useLetterAvatars', () => {
['names starting with numbers', '5th Avenue', '-5'],
['single character names', 'X', '-'],
])('should handle %s', (_, name, expectedChar) => {
- jest.spyOn(PresetAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
+ jest.spyOn(UserAvatarCatalog, 'getLetterAvatar').mockReturnValue(mockAvatarComponent);
const {result} = renderHook(() => useLetterAvatars(name));
@@ -79,7 +79,7 @@ describe('useLetterAvatars', () => {
['names with only spaces', ' '],
['names with only special characters', '!@#$%'],
])('should handle %s', (_, name) => {
- jest.spyOn(PresetAvatarCatalog, 'getLetterAvatar').mockReturnValue(null);
+ jest.spyOn(UserAvatarCatalog, 'getLetterAvatar').mockReturnValue(null);
const {result} = renderHook(() => useLetterAvatars(name));
diff --git a/tests/unit/libs/Avatars/PresetAvatarCatalogTest.tsx b/tests/unit/libs/Avatars/UserAvatarCatalogTest.tsx
similarity index 53%
rename from tests/unit/libs/Avatars/PresetAvatarCatalogTest.tsx
rename to tests/unit/libs/Avatars/UserAvatarCatalogTest.tsx
index 2a2f5a88fe67..cb237c939645 100644
--- a/tests/unit/libs/Avatars/PresetAvatarCatalogTest.tsx
+++ b/tests/unit/libs/Avatars/UserAvatarCatalogTest.tsx
@@ -1,60 +1,64 @@
import {render} from '@testing-library/react-native';
import React from 'react';
import Icon from '@components/Icon';
-import {getAvatarLocal, getAvatarURL, PRESET_AVATAR_CATALOG} from '@libs/Avatars/PresetAvatarCatalog';
+import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog';
const SAMPLE_DEFAULT_ID = 'default-avatar_1';
const SAMPLE_SEASON_ID = 'car-blue100';
-describe('PresetAvatarCatalog', () => {
+describe('UserAvatarCatalog', () => {
it('resolves a local component for a default avatar ID', () => {
- const AvatarComponent = getAvatarLocal(SAMPLE_DEFAULT_ID);
+ const AvatarComponent = USER_AVATARS.getLocal(SAMPLE_DEFAULT_ID);
expect(AvatarComponent).toBeDefined();
expect(typeof AvatarComponent).toBe('function');
});
it('resolves a CDN URL for a default avatar ID', () => {
- const avatarUrl = getAvatarURL(SAMPLE_DEFAULT_ID);
+ const avatarUrl = USER_AVATARS.getURL(SAMPLE_DEFAULT_ID);
expect(avatarUrl).toContain(`/images/avatars/${SAMPLE_DEFAULT_ID}`);
});
it('renders a default avatar component', () => {
- const AvatarComponent = getAvatarLocal(SAMPLE_DEFAULT_ID);
+ const AvatarComponent = USER_AVATARS.getLocal(SAMPLE_DEFAULT_ID);
const {toJSON} = render();
expect(toJSON()).toBeTruthy();
});
it('matches snapshot for a default avatar', () => {
- const AvatarComponent = getAvatarLocal(SAMPLE_DEFAULT_ID);
+ const AvatarComponent = USER_AVATARS.getLocal(SAMPLE_DEFAULT_ID);
const {toJSON} = render();
expect(toJSON()).toMatchSnapshot();
});
it('resolves a local component for a seasonal avatar ID', () => {
- const AvatarComponent = getAvatarLocal(SAMPLE_SEASON_ID);
+ const AvatarComponent = USER_AVATARS.getLocal(SAMPLE_SEASON_ID);
expect(AvatarComponent).toBeDefined();
expect(typeof AvatarComponent).toBe('function');
});
it('resolves a CDN URL for a seasonal avatar ID', () => {
- const avatarUrl = getAvatarURL(SAMPLE_SEASON_ID);
+ const avatarUrl = USER_AVATARS.getURL(SAMPLE_SEASON_ID);
expect(avatarUrl).toContain(`/images/avatars/custom-avatars/season-f1/${SAMPLE_SEASON_ID}`);
});
it('renders a seasonal avatar component', () => {
- const AvatarComponent = getAvatarLocal(SAMPLE_SEASON_ID);
+ const AvatarComponent = USER_AVATARS.getLocal(SAMPLE_SEASON_ID);
const {toJSON} = render();
expect(toJSON()).toBeTruthy();
});
- it('throws or returns undefined for an unknown ID', () => {
- // @ts-expect-error - This is a test for an unknown ID
- expect(getAvatarLocal('not-a-real-id')).toBeUndefined();
- // @ts-expect-error - This is a test for an unknown ID
- expect(getAvatarURL('not-a-real-id')).toBeUndefined();
+ it('returns undefined for an unknown ID', () => {
+ // @ts-expect-error - testing an unknown ID
+ expect(USER_AVATARS.getLocal('not-a-real-id')).toBeUndefined();
+ // @ts-expect-error - testing an unknown ID
+ expect(USER_AVATARS.getURL('not-a-real-id')).toBeUndefined();
});
- it('ALL contains both default and seasonal IDs', () => {
- expect(Object.keys(PRESET_AVATAR_CATALOG)).toEqual(expect.arrayContaining([SAMPLE_DEFAULT_ID, SAMPLE_SEASON_ID]));
+ it('contains both default and seasonal IDs', () => {
+ expect(Object.keys(USER_AVATARS.entries)).toEqual(expect.arrayContaining([SAMPLE_DEFAULT_ID, SAMPLE_SEASON_ID]));
+ });
+
+ it('does not contain bot avatar IDs', () => {
+ expect(Object.keys(USER_AVATARS.entries)).not.toContain('bot-avatar--blue');
});
});
diff --git a/tests/unit/libs/Avatars/__snapshots__/PresetAvatarCatalogTest.tsx.snap b/tests/unit/libs/Avatars/__snapshots__/UserAvatarCatalogTest.tsx.snap
similarity index 80%
rename from tests/unit/libs/Avatars/__snapshots__/PresetAvatarCatalogTest.tsx.snap
rename to tests/unit/libs/Avatars/__snapshots__/UserAvatarCatalogTest.tsx.snap
index 955f41555bee..f4f7067d6972 100644
--- a/tests/unit/libs/Avatars/__snapshots__/PresetAvatarCatalogTest.tsx.snap
+++ b/tests/unit/libs/Avatars/__snapshots__/UserAvatarCatalogTest.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`PresetAvatarCatalog matches snapshot for a default avatar 1`] = `
+exports[`UserAvatarCatalog matches snapshot for a default avatar 1`] = `