From c3ec1a996802d7b1a9b44faf76917ba45527e774 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 28 May 2026 18:06:25 +0100 Subject: [PATCH 1/2] no bots in user presets --- src/libs/Avatars/PresetAvatarCatalog.ts | 1 - src/libs/Avatars/PresetAvatarCatalog.types.ts | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libs/Avatars/PresetAvatarCatalog.ts b/src/libs/Avatars/PresetAvatarCatalog.ts index 390ab4d7033e..abc1972e1dc7 100644 --- a/src/libs/Avatars/PresetAvatarCatalog.ts +++ b/src/libs/Avatars/PresetAvatarCatalog.ts @@ -192,7 +192,6 @@ const DISPLAY_ORDER = [ const PRESET_AVATAR_CATALOG: Record = { ...DEFAULTS, ...SEASON_F1, - ...BOT_AVATARS, }; const buildOrderedAvatars = (): Array<{id: PresetAvatarID} & AvatarEntry> => { diff --git a/src/libs/Avatars/PresetAvatarCatalog.types.ts b/src/libs/Avatars/PresetAvatarCatalog.types.ts index a1ce60d128da..d6517a191811 100644 --- a/src/libs/Avatars/PresetAvatarCatalog.types.ts +++ b/src/libs/Avatars/PresetAvatarCatalog.types.ts @@ -90,10 +90,8 @@ 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 PresetAvatarID = DefaultAvatarIDs | SeasonF1AvatarIDs; -export type {DefaultAvatarIDs, SeasonF1AvatarIDs, BotAvatarIDs, LetterAvatarIDs, PresetAvatarID, LetterAvatarColorStyle, AvatarEntry}; +export type {DefaultAvatarIDs, SeasonF1AvatarIDs, LetterAvatarIDs, PresetAvatarID, LetterAvatarColorStyle, AvatarEntry}; From 95f3f701404826777b7fb98bda01012423882a4f Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 28 May 2026 19:00:28 +0100 Subject: [PATCH 2/2] build clear catalogs --- src/components/Avatar.tsx | 10 +-- src/components/AvatarSelector.tsx | 4 +- src/components/Icon/DefaultBotAvatars.ts | 17 +---- src/hooks/useLetterAvatars.tsx | 2 +- src/libs/Avatars/AgentAvatarCatalog.ts | 26 ++++++++ src/libs/Avatars/AvatarCatalog.ts | 45 +++++++++++++ src/libs/Avatars/AvatarLookup.ts | 46 +++++++++++++ ...tAvatarCatalog.ts => UserAvatarCatalog.ts} | 62 ++++------------- ...og.types.ts => UserAvatarCatalog.types.ts} | 7 +- src/libs/UserAvatarUtils.ts | 66 +++++++++---------- src/libs/actions/Agent.ts | 4 +- src/pages/settings/Agents/AddAgentPage.tsx | 27 ++++---- .../Agents/Fields/EditAgentAvatarPage.tsx | 44 ++++++------- .../settings/Profile/Avatar/AvatarPage.tsx | 9 +-- .../settings/Profile/Avatar/AvatarPreview.tsx | 10 +-- src/stories/Avatar.stories.tsx | 4 +- src/styles/utils/index.ts | 2 +- tests/ui/AvatarSelector.test.tsx | 30 +++++---- tests/unit/UserAvatarUtilsTest.ts | 39 +++++++---- tests/unit/hooks/useLetterAvatars.test.tsx | 12 ++-- ...alogTest.tsx => UserAvatarCatalogTest.tsx} | 36 +++++----- ...sx.snap => UserAvatarCatalogTest.tsx.snap} | 2 +- 22 files changed, 290 insertions(+), 214 deletions(-) create mode 100644 src/libs/Avatars/AgentAvatarCatalog.ts create mode 100644 src/libs/Avatars/AvatarCatalog.ts create mode 100644 src/libs/Avatars/AvatarLookup.ts rename src/libs/Avatars/{PresetAvatarCatalog.ts => UserAvatarCatalog.ts} (83%) rename src/libs/Avatars/{PresetAvatarCatalog.types.ts => UserAvatarCatalog.types.ts} (91%) rename tests/unit/libs/Avatars/{PresetAvatarCatalogTest.tsx => UserAvatarCatalogTest.tsx} (53%) rename tests/unit/libs/Avatars/__snapshots__/{PresetAvatarCatalogTest.tsx.snap => UserAvatarCatalogTest.tsx.snap} (80%) 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 abc1972e1dc7..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,25 +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, }; -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) @@ -225,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 91% rename from src/libs/Avatars/PresetAvatarCatalog.types.ts rename to src/libs/Avatars/UserAvatarCatalog.types.ts index d6517a191811..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' @@ -91,7 +89,6 @@ type LetterAvatarIDs = | 'letter-default-avatar_z'; type LetterAvatarColorStyle = {backgroundColor: string; fillColor: string}; -type AvatarEntry = {local: React.FC; url: string}; -type PresetAvatarID = DefaultAvatarIDs | SeasonF1AvatarIDs; +type UserAvatarID = DefaultAvatarIDs | SeasonF1AvatarIDs; -export type {DefaultAvatarIDs, SeasonF1AvatarIDs, 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`] = `