From 125a004cd4099a90fe3f84a9760c5f9be1ffa6ba Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 25 Jun 2026 11:32:25 +0100 Subject: [PATCH 01/19] Add letter avatar renderer, palette keys, and beta flag --- src/CONST/index.ts | 1 + src/components/UserInitialsAvatar.tsx | 44 ++++++++++++++++++ src/libs/Avatars/letterAvatarPalette.ts | 5 +- src/stories/UserInitialsAvatar.stories.tsx | 54 ++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/components/UserInitialsAvatar.tsx create mode 100644 src/stories/UserInitialsAvatar.stories.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 01959d09842d..696b1e90e7fe 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1042,6 +1042,7 @@ const CONST = { VENDOR_MATCHING: 'vendorMatching', RULES_REVAMP: 'rulesRevamp', COMMUTER_EXCLUSIONS: 'commuterExclusions', + DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/components/UserInitialsAvatar.tsx b/src/components/UserInitialsAvatar.tsx new file mode 100644 index 000000000000..cd1c9bda3d7e --- /dev/null +++ b/src/components/UserInitialsAvatar.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {LetterAvatarColorStyle} from '@libs/Avatars/letterAvatarPalette'; +import Text from './Text'; + +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) { + return ( + + + {text} + + + ); +} + +export default UserInitialsAvatar; +export type {UserInitialsAvatarProps}; diff --git a/src/libs/Avatars/letterAvatarPalette.ts b/src/libs/Avatars/letterAvatarPalette.ts index 06dd89cdce42..e147b73e2313 100644 --- a/src/libs/Avatars/letterAvatarPalette.ts +++ b/src/libs/Avatars/letterAvatarPalette.ts @@ -53,6 +53,9 @@ const LETTER_AVATAR_SCHEMES: Record; + +const story: Meta = { + title: 'Components/UserInitialsAvatar', + component: UserInitialsAvatar, +}; + +function Template(props: UserInitialsAvatarProps) { + return ( + + {SAMPLE_COLORS.map((colors) => ( + + ))} + + ); +} + +const Default: UserInitialsAvatarStory = Template.bind({}); +Default.args = { + text: 'JD', + colors: DEFAULT_LETTER_AVATAR_SCHEME, + size: 40, +}; + +const Large: UserInitialsAvatarStory = Template.bind({}); +Large.args = { + text: 'AB', + colors: DEFAULT_LETTER_AVATAR_SCHEME, + size: 80, +}; + +const SingleLetter: UserInitialsAvatarStory = Template.bind({}); +SingleLetter.args = { + text: 'C', + colors: DEFAULT_LETTER_AVATAR_SCHEME, + size: 80, +}; + +export default story; +export {Default, Large, SingleLetter}; From cbbb47bcf9655fb80d3339229934434bba8f6853 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 25 Jun 2026 11:32:26 +0100 Subject: [PATCH 02/19] Resolve and render generated letter avatars in Avatar --- src/components/Avatar.tsx | 28 +++++++- src/libs/UserAvatarUtils.ts | 131 +++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 63a828826995..3dd89f66fa17 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -3,18 +3,20 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; 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. */ @@ -74,6 +76,7 @@ function Avatar({ testID = 'Avatar', }: AvatarProps) { const defaultAvatars = useDefaultAvatars(); + const {isBetaEnabled} = usePermissions(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -86,6 +89,11 @@ 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); + const shouldRenderLetterAvatar = !isWorkspace && !!letterAvatarParts && isBetaEnabled(CONST.BETAS.DEFAULT_LETTER_AVATARS); + let optimizedSource = source; const localFromCatalog = findLocalAvatarForURL(source); @@ -111,6 +119,24 @@ function Avatar({ } else { iconColors = null; } + + if (shouldRenderLetterAvatar && letterAvatarParts) { + return ( + + + + + + ); + } + return ( 1 ? (lastWord.match(/[a-z0-9]/i)?.at(0) ?? '') : ''; + return `${firstInitial}${lastInitial}`.toUpperCase(); +} + +/** + * Returns the first alphanumeric character of a string, uppercased, or '' when none exists. + * + * @param name - The string to read the first character from + */ +function firstLetterAvatarCharacter(name: string): string { + return (name.match(/[a-z0-9]/i)?.at(0) ?? '').toUpperCase(); +} + +/** + * 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 { + if (accountID === CONST.ACCOUNT_ID.CONCIERGE || accountID === CONST.ACCOUNT_ID.NOTIFICATIONS) { + return ''; + } + + let initials = firstLetterAvatarCharacter(firstName) + firstLetterAvatarCharacter(lastName); + if (initials === '' && login !== '' && !login.endsWith(CONST.SMS.DOMAIN)) { + initials = firstLetterAvatarCharacter(login); + } + if (initials === '') { + return ''; + } + + const colorIndex = login !== '' ? parseInt(md5(login).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}/images/avatars/generated/letter/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. * @@ -298,6 +417,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') { @@ -316,8 +440,13 @@ export { getCatalogAvatarNameFromURL, getFullSizeAvatar, getSmallSizeAvatar, + getDefaultLetterAvatarScheme, + getLetterAvatarInitials, + getLetterAvatarURL, + parseLetterAvatarURL, isCatalogAvatar, isDefaultAvatar, + isGeneratedLetterAvatarURL, isLetterAvatar, }; export type {AvatarSource}; From ef95fe0007d9068070e621274536ec324a641359 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 25 Jun 2026 13:30:03 +0100 Subject: [PATCH 03/19] Update UserAvatarUtils test for email letter-avatar default --- tests/unit/UserAvatarUtilsTest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 9381a3790d85..8b9ad35e929d 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -98,12 +98,13 @@ describe('UserAvatarUtils', () => { expect(url1).toBe(url25); // Same modulo result }); - it('should use accountEmail hash instead of accountID when email is provided', () => { + it('should return a letter avatar from the email when an email is provided', () => { const urlByID = UserAvatarUtils.getDefaultAvatarURL({accountID: 10}); const urlByEmail = UserAvatarUtils.getDefaultAvatarURL({accountID: 10, accountEmail: 'john.doe@example.com'}); expect(urlByID).toContain('default-avatar_'); - expect(urlByEmail).toContain('default-avatar_'); + expect(urlByEmail).toContain('generated/letter/'); + expect(urlByEmail).toContain('/J.png'); expect(urlByID).not.toBe(urlByEmail); }); From 72c0f1f5bccc56ee4ff793e3249eea4affbf8bb1 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 25 Jun 2026 14:40:46 +0100 Subject: [PATCH 04/19] Add letter avatar golden parity cases to UserAvatarUtils test --- tests/unit/UserAvatarUtilsTest.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 8b9ad35e929d..0f884120846d 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -298,4 +298,36 @@ describe('UserAvatarUtils', () => { expect(name).toBe('default-avatar_20'); }); }); + + describe('getLetterAvatarURL (golden parity)', () => { + // Frozen golden cases shared with the Auth and Web getLetterAvatarURL tests. All three + // implementations must produce these exact URLs. Colors come from md5(login) % palette size. + const BASE = `${CONST.CLOUDFRONT_URL}/images/avatars/generated/letter/v1`; + const cases = [ + {accountID: 42, firstName: 'Dave', lastName: 'Lee', login: 'dave@example.com', expected: `${BASE}/blue100/DL.png`}, + {accountID: 42, firstName: 'Dave', lastName: '', login: 'dave@example.com', expected: `${BASE}/blue100/D.png`}, + {accountID: 42, firstName: '', lastName: '', login: 'hiiii@example.com', expected: `${BASE}/blue400/H.png`}, + {accountID: 7, firstName: '', lastName: '', login: '+15551234567@expensify.sms', expected: ''}, + {accountID: CONST.ACCOUNT_ID.CONCIERGE, firstName: 'Concierge', lastName: '', login: 'concierge@expensify.com', expected: ''}, + {accountID: CONST.ACCOUNT_ID.NOTIFICATIONS, firstName: 'Notifications', lastName: '', login: 'notifications@expensify.com', expected: ''}, + ]; + + it.each(cases)('builds the canonical URL for $login ($firstName/$lastName)', ({accountID, firstName, lastName, login, expected}) => { + expect(UserAvatarUtils.getLetterAvatarURL(accountID, firstName, lastName, login)).toBe(expected); + }); + + it('derives color from the login so it stays stable across an optimistic accountID change', () => { + const optimistic = UserAvatarUtils.getLetterAvatarURL(1, '', '', 'consistent@example.com'); + const real = UserAvatarUtils.getLetterAvatarURL(999, '', '', 'consistent@example.com'); + expect(optimistic).toBe(real); + expect(optimistic).toBe(`${BASE}/pink400/C.png`); + }); + + it('round-trips through parseLetterAvatarURL', () => { + const url = UserAvatarUtils.getLetterAvatarURL(42, 'Dave', 'Lee', 'dave@example.com'); + const parsed = UserAvatarUtils.parseLetterAvatarURL(url); + expect(parsed?.initials).toBe('DL'); + expect(parsed?.colors).toEqual(UserAvatarUtils.getLetterAvatarScheme('blue100')); + }); + }); }); From 66d122c5a6d2d260808f7a8c6ac6f409ac8e652b Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 25 Jun 2026 15:09:16 +0100 Subject: [PATCH 05/19] Remove unused letter-avatar exports flagged by knip --- src/libs/UserAvatarUtils.ts | 39 +------------------------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index c2db0415c0c8..9f014e3e2ea9 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -2,7 +2,7 @@ import {md5} 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_COLOR_KEYS, LETTER_AVATAR_COLOR_OPTIONS, 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'; @@ -219,40 +219,6 @@ function isGeneratedLetterAvatarURL(avatarSource?: AvatarSource): boolean { return typeof avatarSource === 'string' && avatarSource.includes('/images/avatars/generated/letter/'); } -/** - * Returns the letter-avatar color scheme for an account, picked by accountID modulo the palette size. - * Mirrors the backend color selection so the client-rendered avatar matches the generated image. - * - * @param accountID - The user's account ID - * @returns The colors for the account's letter avatar - */ -function getDefaultLetterAvatarScheme(accountID?: number): LetterAvatarColorStyle { - return LETTER_AVATAR_COLOR_OPTIONS[(accountID ?? CONST.DEFAULT_NUMBER_ID) % LETTER_AVATAR_COLOR_OPTIONS.length]; -} - -/** - * Derives the initials for a letter avatar from a display name. - * Takes the first alphanumeric character of the first word and of the last word, uppercased. - * Mirrors the backend firstAlphanumeric(firstName) + firstAlphanumeric(lastName) rule using the display name. - * - * @param name - The user's display name - * @returns The 1-2 character initials, or an empty string when nothing usable is found - */ -function getLetterAvatarInitials(name?: string): string { - if (!name) { - return ''; - } - const words = name.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) { - return ''; - } - const firstWord = words.at(0) ?? ''; - const lastWord = words.at(-1) ?? ''; - const firstInitial = firstWord.match(/[a-z0-9]/i)?.at(0) ?? ''; - const lastInitial = words.length > 1 ? (lastWord.match(/[a-z0-9]/i)?.at(0) ?? '') : ''; - return `${firstInitial}${lastInitial}`.toUpperCase(); -} - /** * Returns the first alphanumeric character of a string, uppercased, or '' when none exists. * @@ -440,13 +406,10 @@ export { getCatalogAvatarNameFromURL, getFullSizeAvatar, getSmallSizeAvatar, - getDefaultLetterAvatarScheme, - getLetterAvatarInitials, getLetterAvatarURL, parseLetterAvatarURL, isCatalogAvatar, isDefaultAvatar, - isGeneratedLetterAvatarURL, isLetterAvatar, }; export type {AvatarSource}; From 9745d43396b9ce93e311a039c6208c054981cc87 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 08:01:02 +0100 Subject: [PATCH 06/19] Fix Permissions test mock for letter-avatar usePermissions call and add cspell word - Avatar now calls usePermissions(), which iterates Permissions keys and invokes each as a checker function. The Permissions test mock spread the module namespace ({default, isBetaEnabled}) instead of the default export, so the non-function 'default' key crashed usePermissions in LHN/Sidebar test render paths. Spread .default so only function checkers are exposed. - Add 'uppercased' to cspell wordlist (used in a UserAvatarUtils comment). --- cspell.json | 1 + src/libs/__mocks__/Permissions.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cspell.json b/cspell.json index fa170233c6b6..32a739f72182 100644 --- a/cspell.json +++ b/cspell.json @@ -945,6 +945,7 @@ "unscrollable", "unsharing", "unsubmitted", + "uppercased", "upsell", "urbanairship", "urlset", diff --git a/src/libs/__mocks__/Permissions.ts b/src/libs/__mocks__/Permissions.ts index 1ea5fd66b1f7..e8cba67a1297 100644 --- a/src/libs/__mocks__/Permissions.ts +++ b/src/libs/__mocks__/Permissions.ts @@ -9,6 +9,6 @@ import type Beta from '@src/types/onyx/Beta'; */ export default { - ...jest.requireActual('../Permissions'), + ...jest.requireActual<{default: typeof Permissions}>('../Permissions').default, isBetaEnabled: (beta: Beta, betas: Beta[]) => !!betas?.includes(beta), }; From d72480694392af924aad068b391cfc85096bad63 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 08:55:24 +0100 Subject: [PATCH 07/19] Only derive letter-avatar initials from a valid email login (phones use the default) --- src/libs/UserAvatarUtils.ts | 7 +++++-- tests/unit/libs/PersonalDetailsUtilsTest.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 9f014e3e2ea9..0e6443d86721 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -1,4 +1,4 @@ -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'; @@ -142,6 +142,8 @@ function getDefaultAvatarURL({accountID = CONST.DEFAULT_NUMBER_ID, accountEmail, return CONST.CONCIERGE_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 ?? ''); if (letterAvatarURL) { return letterAvatarURL; @@ -245,7 +247,8 @@ function getLetterAvatarURL(accountID: number, firstName: string, lastName: stri } let initials = firstLetterAvatarCharacter(firstName) + firstLetterAvatarCharacter(lastName); - if (initials === '' && login !== '' && !login.endsWith(CONST.SMS.DOMAIN)) { + // Only a real email seeds the initial. Phone numbers (raw or @expensify.sms) fall back to the illustrated default. + if (initials === '' && !login.endsWith(CONST.SMS.DOMAIN) && Str.isValidEmail(login)) { initials = firstLetterAvatarCharacter(login); } if (initials === '') { diff --git a/tests/unit/libs/PersonalDetailsUtilsTest.ts b/tests/unit/libs/PersonalDetailsUtilsTest.ts index 673afcd17a30..eb71cf1036dc 100644 --- a/tests/unit/libs/PersonalDetailsUtilsTest.ts +++ b/tests/unit/libs/PersonalDetailsUtilsTest.ts @@ -163,7 +163,7 @@ describe('PersonalDetailsUtils', () => { // eslint-disable-next-line @typescript-eslint/naming-convention '2': { accountID: 2, - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_18.png', + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/generated/letter/v1/ice700/T.png', displayName: 'test2@test.com', isOptimisticPersonalDetail: true, login: 'test2@test.com', From fbb62aeacfebe4723f2725b92318535785e5c671 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 08:55:39 +0100 Subject: [PATCH 08/19] Polish letter avatar renderer and lock isDefaultAvatar behavior - Move UserInitialsAvatar static styles into StyleSheet and name the font-size ratio - Note why the local default reads initials from the email, not the name - Add a regression test that a generated letter URL is not a default avatar --- src/components/UserInitialsAvatar.tsx | 32 +++++++++++++++------------ tests/unit/UserAvatarUtilsTest.ts | 4 ++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/components/UserInitialsAvatar.tsx b/src/components/UserInitialsAvatar.tsx index cd1c9bda3d7e..d4d5c769c825 100644 --- a/src/components/UserInitialsAvatar.tsx +++ b/src/components/UserInitialsAvatar.tsx @@ -1,8 +1,22 @@ import React from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; 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; @@ -16,23 +30,13 @@ type UserInitialsAvatarProps = { function UserInitialsAvatar({text, colors, size}: UserInitialsAvatarProps) { return ( - + {text} diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 0f884120846d..09ae54ce7877 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -144,6 +144,10 @@ describe('UserAvatarUtils', () => { it('should return false for undefined', () => { expect(UserAvatarUtils.isDefaultAvatar(undefined)).toBe(false); }); + + it('should return false for a generated letter-avatar URL so getAvatarURL passes it through instead of recomputing', () => { + expect(UserAvatarUtils.isDefaultAvatar(`${CONST.CLOUDFRONT_URL}/images/avatars/generated/letter/v1/blue100/DL.png`)).toBe(false); + }); }); describe('isCatalogAvatar', () => { From e9b0f32c1f75e6356f124937acda190f61c15e85 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 10:25:11 +0100 Subject: [PATCH 09/19] clean up notifications@ --- src/CONST/index.ts | 1 + src/libs/UserAvatarUtils.ts | 14 ++++++++++---- tests/unit/UserAvatarUtilsTest.ts | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1f6062748e06..f1077b102f30 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1258,6 +1258,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', diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 0e6443d86721..3589969cea5d 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -101,10 +101,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; } @@ -138,9 +138,12 @@ 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. @@ -183,6 +186,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; @@ -242,7 +248,7 @@ function firstLetterAvatarCharacter(name: string): string { * @returns The generated letter-avatar URL, or '' when no letter avatar applies */ function getLetterAvatarURL(accountID: number, firstName: string, lastName: string, login: string): string { - if (accountID === CONST.ACCOUNT_ID.CONCIERGE || accountID === CONST.ACCOUNT_ID.NOTIFICATIONS) { + if (accountID === CONST.ACCOUNT_ID.CONCIERGE || accountID === CONST.ACCOUNT_ID.NOTIFICATIONS || login === CONST.EMAIL.CONCIERGE || login === CONST.EMAIL.NOTIFICATIONS) { return ''; } diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 09ae54ce7877..e3f271dd7575 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -30,6 +30,12 @@ describe('UserAvatarUtils', () => { expect(avatar).toBeDefined(); }); + it('should resolve the Notifications icon URL to the local Notifications avatar regardless of accountID', () => { + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const avatar = UserAvatarUtils.getAvatar({avatarSource: CONST.NOTIFICATIONS_ICON_URL, accountID: 999, defaultAvatars: avatars.current}); + expect(avatar).toBe(avatars.current.NotificationsAvatar); + }); + it('should return default avatar SVG for default avatar URL', () => { const {result: avatars} = renderHook(() => useDefaultAvatars()); const avatar = UserAvatarUtils.getAvatar({avatarSource: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_2.png', accountID: 2, defaultAvatars: avatars.current}); @@ -70,6 +76,11 @@ describe('UserAvatarUtils', () => { expect(url).toBe(CONST.CONCIERGE_ICON_URL); }); + it('should return Notifications icon URL for the Notifications account by accountID or email', () => { + expect(UserAvatarUtils.getDefaultAvatarURL({accountID: CONST.ACCOUNT_ID.NOTIFICATIONS})).toBe(CONST.NOTIFICATIONS_ICON_URL); + expect(UserAvatarUtils.getDefaultAvatarURL({accountID: 999, accountEmail: CONST.EMAIL.NOTIFICATIONS})).toBe(CONST.NOTIFICATIONS_ICON_URL); + }); + it('should return default avatar URL for regular account', () => { const url = UserAvatarUtils.getDefaultAvatarURL({accountID: 1}); expect(url).toContain('default-avatar_'); @@ -137,6 +148,10 @@ describe('UserAvatarUtils', () => { expect(UserAvatarUtils.isDefaultAvatar(CONST.CONCIERGE_ICON_URL_2021)).toBe(true); }); + it('should return true for the Notifications icon URL', () => { + expect(UserAvatarUtils.isDefaultAvatar(CONST.NOTIFICATIONS_ICON_URL)).toBe(true); + }); + it('should return false for custom avatar URLs', () => { expect(UserAvatarUtils.isDefaultAvatar('https://example.com/custom-avatar.png')).toBe(false); }); @@ -314,6 +329,9 @@ describe('UserAvatarUtils', () => { {accountID: 7, firstName: '', lastName: '', login: '+15551234567@expensify.sms', expected: ''}, {accountID: CONST.ACCOUNT_ID.CONCIERGE, firstName: 'Concierge', lastName: '', login: 'concierge@expensify.com', expected: ''}, {accountID: CONST.ACCOUNT_ID.NOTIFICATIONS, firstName: 'Notifications', lastName: '', login: 'notifications@expensify.com', expected: ''}, + // System accounts are recognized by login too, so a non-system accountID (e.g. dev DB IDs) still gets no letter avatar. + {accountID: 999, firstName: '', lastName: '', login: 'concierge@expensify.com', expected: ''}, + {accountID: 999, firstName: '', lastName: '', login: 'notifications@expensify.com', expected: ''}, ]; it.each(cases)('builds the canonical URL for $login ($firstName/$lastName)', ({accountID, firstName, lastName, login, expected}) => { From d88278ef7a3a11f8b0138bb48619ada1062985dd Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 11:56:27 +0100 Subject: [PATCH 10/19] Derive letter-avatar initials from UTF-8 and merge-stripped login --- src/libs/UserAvatarUtils.ts | 28 +++++++++++++++++++++++----- tests/unit/UserAvatarUtilsTest.ts | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 3589969cea5d..8aba497f3317 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -233,7 +233,17 @@ function isGeneratedLetterAvatarURL(avatarSource?: AvatarSource): boolean { * @param name - The string to read the first character from */ function firstLetterAvatarCharacter(name: string): string { - return (name.match(/[a-z0-9]/i)?.at(0) ?? '').toUpperCase(); + for (const character of name) { + // A non-ASCII codepoint has no pre-generated image, so fall back instead of skipping to a + // later ASCII letter, which would be a misleading initial for the name. + if ((character.codePointAt(0) ?? 0) >= 0x80) { + return ''; + } + if (/[a-z0-9]/i.test(character)) { + return character.toUpperCase(); + } + } + return ''; } /** @@ -248,20 +258,28 @@ function firstLetterAvatarCharacter(name: string): string { * @returns The generated letter-avatar URL, or '' when no letter avatar applies */ function getLetterAvatarURL(accountID: number, firstName: string, lastName: string, login: string): string { - if (accountID === CONST.ACCOUNT_ID.CONCIERGE || accountID === CONST.ACCOUNT_ID.NOTIFICATIONS || login === CONST.EMAIL.CONCIERGE || login === CONST.EMAIL.NOTIFICATIONS) { + // 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 === '' && !login.endsWith(CONST.SMS.DOMAIN) && Str.isValidEmail(login)) { - initials = firstLetterAvatarCharacter(login); + if (initials === '' && !normalizedLogin.endsWith(CONST.SMS.DOMAIN) && Str.isValidEmail(normalizedLogin)) { + initials = firstLetterAvatarCharacter(normalizedLogin); } if (initials === '') { return ''; } - const colorIndex = login !== '' ? parseInt(md5(login).substring(0, 4), 16) % LETTER_AVATAR_COLOR_KEYS.length : accountID % LETTER_AVATAR_COLOR_KEYS.length; + 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}/images/avatars/generated/letter/v1/${colorKey}/${initials}.png`; } diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index e3f271dd7575..ce20f0406275 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -338,6 +338,21 @@ describe('UserAvatarUtils', () => { expect(UserAvatarUtils.getLetterAvatarURL(accountID, firstName, lastName, login)).toBe(expected); }); + it('falls back instead of using a later ASCII letter when the name starts with a non-ASCII character', () => { + // "Élodie" must not become "L" and a leading CJK name must not become "A". + expect(UserAvatarUtils.getLetterAvatarURL(42, 'Élodie', '', '')).toBe(''); + expect(UserAvatarUtils.getLetterAvatarURL(42, '李Ann', '', '')).toBe(''); + // An ASCII first letter still wins even when later characters are non-ASCII. + expect(UserAvatarUtils.getLetterAvatarURL(42, 'José', '', 'dave@example.com')).toBe(`${BASE}/blue100/J.png`); + }); + + it('derives the initial and color from the merge-stripped login', () => { + const merged = UserAvatarUtils.getLetterAvatarURL(42, '', '', 'MERGED_0@real@example.com'); + const stripped = UserAvatarUtils.getLetterAvatarURL(42, '', '', 'real@example.com'); + expect(merged).toBe(stripped); + expect(merged.endsWith('/R.png')).toBe(true); + }); + it('derives color from the login so it stays stable across an optimistic accountID change', () => { const optimistic = UserAvatarUtils.getLetterAvatarURL(1, '', '', 'consistent@example.com'); const real = UserAvatarUtils.getLetterAvatarURL(999, '', '', 'consistent@example.com'); From 544a93fefa060e0e133ddd4aa63113a74073b856 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 12:50:58 +0100 Subject: [PATCH 11/19] Add cspell ignore for non-ASCII test fixtures --- tests/unit/UserAvatarUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index ce20f0406275..8b477f396f8e 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -1,3 +1,4 @@ +// cspell:ignore Élodie José import {renderHook} from '@testing-library/react-native'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; From ea0fdb167202b70b7a0bab4af4fc50dec9975396 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 12:58:54 +0100 Subject: [PATCH 12/19] Treat generated letter avatars as defaults in the avatar edit flow --- src/libs/UserAvatarUtils.ts | 1 + src/pages/settings/Profile/Avatar/AvatarPreview.tsx | 9 +++++++-- tests/unit/UserAvatarUtilsTest.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 8aba497f3317..60503df15c3b 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -437,6 +437,7 @@ export { parseLetterAvatarURL, isCatalogAvatar, isDefaultAvatar, + isGeneratedLetterAvatarURL, isLetterAvatar, }; export type {AvatarSource}; diff --git a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx index 98653dd09418..decaf35d0a7c 100644 --- a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx @@ -14,7 +14,7 @@ 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, isCatalogAvatar, isLetterAvatar} from '@libs/UserAvatarUtils'; +import {getDefaultAvatarName, isCatalogAvatar, isGeneratedLetterAvatarURL, isLetterAvatar} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -74,7 +74,12 @@ 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 && (isCatalogAvatar(currentUserPersonalDetails?.avatar) || isLetterAvatar(currentUserPersonalDetails?.originalFileName))) || !!selected; + const shouldHideAvatarEdit = + (!imageData.uri && + (isCatalogAvatar(currentUserPersonalDetails?.avatar) || + isGeneratedLetterAvatarURL(currentUserPersonalDetails?.avatar) || + isLetterAvatar(currentUserPersonalDetails?.originalFileName))) || + !!selected; /** * Validates an image and opens avatar crop modal if valid diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 8b477f396f8e..63740b06729d 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -211,6 +211,18 @@ describe('UserAvatarUtils', () => { }); }); + describe('isGeneratedLetterAvatarURL', () => { + it('should return true for a backend-generated letter-avatar URL so the edit flow treats it as a default', () => { + expect(UserAvatarUtils.isGeneratedLetterAvatarURL(`${CONST.CLOUDFRONT_URL}/images/avatars/generated/letter/v1/blue100/DL.png`)).toBe(true); + }); + + it('should return false for uploaded photos and other avatar URLs', () => { + expect(UserAvatarUtils.isGeneratedLetterAvatarURL('https://example.com/custom-avatar.png')).toBe(false); + expect(UserAvatarUtils.isGeneratedLetterAvatarURL(`${CONST.CLOUDFRONT_URL}/images/avatars/default-avatar_5.png`)).toBe(false); + expect(UserAvatarUtils.isGeneratedLetterAvatarURL(undefined)).toBe(false); + }); + }); + describe('getSmallSizeAvatar', () => { it('should add _128 suffix to CloudFront avatars', () => { const source = 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatar.png'; From a3ac90019ccb4d41bf81d6ce01ad57315a4afa3f Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Fri, 26 Jun 2026 14:10:25 +0100 Subject: [PATCH 13/19] Render letter avatars locally for all users (remove beta gate) --- src/components/Avatar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 3dd89f66fa17..486cc6474ba7 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -3,7 +3,6 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -76,7 +75,6 @@ function Avatar({ testID = 'Avatar', }: AvatarProps) { const defaultAvatars = useDefaultAvatars(); - const {isBetaEnabled} = usePermissions(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -92,7 +90,7 @@ function Avatar({ // Read the color and initials directly from the generated letter-avatar URL. const letterAvatarParts = parseLetterAvatarURL(source); - const shouldRenderLetterAvatar = !isWorkspace && !!letterAvatarParts && isBetaEnabled(CONST.BETAS.DEFAULT_LETTER_AVATARS); + const shouldRenderLetterAvatar = !isWorkspace && !!letterAvatarParts; let optimizedSource = source; const localFromCatalog = findLocalAvatarForURL(source); From acfd380e7e2b545d0084154f994c81cd0bf45623 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Mon, 29 Jun 2026 11:33:17 +0100 Subject: [PATCH 14/19] Use StyleUtils helpers instead of inline style in UserInitialsAvatar --- src/components/UserInitialsAvatar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/UserInitialsAvatar.tsx b/src/components/UserInitialsAvatar.tsx index d4d5c769c825..8d21cfcc67e8 100644 --- a/src/components/UserInitialsAvatar.tsx +++ b/src/components/UserInitialsAvatar.tsx @@ -1,5 +1,6 @@ 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'; @@ -29,8 +30,9 @@ type UserInitialsAvatarProps = { }; function UserInitialsAvatar({text, colors, size}: UserInitialsAvatarProps) { + const StyleUtils = useStyleUtils(); return ( - + Date: Mon, 29 Jun 2026 11:33:23 +0100 Subject: [PATCH 15/19] Extract generated letter-avatar path to CONST --- src/CONST/index.ts | 2 ++ src/libs/UserAvatarUtils.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f1077b102f30..400922abc347 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -427,6 +427,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, diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 60503df15c3b..7b01ded0d885 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -224,7 +224,7 @@ function isLetterAvatar(originalFileName?: string): boolean { * @returns True if the source is a string pointing to a generated letter avatar */ function isGeneratedLetterAvatarURL(avatarSource?: AvatarSource): boolean { - return typeof avatarSource === 'string' && avatarSource.includes('/images/avatars/generated/letter/'); + return typeof avatarSource === 'string' && avatarSource.includes(CONST.GENERATED_LETTER_AVATAR_PATH); } /** @@ -281,7 +281,7 @@ function getLetterAvatarURL(accountID: number, firstName: string, lastName: stri 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}/images/avatars/generated/letter/v1/${colorKey}/${initials}.png`; + return `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/${colorKey}/${initials}.png`; } /** From 8d5b043a4a42cd2ac26b053c27cd948d1ae5ce40 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Mon, 29 Jun 2026 11:36:54 +0100 Subject: [PATCH 16/19] Reset to default avatar via deleteAvatar when removing profile photo --- src/libs/actions/PersonalDetails.ts | 1 - src/pages/settings/Profile/Avatar/AvatarPreview.tsx | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index de5ae40c9656..8190c85d4a04 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -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 */ diff --git a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx index decaf35d0a7c..cd7caff0ab6f 100644 --- a/src/pages/settings/Profile/Avatar/AvatarPreview.tsx +++ b/src/pages/settings/Profile/Avatar/AvatarPreview.tsx @@ -14,7 +14,8 @@ 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, isCatalogAvatar, isGeneratedLetterAvatarURL, isLetterAvatar} from '@libs/UserAvatarUtils'; +import {isCatalogAvatar, isGeneratedLetterAvatarURL, isLetterAvatar} from '@libs/UserAvatarUtils'; +import {deleteAvatar} from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -107,12 +108,8 @@ function AvatarPreview({selected, avatarCaptureRef, setSelected, isAvatarCropMod }; const onImageRemoved = () => { - setSelected( - getDefaultAvatarName({ - accountID: currentUserPersonalDetails?.accountID, - accountEmail: currentUserPersonalDetails?.email, - }), - ); + deleteAvatar(currentUserPersonalDetails); + setSelected(undefined); setImageData({...EMPTY_FILE}); }; From 693a8d65c399d3f9852618f4d5ea309e6b81f4fb Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Wed, 1 Jul 2026 10:23:05 +0100 Subject: [PATCH 17/19] Address review: drop unused beta const, simplify letter-avatar condition, fix typo - Remove the unused CONST.BETAS.DEFAULT_LETTER_AVATARS flag (never referenced; the feature ships without a beta gate). - Inline the letter-avatar render check to a single condition instead of a redundant shouldRenderLetterAvatar boolean plus a repeated truthiness test. - Fix 'Weather' -> 'Whether' typo in AvatarPreview. --- src/CONST/index.ts | 1 - src/components/Avatar.tsx | 3 +-- src/pages/settings/Profile/Avatar/AvatarPreview.tsx | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 481c1c63cc32..6384dbf3b215 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -975,7 +975,6 @@ const CONST = { VENDOR_MATCHING: 'vendorMatching', RULES_REVAMP: 'rulesRevamp', COMMUTER_EXCLUSIONS: 'commuterExclusions', - DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 486cc6474ba7..af3e177a30a3 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -90,7 +90,6 @@ function Avatar({ // Read the color and initials directly from the generated letter-avatar URL. const letterAvatarParts = parseLetterAvatarURL(source); - const shouldRenderLetterAvatar = !isWorkspace && !!letterAvatarParts; let optimizedSource = source; const localFromCatalog = findLocalAvatarForURL(source); @@ -118,7 +117,7 @@ function Avatar({ iconColors = null; } - if (shouldRenderLetterAvatar && letterAvatarParts) { + if (!isWorkspace && letterAvatarParts) { return ( Date: Thu, 2 Jul 2026 16:36:01 +0100 Subject: [PATCH 18/19] Fold Latin accents to their ASCII base letter in avatar initials --- src/libs/UserAvatarUtils.ts | 25 ++++++++++++++++++------- tests/unit/UserAvatarUtilsTest.ts | 14 ++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 7b01ded0d885..ce429b85e093 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -14,6 +14,10 @@ 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 = + 'AAAAAAACEEEEIIIIDNOOOOO.OUUUUYTSAAAAAAACEEEEIIIIDNOOOOO.OUUUUYTYAAAAAACCCCCCCCDDDDEEEEEEEEEEGGGGGGGGHHHHIIIIIIIIIIIIJJKKKLLLLLLLLLLNNNNNNNNNOOOOOOOORRRRRRSSSSSSSSTTTTTTUUUUUUUUUUUUWWYYYZZZZZZS'; + /** * Avatar naming convention * @@ -228,20 +232,27 @@ function isGeneratedLetterAvatarURL(avatarSource?: AvatarSource): boolean { } /** - * Returns the first alphanumeric character of a string, uppercased, or '' when none exists. + * 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) { - // A non-ASCII codepoint has no pre-generated image, so fall back instead of skipping to a - // later ASCII letter, which would be a misleading initial for the name. - if ((character.codePointAt(0) ?? 0) >= 0x80) { - return ''; + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint < 0x80) { + if (/[a-z0-9]/i.test(character)) { + return character.toUpperCase(); + } + continue; } - if (/[a-z0-9]/i.test(character)) { - return character.toUpperCase(); + // 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 ''; } return ''; } diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 63740b06729d..54aec6d643ba 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -1,4 +1,4 @@ -// cspell:ignore Élodie José +// cspell:ignore Élodie José Øyvind import {renderHook} from '@testing-library/react-native'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; @@ -351,9 +351,15 @@ describe('UserAvatarUtils', () => { expect(UserAvatarUtils.getLetterAvatarURL(accountID, firstName, lastName, login)).toBe(expected); }); - it('falls back instead of using a later ASCII letter when the name starts with a non-ASCII character', () => { - // "Élodie" must not become "L" and a leading CJK name must not become "A". - expect(UserAvatarUtils.getLetterAvatarURL(42, 'Élodie', '', '')).toBe(''); + it('folds a Latin accented first letter to its ASCII base letter', () => { + // The color comes from the login even when the initial comes from the name. + expect(UserAvatarUtils.getLetterAvatarURL(42, 'Élodie', '', 'elodie@example.com')).toBe(`${BASE}/tangerine100/E.png`); + expect(UserAvatarUtils.getLetterAvatarURL(42, 'Élodie', 'Lee', 'elodie.lee@example.com')).toBe(`${BASE}/green700/EL.png`); + expect(UserAvatarUtils.getLetterAvatarURL(42, 'Øyvind', '', 'oyvind@example.com')).toBe(`${BASE}/tangerine100/O.png`); + }); + + it('falls back instead of using a later ASCII letter when the name starts with a non-Latin character', () => { + // A leading CJK name must not become "A". A hidden login reaches the client as an empty login, so there is no initial to fall back to. expect(UserAvatarUtils.getLetterAvatarURL(42, '李Ann', '', '')).toBe(''); // An ASCII first letter still wins even when later characters are non-ASCII. expect(UserAvatarUtils.getLetterAvatarURL(42, 'José', '', 'dave@example.com')).toBe(`${BASE}/blue100/J.png`); From bec8df8f9d3496e56003d73832f1bc586af76e45 Mon Sep 17 00:00:00 2001 From: Georgia Monahan Date: Thu, 2 Jul 2026 21:26:03 +0100 Subject: [PATCH 19/19] Exempt the accent fold table from spellcheck --- src/libs/UserAvatarUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index ce429b85e093..4676a24384ed 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -16,6 +16,7 @@ const LETTER_AVATAR_NAME_REGEX = /^letter-avatar-#[0-9A-F]{6}-#[0-9A-F]{6}-[A-Z] /** 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'; /**