Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
125a004
Add letter avatar renderer, palette keys, and beta flag
grgia Jun 25, 2026
cbbb47b
Resolve and render generated letter avatars in Avatar
grgia Jun 25, 2026
ef95fe0
Update UserAvatarUtils test for email letter-avatar default
grgia Jun 25, 2026
39a1bef
Merge branch 'main' into grgia-letter-avatars-app-clean
grgia Jun 25, 2026
72c0f1f
Add letter avatar golden parity cases to UserAvatarUtils test
grgia Jun 25, 2026
66d122c
Remove unused letter-avatar exports flagged by knip
grgia Jun 25, 2026
9745d43
Fix Permissions test mock for letter-avatar usePermissions call and a…
grgia Jun 26, 2026
423c726
Merge branch 'main' into grgia-letter-avatars-app-clean
grgia Jun 26, 2026
d724806
Only derive letter-avatar initials from a valid email login (phones u…
grgia Jun 26, 2026
fbb62ae
Polish letter avatar renderer and lock isDefaultAvatar behavior
grgia Jun 26, 2026
e9b0f32
clean up notifications@
grgia Jun 26, 2026
d88278e
Derive letter-avatar initials from UTF-8 and merge-stripped login
grgia Jun 26, 2026
544a93f
Add cspell ignore for non-ASCII test fixtures
grgia Jun 26, 2026
ea0fdb1
Treat generated letter avatars as defaults in the avatar edit flow
grgia Jun 26, 2026
a3ac900
Render letter avatars locally for all users (remove beta gate)
grgia Jun 26, 2026
acfd380
Use StyleUtils helpers instead of inline style in UserInitialsAvatar
grgia Jun 29, 2026
16e7bd7
Extract generated letter-avatar path to CONST
grgia Jun 29, 2026
8d5b043
Reset to default avatar via deleteAvatar when removing profile photo
grgia Jun 29, 2026
c84ab99
Merge branch 'main' into grgia-letter-avatars-app-clean
grgia Jun 30, 2026
693a8d6
Address review: drop unused beta const, simplify letter-avatar condit…
grgia Jul 1, 2026
155ee5f
Fold Latin accents to their ASCII base letter in avatar initials
grgia Jul 2, 2026
bec8df8
Exempt the accent fold table from spellcheck
grgia Jul 2, 2026
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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@
"unscrollable",
"unsharing",
"unsubmitted",
"uppercased",
"upsell",
"urbanairship",
"urlset",
Expand Down
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,8 @@ const CONST = {
DEFAULT_GROUP_AVATAR_COUNT: 18,
DEFAULT_AVATAR_COUNT: 24,

GENERATED_LETTER_AVATAR_PATH: '/images/avatars/generated/letter/',

DISPLAY_NAME: {
// This value is consistent with the BE display name max length limit.
MAX_LENGTH: 100,
Expand Down Expand Up @@ -1249,6 +1251,7 @@ const CONST = {
CLOUDFRONT_DOMAIN_REGEX: /^https:\/\/\w+\.cloudfront\.net/i,
CONCIERGE_ICON_URL_2021: `${CLOUDFRONT_URL}/images/icons/concierge_2021.png`,
CONCIERGE_ICON_URL: `${CLOUDFRONT_URL}/images/icons/concierge_2022.png`,
NOTIFICATIONS_ICON_URL: `${CLOUDFRONT_URL}/images/expensify__favicon.png`,
COMPANY_CARD_PLAID: `${CLOUDFRONT_URL}/images/plaid/`,
// The version below must stay in sync with the `@lottiefiles/dotlottie-web` version pinned in package-lock.json.
DOTLOTTIE_WASM_URL: 'https://cdn.expensify.com/cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.44.0/dist/dotlottie-player.wasm',
Expand Down
25 changes: 24 additions & 1 deletion src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {findLocalAvatarForURL} from '@libs/Avatars/AvatarLookup';
import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils';
import type {AvatarSource} from '@libs/UserAvatarUtils';
import {getAvatar} from '@libs/UserAvatarUtils';
import {getAvatar, parseLetterAvatarURL} from '@libs/UserAvatarUtils';
import type {AvatarSizeName} from '@styles/utils';
import CONST from '@src/CONST';
import type {AvatarType} from '@src/types/onyx/OnyxCommon';
import Icon from './Icon';
import Image from './Image';
import UserInitialsAvatar from './UserInitialsAvatar';

type AvatarProps = {
/** Source for the avatar. Can be a URL or an icon. */
Expand Down Expand Up @@ -86,6 +87,10 @@ function Avatar({
const userAccountID = isWorkspace ? undefined : (avatarID as number);

const source = isWorkspace ? originalSource : getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars});

// Read the color and initials directly from the generated letter-avatar URL.
const letterAvatarParts = parseLetterAvatarURL(source);

let optimizedSource = source;
const localFromCatalog = findLocalAvatarForURL(source);

Expand All @@ -111,6 +116,24 @@ function Avatar({
} else {
iconColors = null;
}

if (!isWorkspace && letterAvatarParts) {
return (
<View
style={[containerStyles, styles.pointerEventsNone]}
testID={testID}
>
<View style={[iconStyle, StyleUtils.getAvatarBorderStyle(size, type), iconAdditionalStyles]}>
<UserInitialsAvatar
text={letterAvatarParts.initials}
colors={letterAvatarParts.colors}
size={iconSize}
/>
</View>
</View>
);
}

return (
<View
style={[containerStyles, styles.pointerEventsNone]}
Expand Down
50 changes: 50 additions & 0 deletions src/components/UserInitialsAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import type {LetterAvatarColorStyle} from '@libs/Avatars/letterAvatarPalette';
import Text from './Text';

/** Initials height relative to the avatar diameter, matching the generated letter-avatar images. */
const INITIALS_FONT_SIZE_RATIO = 0.28;

const styles = StyleSheet.create({
circle: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
text: {
includeFontPadding: false,
},
});

type UserInitialsAvatarProps = {
/** The initials to render */
text: string;

/** Background and fill colors for the avatar */
colors: LetterAvatarColorStyle;

/** Pixel diameter of the avatar */
size: number;
};

function UserInitialsAvatar({text, colors, size}: UserInitialsAvatarProps) {
const StyleUtils = useStyleUtils();
return (
<View style={[styles.circle, StyleUtils.getWidthAndHeightStyle(size, size), StyleUtils.getBorderRadiusStyle(size / 2), StyleUtils.getBackgroundColorStyle(colors.backgroundColor)]}>
<Text
family="EXP_NEUE_BOLD"
color={colors.fillColor}
fontSize={Math.round(size * INITIALS_FONT_SIZE_RATIO)}
textAlign="center"
style={styles.text}
>
{text}
</Text>
</View>
);
}

export default UserInitialsAvatar;
export type {UserInitialsAvatarProps};
5 changes: 4 additions & 1 deletion src/libs/Avatars/letterAvatarPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ const LETTER_AVATAR_SCHEMES: Record<LetterAvatarSchemeKey, LetterAvatarColorStyl
/** The schemes as an ordered array. */
const LETTER_AVATAR_COLOR_OPTIONS: LetterAvatarColorStyle[] = Object.values(LETTER_AVATAR_SCHEMES);

/** The scheme keys as an ordered array. Shares its order with LETTER_AVATAR_COLOR_OPTIONS. */
const LETTER_AVATAR_COLOR_KEYS: LetterAvatarSchemeKey[] = Object.keys(LETTER_AVATAR_SCHEMES).filter(isLetterAvatarSchemeKey);

/** Used when no color has been picked. */
const DEFAULT_LETTER_AVATAR_SCHEME: LetterAvatarColorStyle = LETTER_AVATAR_SCHEMES.blue100;

function isLetterAvatarSchemeKey(value: string): value is LetterAvatarSchemeKey {
return Object.hasOwn(LETTER_AVATAR_SCHEMES, value);
}

export {LETTER_AVATAR_SCHEMES, LETTER_AVATAR_COLOR_OPTIONS, DEFAULT_LETTER_AVATAR_SCHEME, isLetterAvatarSchemeKey};
export {LETTER_AVATAR_SCHEMES, LETTER_AVATAR_COLOR_OPTIONS, LETTER_AVATAR_COLOR_KEYS, DEFAULT_LETTER_AVATAR_SCHEME, isLetterAvatarSchemeKey};
export type {LetterAvatarColorStyle, LetterAvatarSchemeKey};
142 changes: 137 additions & 5 deletions src/libs/UserAvatarUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {md5} from 'expensify-common';
import {md5, Str} from 'expensify-common';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import {findAvatarIDFromURL, findCatalogMatchForURL, findLocalAvatarForURL} from './Avatars/AvatarLookup';
import {DEFAULT_LETTER_AVATAR_SCHEME, isLetterAvatarSchemeKey, LETTER_AVATAR_SCHEMES} from './Avatars/letterAvatarPalette';
import {DEFAULT_LETTER_AVATAR_SCHEME, isLetterAvatarSchemeKey, LETTER_AVATAR_COLOR_KEYS, LETTER_AVATAR_SCHEMES} from './Avatars/letterAvatarPalette';
import type {LetterAvatarColorStyle} from './Avatars/letterAvatarPalette';
import {DEFAULT_AVATAR_PREFIX, USER_AVATARS} from './Avatars/UserAvatarCatalog';
import type {DefaultAvatarIDs} from './Avatars/UserAvatarCatalog.types';
Expand All @@ -14,6 +14,11 @@ type AvatarSource = IconAsset | string;
const DEFAULT_AVATAR_URL_PATTERNS = ['images/avatars/avatar_', 'images/avatars/default-avatar_', 'images/avatars/user/default'];
const LETTER_AVATAR_NAME_REGEX = /^letter-avatar-#[0-9A-F]{6}-#[0-9A-F]{6}-[A-Z]\.png$/;

/** Folds U+00C0-U+017F to the ASCII base letter; '.' entries have no fold. Index = codepoint - 0xC0. */
const LETTER_AVATAR_ACCENT_FOLD_TABLE =
// cspell:disable-next-line
'AAAAAAACEEEEIIIIDNOOOOO.OUUUUYTSAAAAAAACEEEEIIIIDNOOOOO.OUUUUYTYAAAAAACCCCCCCCDDDDEEEEEEEEEEGGGGGGGGHHHHIIIIIIIIIIIIJJKKKLLLLLLLLLLNNNNNNNNNOOOOOOOORRRRRRSSSSSSSSTTTTTTUUUUUUUUUUUUWWYYYZZZZZZS';

/**
* Avatar naming convention
*
Expand Down Expand Up @@ -101,10 +106,10 @@ function getLetterAvatarScheme(avatarSchemeKey?: string): LetterAvatarColorStyle
* @returns The avatar icon asset (SVG component), or undefined if no default avatar matches
*/
function getDefaultAvatar({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail, avatarURL, defaultAvatars}: DefaultAvatarArgsType & DefaultAvatarsType): IconAsset | undefined {
if (accountID === CONST.ACCOUNT_ID.CONCIERGE) {
if (accountID === CONST.ACCOUNT_ID.CONCIERGE || accountEmail === CONST.EMAIL.CONCIERGE || avatarURL === CONST.CONCIERGE_ICON_URL) {
return defaultAvatars.ConciergeAvatar;
}
if (accountID === CONST.ACCOUNT_ID.NOTIFICATIONS) {
if (accountID === CONST.ACCOUNT_ID.NOTIFICATIONS || accountEmail === CONST.EMAIL.NOTIFICATIONS || avatarURL === CONST.NOTIFICATIONS_ICON_URL) {
return defaultAvatars.NotificationsAvatar;
}

Expand Down Expand Up @@ -138,9 +143,19 @@ function getDefaultAvatarName({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail
*
*/
function getDefaultAvatarURL({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail, avatarURL}: DefaultAvatarArgsType): string {
if (Number(accountID) === CONST.ACCOUNT_ID.CONCIERGE) {
if (Number(accountID) === CONST.ACCOUNT_ID.CONCIERGE || accountEmail === CONST.EMAIL.CONCIERGE) {
return CONST.CONCIERGE_ICON_URL;
}
if (Number(accountID) === CONST.ACCOUNT_ID.NOTIFICATIONS || accountEmail === CONST.EMAIL.NOTIFICATIONS) {
return CONST.NOTIFICATIONS_ICON_URL;
}

// The local default has no name to read initials from, so they come from the email. The backend emits
// name-based initials on the avatar URL, which the client parses instead of recomputing here.
const letterAvatarURL = getLetterAvatarURL(accountID, '', '', accountEmail ?? '');
Comment thread
grgia marked this conversation as resolved.
if (letterAvatarURL) {
return letterAvatarURL;
Comment thread
grgia marked this conversation as resolved.
Comment thread
grgia marked this conversation as resolved.
Comment thread
grgia marked this conversation as resolved.
}

return USER_AVATARS.getURL(getDefaultAvatarName({accountID, accountEmail, avatarURL})) ?? '';
}
Expand Down Expand Up @@ -176,6 +191,9 @@ function isDefaultAvatar(avatarSource?: AvatarSource): avatarSource is string |
if (avatarSource === CONST.CONCIERGE_ICON_URL_2021 || avatarSource === CONST.CONCIERGE_ICON_URL) {
return true;
}
if (avatarSource === CONST.NOTIFICATIONS_ICON_URL) {
return true;
}
}

return false;
Expand Down Expand Up @@ -203,6 +221,112 @@ function isLetterAvatar(originalFileName?: string): boolean {
return !!(originalFileName && LETTER_AVATAR_NAME_REGEX.test(originalFileName));
}

/**
* Determines if an avatar source is a backend-generated letter-avatar URL.
* These URLs are served for photo-less users and contain the generated letter path segment.
*
* @param avatarSource - The avatar source to check
* @returns True if the source is a string pointing to a generated letter avatar
*/
function isGeneratedLetterAvatarURL(avatarSource?: AvatarSource): boolean {
Comment thread
grgia marked this conversation as resolved.
return typeof avatarSource === 'string' && avatarSource.includes(CONST.GENERATED_LETTER_AVATAR_PATH);
}

/**
* Returns the first alphanumeric character of a string uppercased, folding Latin accented letters
* to their ASCII base letter, or '' when the string yields no initial.
*
* @param name - The string to read the first character from
*/
function firstLetterAvatarCharacter(name: string): string {
for (const character of name) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x80) {
if (/[a-z0-9]/i.test(character)) {
return character.toUpperCase();
}
continue;
}
// Latin accents fold to their ASCII base letter; any other non-ASCII codepoint contributes no
// initial rather than substituting a later ASCII letter.
if (codePoint >= 0xc0 && codePoint <= 0x17f) {
const folded = LETTER_AVATAR_ACCENT_FOLD_TABLE.charAt(codePoint - 0xc0);
return folded === '.' ? '' : folded;
}
return '';
}
Comment on lines +242 to +257

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.

Should we consider cases like Élodie Lee -> EL? I believe that we this we will only show L for that case and we might want to fallback some characters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ooo great point, let me check what those characters entail

return '';
}

/**
* Builds the generated letter-avatar URL for an account from its name and login.
* Initials come from the first alphanumeric character of the first and last name, falling back to the login
* for non-SMS logins. The color key is picked by hashing the login, or by accountID modulo when there is no login.
*
* @param accountID - The user's account ID
* @param firstName - The user's first name
* @param lastName - The user's last name
* @param login - The user's login (email or SMS), or '' when unknown
* @returns The generated letter-avatar URL, or '' when no letter avatar applies
*/
function getLetterAvatarURL(accountID: number, firstName: string, lastName: string, login: string): string {
// The displayed login has the merge prefix stripped, so derive the initial and color from the
// stripped form to match what users see. This is a no-op for non-merged logins.
const normalizedLogin = login.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
if (
accountID === CONST.ACCOUNT_ID.CONCIERGE ||
accountID === CONST.ACCOUNT_ID.NOTIFICATIONS ||
normalizedLogin === CONST.EMAIL.CONCIERGE ||
normalizedLogin === CONST.EMAIL.NOTIFICATIONS
) {
return '';
}

let initials = firstLetterAvatarCharacter(firstName) + firstLetterAvatarCharacter(lastName);
// Only a real email seeds the initial. Phone numbers (raw or @expensify.sms) fall back to the illustrated default.
if (initials === '' && !normalizedLogin.endsWith(CONST.SMS.DOMAIN) && Str.isValidEmail(normalizedLogin)) {
initials = firstLetterAvatarCharacter(normalizedLogin);
}
if (initials === '') {
return '';
}

const colorIndex = normalizedLogin !== '' ? parseInt(md5(normalizedLogin).substring(0, 4), 16) % LETTER_AVATAR_COLOR_KEYS.length : accountID % LETTER_AVATAR_COLOR_KEYS.length;
const colorKey = LETTER_AVATAR_COLOR_KEYS.at(colorIndex) ?? LETTER_AVATAR_COLOR_KEYS.at(0);
return `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/${colorKey}/${initials}.png`;
}

/**
* Parses a generated letter-avatar URL into its color scheme and initials.
* The last two path segments before the extension are the color key and the initials.
*
* @param source - The avatar source to parse
* @returns The colors and initials, or undefined when the source is not a generated letter-avatar URL
*/
function parseLetterAvatarURL(source: AvatarSource | undefined): {colors: LetterAvatarColorStyle; initials: string} | undefined {
if (typeof source !== 'string' || !isGeneratedLetterAvatarURL(source)) {
return undefined;
}

const fileName = source.split('?').at(0)?.split('/').slice(-2) ?? [];
const colorKey = fileName.at(0);
const rawInitials = fileName.at(1);
if (colorKey === undefined || rawInitials === undefined) {
return undefined;
}

const initials = rawInitials
.replace(/\.png$/i, '')
.replace(/_128$/, '')
.toUpperCase();
if (initials === '') {
return undefined;
}

const colors = isLetterAvatarSchemeKey(colorKey) ? LETTER_AVATAR_SCHEMES[colorKey] : DEFAULT_LETTER_AVATAR_SCHEME;
return {colors, initials};
}

/**
* Returns the appropriate avatar source (SVG asset or URL) for rendering in React components.
*
Expand Down Expand Up @@ -298,6 +422,11 @@ function getSmallSizeAvatar(args: GetAvatarArgsType & DefaultAvatarsType): Avata
return source;
}

// Generated letter avatars are published at a single size, so they have no _SIZE variants.
if (isGeneratedLetterAvatarURL(source)) {
return source;
}

// If image source already has _128 at the end, the given avatar URL is already what we want to use here.
const lastPeriodIndex = source.lastIndexOf('.');
if (source.substring(lastPeriodIndex - 4, lastPeriodIndex) === '_128') {
Expand All @@ -316,8 +445,11 @@ export {
getCatalogAvatarNameFromURL,
getFullSizeAvatar,
getSmallSizeAvatar,
getLetterAvatarURL,
parseLetterAvatarURL,
isCatalogAvatar,
isDefaultAvatar,
isGeneratedLetterAvatarURL,
isLetterAvatar,
};
export type {AvatarSource};
2 changes: 1 addition & 1 deletion src/libs/__mocks__/Permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ import type Beta from '@src/types/onyx/Beta';
*/

export default {
...jest.requireActual<typeof Permissions>('../Permissions'),
...jest.requireActual<{default: typeof Permissions}>('../Permissions').default,
isBetaEnabled: (beta: Beta, betas: Beta[]) => !!betas?.includes(beta),
};
1 change: 0 additions & 1 deletion src/libs/actions/PersonalDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,6 @@ function updateAvatar(
API.write(WRITE_COMMANDS.UPDATE_USER_AVATAR, parameters, {optimisticData, successData, failureData});
}

// TODO remove when no longer needed
/**
* Replaces the user's avatar image with a default avatar
*/
Expand Down
Loading
Loading