Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/components/AvatarSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,7 +47,7 @@ function AvatarSelector({selectedID, onSelect, label, name, size = CONST.AVATAR_
<Text style={StyleUtils.combineStyles([styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting, styles.pre, styles.ph2])}>{label}</Text>
)}
<View style={styles.avatarSelectorListContainer}>
{PRESET_AVATAR_CATALOG_ORDERED.map(({id, local}) => {
{USER_AVATARS.ordered.map(({id, local}) => {
const isSelected = selectedID === id;

return (
Expand Down
17 changes: 1 addition & 16 deletions src/components/Icon/DefaultBotAvatars.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,8 @@
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';
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<typeof botAvatars>;

const botAvatarIDs = new Map<BotAvatar, string>([
[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};
2 changes: 1 addition & 1 deletion src/hooks/useLetterAvatars.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
26 changes: 26 additions & 0 deletions src/libs/Avatars/AgentAvatarCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* eslint-disable @typescript-eslint/naming-convention */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-5 (docs)

The /* eslint-disable @typescript-eslint/naming-convention */ directive at the top of this new file lacks a justification comment explaining why the naming convention rule needs to be disabled. The keys in AGENT_AVATAR_ENTRIES use kebab-case (e.g., 'bot-avatar--blue') which conflicts with the naming convention rule, but this reason should be documented.

Add a justification comment explaining why the rule is disabled, for example:

/* eslint-disable @typescript-eslint/naming-convention -- Avatar IDs use kebab-case to match CDN filenames */

Reviewed at: 95f3f70 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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<AgentAvatarID, AvatarEntry> = {
'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<AgentAvatarID>(
AGENT_AVATAR_ENTRIES,
(Object.keys(AGENT_AVATAR_ENTRIES) as AgentAvatarID[]).map((id) => ({id, ...AGENT_AVATAR_ENTRIES[id]})),
);

export {AGENT_AVATARS};
export type {AgentAvatarID};
45 changes: 45 additions & 0 deletions src/libs/Avatars/AvatarCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type {SvgProps} from 'react-native-svg';

type AvatarEntry = {local: React.FC<SvgProps>; url: string};

// Base shape for cross-catalog iteration. Specific catalogs widen `string` to their own ID literal union.
type AvatarCatalogBase = {
entries: Record<string, AvatarEntry>;
ordered: ReadonlyArray<{id: string} & AvatarEntry>;
getLocal: (id: string) => React.FC<SvgProps> | undefined;
getURL: (id: string) => string | undefined;
isAvatarID: (value: unknown) => boolean;
resolveURI: (id: string) => string;
getNameFromURL: (url: string | undefined) => string | undefined;
};

type AvatarCatalog<ID extends string> = {
entries: Record<ID, AvatarEntry>;
ordered: Array<{id: ID} & AvatarEntry>;
getLocal: (id: ID) => React.FC<SvgProps> | undefined;
getURL: (id: ID) => string | undefined;
isAvatarID: (value: unknown) => value is ID;
resolveURI: (id: string) => string;
getNameFromURL: (url: string | undefined) => ID | undefined;
};

function createAvatarCatalog<ID extends string>(entries: Record<ID, AvatarEntry>, ordered: Array<{id: ID} & AvatarEntry>): AvatarCatalog<ID> {
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};
46 changes: 46 additions & 0 deletions src/libs/Avatars/AvatarLookup.ts
Original file line number Diff line number Diff line change
@@ -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<SvgProps> | 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};
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -90,15 +90,6 @@ const SEASON_F1: Record<SeasonF1AvatarIDs, AvatarEntry> = {
'wrenches-pink600': {local: SeasonF1.WrenchesPink600, url: `${CDN_SEASON_F1}/wrenches-pink600.png`},
};

const BOT_AVATARS: Record<BotAvatarIDs, AvatarEntry> = {
'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<LetterAvatarIDs, Omit<AvatarEntry, 'url'>> = {
'letter-default-avatar_0': {local: LetterDefaultAvatars.Workspace0},
'letter-default-avatar_1': {local: LetterDefaultAvatars.Workspace1},
Expand Down Expand Up @@ -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<PresetAvatarID, AvatarEntry> = {
const USER_AVATAR_ENTRIES: Record<UserAvatarID, AvatarEntry> = {
...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<PresetAvatarID>(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<UserAvatarID>(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<UserAvatarID>(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)
Expand All @@ -226,33 +218,4 @@ function getLetterAvatar(name?: string): React.FC<SvgProps> | 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};
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type {SvgProps} from 'react-native-svg';

type DefaultAvatarIDs =
| 'default-avatar_1'
| 'default-avatar_2'
Expand Down Expand Up @@ -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<SvgProps>; 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};
Loading
Loading