Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/libs/Avatars/AgentAvatarCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/naming-convention -- avatar IDs are kebab-case to match CDN filenames */
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);

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

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

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

function createAvatarCatalog<ID extends string>(entries: Record<ID, AvatarEntry>, displayOrder: readonly ID[] = []): AvatarCatalog<ID> {
const hasID = (id: string): id is ID => id in entries;

const explicit = displayOrder.filter(hasID);
const explicitSet = new Set<ID>(explicit);
const leftovers = Object.keys(entries)
.filter(hasID)
.filter((id) => !explicitSet.has(id))
.sort();
const ordered = [...explicit, ...leftovers].map((id) => ({id, ...entries[id]}));

return {
entries,
ordered,
isAvatarID: (value): value is ID => typeof value === 'string' && hasID(value),
getNameFromURL: (url) => {
if (!url) {
return undefined;
}
const name = url.split('/').at(-1)?.split('.').at(0) ?? '';
return hasID(name) ? name : undefined;
},
getLocal: (id) => (hasID(id) ? entries[id].local : undefined),
getURL: (id) => (hasID(id) ? entries[id].url : undefined),
resolveURI: (id) => (hasID(id) ? entries[id].url : id),
};
}

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

// Catalogs whose entries are CDN-backed (not letter avatars), in lookup priority order.
const CDN_CATALOGS: Array<AvatarCatalog<string>> = [USER_AVATARS, AGENT_AVATARS];

function findCatalogMatchForURL(source: unknown): {catalog: AvatarCatalog<string>; id: string} | undefined {
if (typeof source !== 'string') {
return undefined;
}
for (const catalog of CDN_CATALOGS) {
const id = catalog.getNameFromURL(source);
if (id) {
return {catalog, id};
}
}
return undefined;
}

function findLocalAvatarForURL(source: unknown): React.FC<SvgProps> | undefined {
const match = findCatalogMatchForURL(source);
return match?.catalog.getLocal(match.id);
}

function findAvatarIDFromURL(source: unknown): string | undefined {
return findCatalogMatchForURL(source)?.id;
}

export {findCatalogMatchForURL, findLocalAvatarForURL, findAvatarIDFromURL};
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,25 +178,18 @@ 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 leftovers = allIDS.filter((id) => !explicitSet.has(id)).sort();
const finalIDOrder = [...explicit, ...leftovers];
return finalIDOrder.map((id) => ({
id,
...PRESET_AVATAR_CATALOG[id],
}));
};
const USER_AVATARS = createAvatarCatalog<UserAvatarID>(USER_AVATAR_ENTRIES, DISPLAY_ORDER);

function isLetterAvatarID(value: string): value is LetterAvatarIDs {
return value in LETTER_DEFAULTS;
}

/**
* Returns a letter avatar component based on the first letter of the provided name.
Expand All @@ -217,42 +201,13 @@ function getLetterAvatar(name?: string): React.FC<SvgProps> | null {
return null;
}
const firstChar = getFirstAlphaNumericCharacter(name).toLowerCase();
const workspaceKey = `letter-default-avatar_${firstChar}` as LetterAvatarIDs;
const workspaceKey = `letter-default-avatar_${firstChar}`;

if (!(workspaceKey in LETTER_DEFAULTS)) {
if (!isLetterAvatarID(workspaceKey)) {
return 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