From e55b38f120c7568e39be57730a5fef2ebbc4deaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 1 Jul 2026 13:45:11 +0200 Subject: [PATCH 1/9] Avatar component decomposition poc --- src/components/Avatar.tsx | 146 ------------ src/components/Avatar/AvatarBody.tsx | 60 +++++ src/components/Avatar/AvatarIcon.tsx | 53 +++++ src/components/Avatar/AvatarImage.tsx | 43 ++++ src/components/Avatar/AvatarUser.tsx | 51 ++++ src/components/Avatar/AvatarWorkspace.tsx | 36 +++ src/components/Avatar/index.tsx | 59 +++++ src/components/Avatar/types.ts | 82 +++++++ src/components/Avatar/useAvatarLoadError.ts | 18 ++ src/components/Avatar/useUserAvatarSource.ts | 56 +++++ .../Avatar/useWorkspaceAvatarSource.ts | 47 ++++ src/libs/UserAvatarUtils.ts | 9 + src/styles/utils/index.ts | 2 +- tests/unit/AvatarTest.tsx | 219 ++++++++++++++++++ tests/unit/UserAvatarUtilsTest.ts | 24 ++ 15 files changed, 758 insertions(+), 147 deletions(-) delete mode 100644 src/components/Avatar.tsx create mode 100644 src/components/Avatar/AvatarBody.tsx create mode 100644 src/components/Avatar/AvatarIcon.tsx create mode 100644 src/components/Avatar/AvatarImage.tsx create mode 100644 src/components/Avatar/AvatarUser.tsx create mode 100644 src/components/Avatar/AvatarWorkspace.tsx create mode 100644 src/components/Avatar/index.tsx create mode 100644 src/components/Avatar/types.ts create mode 100644 src/components/Avatar/useAvatarLoadError.ts create mode 100644 src/components/Avatar/useUserAvatarSource.ts create mode 100644 src/components/Avatar/useWorkspaceAvatarSource.ts create mode 100644 tests/unit/AvatarTest.tsx diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx deleted file mode 100644 index 63a828826995..000000000000 --- a/src/components/Avatar.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, {useState} from 'react'; -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; -import useDefaultAvatars from '@hooks/useDefaultAvatars'; -import useNetwork from '@hooks/useNetwork'; -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 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'; - -type AvatarProps = { - /** Source for the avatar. Can be a URL or an icon. */ - source?: AvatarSource; - - /** Extra styles to pass to Image */ - imageStyles?: StyleProp; - - /** Additional styles to pass to Icon */ - iconAdditionalStyles?: StyleProp; - - /** Extra styles to pass to View wrapper */ - containerStyles?: StyleProp; - - /** Set the size of Avatar */ - size?: AvatarSizeName; - - /** - * The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue' - * If the avatar is type === workspace, this fill color will be ignored and decided based on the name prop. - */ - fill?: string; - - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. - * If the avatar is type === workspace, this fallback icon will be ignored and decided based on the name prop. - */ - fallbackIcon?: AvatarSource; - - /** Used to locate fallback icon in end-to-end tests. */ - fallbackIconTestID?: string; - - /** Owner of the avatar. If user, displayName. If workspace, policy name */ - name?: string; - - /** Denotes whether it is an avatar or a workspace avatar */ - type: AvatarType; - - /** Optional account id if it's user avatar or policy id if it's workspace avatar */ - avatarID?: number | string; - - /** Test ID for the Avatar component */ - testID?: string; -}; - -function Avatar({ - source: originalSource, - imageStyles, - iconAdditionalStyles, - containerStyles, - size = CONST.AVATAR_SIZE.DEFAULT, - fill, - fallbackIcon, - fallbackIconTestID = '', - type, - name = '', - avatarID, - testID = 'Avatar', -}: AvatarProps) { - const defaultAvatars = useDefaultAvatars(); - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const [errorSource, setErrorSource] = useState(undefined); - const imageError = errorSource !== undefined && errorSource === originalSource; - - useNetwork({onReconnect: () => setErrorSource(undefined)}); - - const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE; - const userAccountID = isWorkspace ? undefined : (avatarID as number); - - const source = isWorkspace ? originalSource : getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars}); - let optimizedSource = source; - const localFromCatalog = findLocalAvatarForURL(source); - - if (localFromCatalog) { - optimizedSource = localFromCatalog; - } - const useFallBackAvatar = imageError || !source || source === defaultAvatars.FallbackAvatar; - const fallbackAvatar = isWorkspace ? getDefaultWorkspaceAvatar(name) : (fallbackIcon ?? defaultAvatars.FallbackAvatar) || defaultAvatars.FallbackAvatar; - const fallbackAvatarTestID = isWorkspace ? getDefaultWorkspaceAvatarTestID(name) : fallbackIconTestID || 'SvgFallbackAvatar Icon'; - const avatarSource = useFallBackAvatar ? fallbackAvatar : optimizedSource; - - // We pass the color styles down to the SVG for the workspace and fallback avatar. - const iconSize = StyleUtils.getAvatarSize(size); - const imageStyle: StyleProp = [StyleUtils.getAvatarStyle(size), imageStyles, styles.noBorderRadius]; - const iconStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined; - - let iconColors; - if (isWorkspace) { - iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); - // Assign the icon fill color only for the default fallback avatar - } else if (useFallBackAvatar && avatarSource === defaultAvatars.FallbackAvatar) { - iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); - } else { - iconColors = null; - } - return ( - - {typeof avatarSource === 'string' ? ( - - {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} - setErrorSource(typeof originalSource === 'string' ? originalSource : undefined)} - cachePolicy="memory-disk" - /> - - ) : ( - - - - )} - - ); -} - -export type {AvatarProps}; -export default Avatar; diff --git a/src/components/Avatar/AvatarBody.tsx b/src/components/Avatar/AvatarBody.tsx new file mode 100644 index 000000000000..eda0b4f1e718 --- /dev/null +++ b/src/components/Avatar/AvatarBody.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import useTheme from '@hooks/useTheme'; +import type {AvatarSizeName} from '@styles/utils'; +import type {AvatarType} from '@src/types/onyx/OnyxCommon'; +import AvatarIcon from './AvatarIcon'; +import AvatarImage from './AvatarImage'; +import type {ResolvedAvatar} from './types'; + +type AvatarBodyProps = { + /** The resolved avatar model that selects which branch to render. */ + resolvedAvatar: ResolvedAvatar; + + /** Set the size of Avatar */ + size: AvatarSizeName; + + /** Denotes avatar type (currently user avatar vs workspace avatar) */ + type: AvatarType; + + /** The fill color for the icon */ + fill?: string; + + /** Styles to pass */ + imageStyles?: StyleProp; + + /** Extra styles to pass to Icon */ + iconAdditionalStyles?: StyleProp; +}; + +/** Renders a resolved avatar as either a remote image or an SVG icon. */ +function AvatarBody({resolvedAvatar, size, type, imageStyles, iconAdditionalStyles, fill}: AvatarBodyProps) { + const theme = useTheme(); + if (resolvedAvatar.isImageSource) { + return ( + + ); + } + + return ( + + ); +} + +export default AvatarBody; diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/AvatarIcon.tsx new file mode 100644 index 000000000000..1077fed5ab91 --- /dev/null +++ b/src/components/Avatar/AvatarIcon.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {SVGAvatarColorStyle} from '@styles/utils/types'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {AvatarBranchCommonProps} from './types'; + +type AvatarIconProps = AvatarBranchCommonProps & { + /** Icon asset to render as the avatar. */ + avatarSource: IconAsset; + + /** Test ID used for the fallback avatar. */ + fallbackAvatarTestID: string; + + /** Styles for View wrapping Icon / Image. */ + iconContainerStyles?: StyleProp; + + /** Fill and background colors for the icon, or null to use the defaults. */ + iconColors: SVGAvatarColorStyle | null; + + /** The fill color for the icon */ + fill?: string; + + /** Additional styles for Icon */ + iconAdditionalStyles?: StyleProp; +}; + +/** Renders an avatar as an SVG icon. */ +function AvatarIcon({avatarSource, size, type, iconContainerStyles, iconAdditionalStyles, fallbackAvatarTestID, iconColors, fill}: AvatarIconProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const iconSize = StyleUtils.getAvatarSize(size); + const containerStyles = iconContainerStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, iconContainerStyles] : undefined; + const additionalStyles = [StyleUtils.getAvatarBorderStyle(size, type), iconColors, iconAdditionalStyles]; + + return ( + + + + ); +} + +export default AvatarIcon; diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx new file mode 100644 index 000000000000..eea727d7ee7a --- /dev/null +++ b/src/components/Avatar/AvatarImage.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Image from '@components/Image'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {AvatarBranchCommonProps} from './types'; + +type AvatarImageProps = AvatarBranchCommonProps & { + /** URL of the remote avatar image. */ + avatarSource: string; + + /** Callback invoked when the avatar image fails to load. */ + onImageError: () => void; + + /** Styles for View wrapping Icon / Image. */ + imageStyles?: StyleProp; + + /** Additional styles for Icon */ + imageContainerAdditionalStyles?: StyleProp; +}; + +/** Renders an avatar as a remote image. */ +function AvatarImage({avatarSource, size, type, imageStyles, imageContainerAdditionalStyles, onImageError}: AvatarImageProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const imageStyle = [StyleUtils.getAvatarStyle(size), imageStyles, styles.noBorderRadius]; + const imageContainerStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined; + + return ( + + {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} + + + ); +} + +export default AvatarImage; diff --git a/src/components/Avatar/AvatarUser.tsx b/src/components/Avatar/AvatarUser.tsx new file mode 100644 index 000000000000..e8444199b80b --- /dev/null +++ b/src/components/Avatar/AvatarUser.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import CONST from '@src/CONST'; +import AvatarBody from './AvatarBody'; +import type {AvatarCommonProps} from './types'; +import useUserAvatarSource from './useUserAvatarSource'; + +type UserAvatarProps = AvatarCommonProps & { + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: AvatarSource; + + /** Used to locate fallback icon in end-to-end tests. */ + fallbackIconTestID?: string; +}; + +/** Renders a user avatar, falling back to a default icon when no source is available. */ +function AvatarUser({ + source, + imageStyles, + iconAdditionalStyles, + containerStyles, + size = CONST.AVATAR_SIZE.DEFAULT, + fill, + fallbackIcon, + fallbackIconTestID = '', + avatarID, + testID = 'Avatar', +}: UserAvatarProps) { + const styles = useThemeStyles(); + const resolvedAvatar = useUserAvatarSource({source, avatarID, fallbackIcon, fallbackIconTestID}); + + return ( + + + + ); +} + +export default AvatarUser; diff --git a/src/components/Avatar/AvatarWorkspace.tsx b/src/components/Avatar/AvatarWorkspace.tsx new file mode 100644 index 000000000000..2316b6c627d7 --- /dev/null +++ b/src/components/Avatar/AvatarWorkspace.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import AvatarBody from './AvatarBody'; +import type {AvatarCommonProps} from './types'; +import useWorkspaceAvatarSource from './useWorkspaceAvatarSource'; + +type WorkspaceAvatarProps = AvatarCommonProps & { + /** Owner of the avatar. Policy name */ + name?: string; +}; + +/** Renders a workspace avatar, falling back to a default icon derived from the workspace name. */ +function AvatarWorkspace({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, fill, name = '', avatarID, testID = 'Avatar'}: WorkspaceAvatarProps) { + const styles = useThemeStyles(); + const resolvedAvatar = useWorkspaceAvatarSource({source, name, avatarID}); + + return ( + + + + ); +} + +export default AvatarWorkspace; diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx new file mode 100644 index 000000000000..a97b06e4bcd4 --- /dev/null +++ b/src/components/Avatar/index.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import CONST from '@src/CONST'; +import type {AvatarType} from '@src/types/onyx/OnyxCommon'; +import AvatarUser from './AvatarUser'; +import AvatarWorkspace from './AvatarWorkspace'; +import type {AvatarCommonProps} from './types'; + +type AvatarProps = AvatarCommonProps & { + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: AvatarSource; + + /** Used to locate fallback icon in end-to-end tests. */ + fallbackIconTestID?: string; + + /** Owner of the avatar. If user, displayName. If workspace, policy name */ + name?: string; + + /** Denotes whether it is an avatar or a workspace avatar */ + type: AvatarType; +}; + +/** Renders a user or workspace avatar depending on the `type` prop. */ +function Avatar({type, name, fallbackIcon, fallbackIconTestID, source, imageStyles, iconAdditionalStyles, containerStyles, size, fill, testID, avatarID}: AvatarProps) { + if (type === CONST.ICON_TYPE_WORKSPACE) { + return ( + + ); + } + + return ( + + ); +} + +export default Avatar; + +export type {AvatarProps}; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts new file mode 100644 index 000000000000..b16535aa4e8f --- /dev/null +++ b/src/components/Avatar/types.ts @@ -0,0 +1,82 @@ +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import type {AvatarSizeName} from '@styles/utils'; +import type {SVGAvatarColorStyle} from '@styles/utils/types'; +import type {AvatarType} from '@src/types/onyx/OnyxCommon'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type AvatarCommonProps = { + /** Source for the avatar. Can be a URL or an icon. */ + source?: AvatarSource; + + /** Extra styles to pass to Image */ + imageStyles?: StyleProp; + + /** Additional styles to pass to Icon */ + iconAdditionalStyles?: StyleProp; + + /** Extra styles to pass to View wrapper */ + containerStyles?: StyleProp; + + /** Set the size of Avatar */ + size?: AvatarSizeName; + + /** + * The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue' + * If the avatar is type === workspace, this fill color will be ignored and decided based on the name prop. + */ + fill?: string; + + /** Test ID for the Avatar component */ + testID?: string; + + /** Optional account id if it's user avatar */ + avatarID?: number | string; +}; + +type ResolvedAvatarBase = { + /** Whether the remote image failed to load, forcing the fallback icon. */ + hasImageError: boolean; + + /** Test ID used to locate the fallback avatar icon in end-to-end tests. */ + fallbackAvatarTestID: string; + + /** Callback invoked when the avatar image fails to load. */ + onImageError: () => void; +}; + +type ResolvedImageAvatar = ResolvedAvatarBase & { + /** Discriminant marking this as a remote image avatar. */ + isImageSource: true; + + /** URL of the remote avatar image. */ + avatarSource: string; + + /** Icon colors are unused for image avatars. */ + iconColors: null; +}; + +type ResolvedIconAvatar = ResolvedAvatarBase & { + /** Discriminant marking this as an SVG icon avatar. */ + isImageSource: false; + + /** Icon asset to render as the avatar. */ + avatarSource: IconAsset; + + /** Fill and background colors for the icon, or null to use the defaults. */ + iconColors: SVGAvatarColorStyle | null; +}; + +type ResolvedAvatar = ResolvedImageAvatar | ResolvedIconAvatar; + +type AvatarBranchCommonProps = { + /** Size of Avatar */ + size: AvatarSizeName; + + /** Denotes whether it is an avatar or a workspace avatar */ + type: AvatarType; + + +}; + +export type {AvatarCommonProps, ResolvedAvatar, AvatarBranchCommonProps}; diff --git a/src/components/Avatar/useAvatarLoadError.ts b/src/components/Avatar/useAvatarLoadError.ts new file mode 100644 index 000000000000..48162f561df0 --- /dev/null +++ b/src/components/Avatar/useAvatarLoadError.ts @@ -0,0 +1,18 @@ +import {useState} from 'react'; +import useNetwork from '@hooks/useNetwork'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; + +function useAvatarLoadError(originalSource?: AvatarSource) { + const [errorSource, setErrorSource] = useState(); + const hasImageError = errorSource !== undefined && errorSource === originalSource; + + useNetwork({onReconnect: () => setErrorSource(undefined)}); + + const onImageError = () => { + setErrorSource(typeof originalSource === 'string' ? originalSource : undefined); + }; + + return {hasImageError, onImageError}; +} + +export default useAvatarLoadError; diff --git a/src/components/Avatar/useUserAvatarSource.ts b/src/components/Avatar/useUserAvatarSource.ts new file mode 100644 index 000000000000..4ac35ce8a128 --- /dev/null +++ b/src/components/Avatar/useUserAvatarSource.ts @@ -0,0 +1,56 @@ +import useDefaultAvatars from '@hooks/useDefaultAvatars'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {getAvatar, optimizeAvatarSource} from '@libs/UserAvatarUtils'; +import type {ResolvedAvatar} from './types'; +import useAvatarLoadError from './useAvatarLoadError'; + +type UseUserAvatarSourceParams = { + source?: AvatarSource; + avatarID?: number | string; + fallbackIcon?: AvatarSource; + fallbackIconTestID?: string; +}; + +function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fallbackIconTestID = ''}: UseUserAvatarSourceParams): ResolvedAvatar { + const defaultAvatars = useDefaultAvatars(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {hasImageError, onImageError} = useAvatarLoadError(originalSource); + + const userAccountID = typeof avatarID === 'number' ? avatarID : undefined; + const source = getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars}); + const optimizedSource = optimizeAvatarSource(source); + const useFallBackAvatar = hasImageError || !source || source === defaultAvatars.FallbackAvatar; + const fallbackAvatar = (fallbackIcon ?? defaultAvatars.FallbackAvatar) || defaultAvatars.FallbackAvatar; + const fallbackAvatarTestID = fallbackIconTestID || 'SvgFallbackAvatar Icon'; + const avatarSource = useFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); + + if (typeof avatarSource === 'string') { + return { + avatarSource, + isImageSource: true, + hasImageError, + iconColors: null, + fallbackAvatarTestID, + onImageError, + }; + } + + let iconColors = null; + if (useFallBackAvatar && avatarSource === defaultAvatars.FallbackAvatar) { + iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); + } + + return { + avatarSource, + isImageSource: false, + hasImageError, + iconColors, + fallbackAvatarTestID, + onImageError, + }; +} + +export default useUserAvatarSource; diff --git a/src/components/Avatar/useWorkspaceAvatarSource.ts b/src/components/Avatar/useWorkspaceAvatarSource.ts new file mode 100644 index 000000000000..174f16ed2dfa --- /dev/null +++ b/src/components/Avatar/useWorkspaceAvatarSource.ts @@ -0,0 +1,47 @@ +import useStyleUtils from '@hooks/useStyleUtils'; +import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {optimizeAvatarSource} from '@libs/UserAvatarUtils'; +import type {ResolvedAvatar} from './types'; +import useAvatarLoadError from './useAvatarLoadError'; + +type UseWorkspaceAvatarSourceParams = { + source?: AvatarSource; + name?: string; + avatarID?: number | string; +}; + +function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: UseWorkspaceAvatarSourceParams): ResolvedAvatar { + const StyleUtils = useStyleUtils(); + const {hasImageError, onImageError} = useAvatarLoadError(originalSource); + + const source = originalSource; + const optimizedSource = optimizeAvatarSource(source); + const useFallBackAvatar = hasImageError || !source; + const fallbackAvatar = getDefaultWorkspaceAvatar(name); + const fallbackAvatarTestID = getDefaultWorkspaceAvatarTestID(name); + const avatarSource = useFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); + const iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); + + if (typeof avatarSource === 'string') { + return { + avatarSource, + isImageSource: true, + hasImageError, + iconColors: null, + fallbackAvatarTestID, + onImageError, + }; + } + + return { + avatarSource, + isImageSource: false, + hasImageError, + iconColors, + fallbackAvatarTestID, + onImageError, + }; +} + +export default useWorkspaceAvatarSource; diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index b5628971b99c..a67d8530f003 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -306,6 +306,14 @@ function getSmallSizeAvatar(args: GetAvatarArgsType & DefaultAvatarsType): Avata return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; } +/** + * Swaps a catalog-backed avatar URL for its bundled local SVG so it renders without a network request. + * Non-catalog sources (uploaded image URLs, SVG components, or undefined) are returned unchanged. + */ +function optimizeAvatarSource(source?: AvatarSource): AvatarSource | undefined { + return findLocalAvatarForURL(source) ?? source; +} + export { getAvatar, getAvatarURL, @@ -319,5 +327,6 @@ export { isCatalogAvatar, isDefaultAvatar, isLetterAvatar, + optimizeAvatarSource, }; export type {AvatarSource}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index f974eec5c98c..e2531f9d0d98 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -306,7 +306,7 @@ function getAvatarSubscriptIconContainerStyle(iconWidth = 16, iconHeight = 16): /** * Helper method to return workspace avatar color styles */ -function getDefaultWorkspaceAvatarColor(text: string): ViewStyle { +function getDefaultWorkspaceAvatarColor(text: string): SVGAvatarColorStyle { const colorHash = hashText(text.trim(), workspaceColorOptions.length); return workspaceColorOptions.at(colorHash) ?? DEFAULT_WORKSPACE_COLOR; } diff --git a/tests/unit/AvatarTest.tsx b/tests/unit/AvatarTest.tsx new file mode 100644 index 000000000000..fe4ba977c81f --- /dev/null +++ b/tests/unit/AvatarTest.tsx @@ -0,0 +1,219 @@ +import {render, screen} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; +import AvatarUser from '@components/Avatar/AvatarUser'; +import AvatarWorkspace from '@components/Avatar/AvatarWorkspace'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ThemeProvider from '@components/ThemeProvider'; +import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; +import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog'; +import {getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const UPLOADED_AVATAR_URL = 'https://example.com/uploaded-avatar.jpg'; +const DEFAULT_AVATAR_URL = USER_AVATARS.entries['default-avatar_1'].url; +const FALLBACK_ICON_TEST_ID = 'SvgFallbackAvatar Icon'; +const AVATAR_IMAGE_TEST_ID = 'AvatarImage'; +const WORKSPACE_NAME = "Cathy's Croissants"; + +// Captures the mocked 's onError callback so tests can simulate a failed image load. +const mockImageErrorHandlerRef: {current?: () => void} = {current: undefined}; + +// Renders a bare React Native as a stand-in for a mocked component. Prefixed with +// `mock` so the hoisted jest.mock factories below are allowed to reference it. +function mockRenderView({testID, accessibilityLabel}: {testID?: string; accessibilityLabel?: string}) { + return ( + + ); +} + +jest.mock('@hooks/useLazyAsset', () => { + function FallbackAvatar() { + return mockRenderView({testID: 'MockFallbackAvatar'}); + } + + return { + useMemoizedLazyExpensifyIcons: () => ({ + ConciergeAvatar: FallbackAvatar, + NotificationsAvatar: FallbackAvatar, + FallbackAvatar, + }), + }; +}); + +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +jest.mock('@components/Image', () => { + function MockImage({source, onError}: {source?: {uri?: string}; onError?: () => void}) { + mockImageErrorHandlerRef.current = onError; + return mockRenderView({testID: 'AvatarImage', accessibilityLabel: source?.uri}); + } + + return {__esModule: true, default: MockImage}; +}); + +function ThemeProviderWithLight({children}: {children: React.ReactNode}) { + return {children}; +} + +const hiddenElementOptions = {includeHiddenElements: true}; + +function getHiddenTestId(testID: string) { + return screen.getByTestId(testID, hiddenElementOptions); +} + +function queryHiddenTestId(testID: string) { + return screen.queryByTestId(testID, hiddenElementOptions); +} + +function renderAvatar(props: React.ComponentProps) { + return render( + + + , + ); +} + +describe('Avatar', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockImageErrorHandlerRef.current = undefined; + }); + + describe('user avatar', () => { + it('renders the Image branch for an uploaded URL source', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: UPLOADED_AVATAR_URL, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId('Avatar')).toBeTruthy(); + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID).props.accessibilityLabel).toBe(UPLOADED_AVATAR_URL); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + }); + + it('renders the Icon branch for a default catalog avatar URL', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: DEFAULT_AVATAR_URL, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeTruthy(); + }); + + it('renders the fallback Icon when no source is provided', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeTruthy(); + }); + + it('switches from the Image branch to the fallback Icon when the image fails to load', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: UPLOADED_AVATAR_URL, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + + mockImageErrorHandlerRef.current?.(); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeTruthy(); + }); + }); + + describe('workspace avatar', () => { + it('renders the Image branch for an uploaded workspace logo URL', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_WORKSPACE, + source: UPLOADED_AVATAR_URL, + name: WORKSPACE_NAME, + avatarID: 'policy_123', + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID).props.accessibilityLabel).toBe(UPLOADED_AVATAR_URL); + expect(queryHiddenTestId(getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME))).toBeNull(); + }); + + it('renders the default workspace Icon when no source is provided', async () => { + const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); + + renderAvatar({ + type: CONST.ICON_TYPE_WORKSPACE, + name: WORKSPACE_NAME, + avatarID: 'policy_123', + }); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); + }); + }); + + describe('compound roots', () => { + it('Avatar.User renders the same as the back-compat default for user avatars', async () => { + render( + + + , + ); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId('Avatar')).toBeTruthy(); + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + }); + + it('Avatar.Workspace renders the same as the back-compat default for workspace avatars', async () => { + const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); + + render( + + + , + ); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); + }); + }); +}); diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 9381a3790d85..9b562c009c06 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -1,6 +1,7 @@ import {renderHook} from '@testing-library/react-native'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; +import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog'; import CONST from '@src/CONST'; import * as UserAvatarUtils from '@src/libs/UserAvatarUtils'; @@ -297,4 +298,27 @@ describe('UserAvatarUtils', () => { expect(name).toBe('default-avatar_20'); }); }); + + describe('optimizeAvatarSource', () => { + it('should return the bundled local SVG for a catalog avatar URL', () => { + const catalogAvatar = USER_AVATARS.entries['default-avatar_1']; + const result = UserAvatarUtils.optimizeAvatarSource(catalogAvatar.url); + + expect(result).toBe(catalogAvatar.local); + expect(typeof result).toBe('function'); + }); + + it('should return the original URL unchanged for a non-catalog (uploaded) URL', () => { + const uploadedURL = 'https://example.com/uploaded-avatar.png'; + expect(UserAvatarUtils.optimizeAvatarSource(uploadedURL)).toBe(uploadedURL); + }); + + it('should return an SVG component source unchanged', () => { + expect(UserAvatarUtils.optimizeAvatarSource(defaultAvatars.Avatar1)).toBe(defaultAvatars.Avatar1); + }); + + it('should return undefined when no source is provided', () => { + expect(UserAvatarUtils.optimizeAvatarSource(undefined)).toBeUndefined(); + }); + }); }); From 5630d215126f1acd3db2732069845271d51de290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 1 Jul 2026 15:24:45 +0200 Subject: [PATCH 2/9] fix prettier --- src/components/Avatar/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts index b16535aa4e8f..bc0ac96dc5a0 100644 --- a/src/components/Avatar/types.ts +++ b/src/components/Avatar/types.ts @@ -75,8 +75,6 @@ type AvatarBranchCommonProps = { /** Denotes whether it is an avatar or a workspace avatar */ type: AvatarType; - - }; export type {AvatarCommonProps, ResolvedAvatar, AvatarBranchCommonProps}; From eb13be1ea6c0f9b24e4d99a6d5be049aea2c34b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 7 Jul 2026 14:37:59 +0200 Subject: [PATCH 3/9] resolve differences after merge; add AvatarInitials --- src/components/Avatar.tsx | 175 ------------------ src/components/Avatar/AvatarBody.tsx | 30 ++- src/components/Avatar/AvatarIcon.tsx | 12 +- src/components/Avatar/AvatarImage.tsx | 10 +- src/components/Avatar/AvatarInitials.tsx | 47 +++++ src/components/Avatar/AvatarUser.tsx | 11 +- src/components/Avatar/AvatarWorkspace.tsx | 10 +- src/components/Avatar/index.tsx | 8 +- src/components/Avatar/types.ts | 26 ++- src/components/Avatar/useAvatarLoadError.ts | 4 +- src/components/Avatar/useUserAvatarSource.ts | 21 ++- .../Avatar/useWorkspaceAvatarSource.ts | 8 +- tests/unit/AvatarTest.tsx | 53 +++++- 13 files changed, 204 insertions(+), 211 deletions(-) delete mode 100644 src/components/Avatar.tsx create mode 100644 src/components/Avatar/AvatarInitials.tsx diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx deleted file mode 100644 index ac28f479b750..000000000000 --- a/src/components/Avatar.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import useDefaultAvatars from '@hooks/useDefaultAvatars'; -import useNetwork from '@hooks/useNetwork'; -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, parseLetterAvatarURL} from '@libs/UserAvatarUtils'; - -import type {AvatarSizeName} from '@styles/utils'; - -import CONST from '@src/CONST'; -import type {AvatarType} from '@src/types/onyx/OnyxCommon'; - -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; - -import React, {useState} from 'react'; -import {View} from 'react-native'; - -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. */ - source?: AvatarSource; - - /** Extra styles to pass to Image */ - imageStyles?: StyleProp; - - /** Additional styles to pass to Icon */ - iconAdditionalStyles?: StyleProp; - - /** Extra styles to pass to View wrapper */ - containerStyles?: StyleProp; - - /** Set the size of Avatar */ - size?: AvatarSizeName; - - /** - * The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue' - * If the avatar is type === workspace, this fill color will be ignored and decided based on the name prop. - */ - fill?: string; - - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. - * If the avatar is type === workspace, this fallback icon will be ignored and decided based on the name prop. - */ - fallbackIcon?: AvatarSource; - - /** Used to locate fallback icon in end-to-end tests. */ - fallbackIconTestID?: string; - - /** Owner of the avatar. If user, displayName. If workspace, policy name */ - name?: string; - - /** Denotes whether it is an avatar or a workspace avatar */ - type: AvatarType; - - /** Optional account id if it's user avatar or policy id if it's workspace avatar */ - avatarID?: number | string; - - /** Test ID for the Avatar component */ - testID?: string; -}; - -function Avatar({ - source: originalSource, - imageStyles, - iconAdditionalStyles, - containerStyles, - size = CONST.AVATAR_SIZE.DEFAULT, - fill, - fallbackIcon, - fallbackIconTestID = '', - type, - name = '', - avatarID, - testID = 'Avatar', -}: AvatarProps) { - const defaultAvatars = useDefaultAvatars(); - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const [errorSource, setErrorSource] = useState(undefined); - const imageError = errorSource !== undefined && errorSource === originalSource; - - useNetwork({onReconnect: () => setErrorSource(undefined)}); - - const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE; - 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); - - if (localFromCatalog) { - optimizedSource = localFromCatalog; - } - const useFallBackAvatar = imageError || !source || source === defaultAvatars.FallbackAvatar; - const fallbackAvatar = isWorkspace ? getDefaultWorkspaceAvatar(name) : (fallbackIcon ?? defaultAvatars.FallbackAvatar) || defaultAvatars.FallbackAvatar; - const fallbackAvatarTestID = isWorkspace ? getDefaultWorkspaceAvatarTestID(name) : fallbackIconTestID || 'SvgFallbackAvatar Icon'; - const avatarSource = useFallBackAvatar ? fallbackAvatar : optimizedSource; - - // We pass the color styles down to the SVG for the workspace and fallback avatar. - const iconSize = StyleUtils.getAvatarSize(size); - const imageStyle: StyleProp = [StyleUtils.getAvatarStyle(size), imageStyles, styles.noBorderRadius]; - const iconStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined; - - let iconColors; - if (isWorkspace) { - iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); - // Assign the icon fill color only for the default fallback avatar - } else if (useFallBackAvatar && avatarSource === defaultAvatars.FallbackAvatar) { - iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); - } else { - iconColors = null; - } - - if (!isWorkspace && letterAvatarParts) { - return ( - - - - - - ); - } - - return ( - - {typeof avatarSource === 'string' ? ( - - {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} - setErrorSource(typeof originalSource === 'string' ? originalSource : undefined)} - cachePolicy="memory-disk" - /> - - ) : ( - - - - )} - - ); -} - -export type {AvatarProps}; -export default Avatar; diff --git a/src/components/Avatar/AvatarBody.tsx b/src/components/Avatar/AvatarBody.tsx index eda0b4f1e718..00b7d71ac35b 100644 --- a/src/components/Avatar/AvatarBody.tsx +++ b/src/components/Avatar/AvatarBody.tsx @@ -1,11 +1,18 @@ -import React from 'react'; -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import useTheme from '@hooks/useTheme'; + import type {AvatarSizeName} from '@styles/utils'; + import type {AvatarType} from '@src/types/onyx/OnyxCommon'; + +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +import type {ResolvedAvatar} from './types'; + import AvatarIcon from './AvatarIcon'; import AvatarImage from './AvatarImage'; -import type {ResolvedAvatar} from './types'; +import AvatarInitials from './AvatarInitials'; type AvatarBodyProps = { /** The resolved avatar model that selects which branch to render. */ @@ -27,10 +34,23 @@ type AvatarBodyProps = { iconAdditionalStyles?: StyleProp; }; -/** Renders a resolved avatar as either a remote image or an SVG icon. */ +/** Renders a resolved avatar as a remote image, an SVG icon, or locally drawn initials. */ function AvatarBody({resolvedAvatar, size, type, imageStyles, iconAdditionalStyles, fill}: AvatarBodyProps) { const theme = useTheme(); - if (resolvedAvatar.isImageSource) { + if (resolvedAvatar.variant === 'initials') { + return ( + + ); + } + + if (resolvedAvatar.variant === 'image') { return ( ; + + /** Additional styles for the wrapping View. */ + initialsAdditionalStyles?: StyleProp; +}; + +/** Renders an avatar as locally drawn user initials, replacing the backend-generated letter-avatar image. */ +function AvatarInitials({initials, colors, size, type, initialsContainerStyles, initialsAdditionalStyles}: AvatarInitialsProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const avatarSize = StyleUtils.getAvatarSize(size); + const containerStyles = initialsContainerStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, initialsContainerStyles] : undefined; + + return ( + + + + ); +} + +export default AvatarInitials; diff --git a/src/components/Avatar/AvatarUser.tsx b/src/components/Avatar/AvatarUser.tsx index e8444199b80b..e47a4299bd9a 100644 --- a/src/components/Avatar/AvatarUser.tsx +++ b/src/components/Avatar/AvatarUser.tsx @@ -1,10 +1,15 @@ -import React from 'react'; -import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; + import type {AvatarSource} from '@libs/UserAvatarUtils'; + import CONST from '@src/CONST'; -import AvatarBody from './AvatarBody'; + +import React from 'react'; +import {View} from 'react-native'; + import type {AvatarCommonProps} from './types'; + +import AvatarBody from './AvatarBody'; import useUserAvatarSource from './useUserAvatarSource'; type UserAvatarProps = AvatarCommonProps & { diff --git a/src/components/Avatar/AvatarWorkspace.tsx b/src/components/Avatar/AvatarWorkspace.tsx index 2316b6c627d7..77f3a7bb9609 100644 --- a/src/components/Avatar/AvatarWorkspace.tsx +++ b/src/components/Avatar/AvatarWorkspace.tsx @@ -1,9 +1,13 @@ -import React from 'react'; -import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; + import CONST from '@src/CONST'; -import AvatarBody from './AvatarBody'; + +import React from 'react'; +import {View} from 'react-native'; + import type {AvatarCommonProps} from './types'; + +import AvatarBody from './AvatarBody'; import useWorkspaceAvatarSource from './useWorkspaceAvatarSource'; type WorkspaceAvatarProps = AvatarCommonProps & { diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx index a97b06e4bcd4..4bf75a3d75c7 100644 --- a/src/components/Avatar/index.tsx +++ b/src/components/Avatar/index.tsx @@ -1,10 +1,14 @@ -import React from 'react'; import type {AvatarSource} from '@libs/UserAvatarUtils'; + import CONST from '@src/CONST'; import type {AvatarType} from '@src/types/onyx/OnyxCommon'; + +import React from 'react'; + +import type {AvatarCommonProps} from './types'; + import AvatarUser from './AvatarUser'; import AvatarWorkspace from './AvatarWorkspace'; -import type {AvatarCommonProps} from './types'; type AvatarProps = AvatarCommonProps & { /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts index bc0ac96dc5a0..56b8efb08c52 100644 --- a/src/components/Avatar/types.ts +++ b/src/components/Avatar/types.ts @@ -1,10 +1,14 @@ -import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import type {LetterAvatarColorStyle} from '@libs/Avatars/letterAvatarPalette'; import type {AvatarSource} from '@libs/UserAvatarUtils'; + import type {AvatarSizeName} from '@styles/utils'; import type {SVGAvatarColorStyle} from '@styles/utils/types'; + import type {AvatarType} from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; + type AvatarCommonProps = { /** Source for the avatar. Can be a URL or an icon. */ source?: AvatarSource; @@ -47,18 +51,15 @@ type ResolvedAvatarBase = { type ResolvedImageAvatar = ResolvedAvatarBase & { /** Discriminant marking this as a remote image avatar. */ - isImageSource: true; + variant: 'image'; /** URL of the remote avatar image. */ avatarSource: string; - - /** Icon colors are unused for image avatars. */ - iconColors: null; }; type ResolvedIconAvatar = ResolvedAvatarBase & { /** Discriminant marking this as an SVG icon avatar. */ - isImageSource: false; + variant: 'icon'; /** Icon asset to render as the avatar. */ avatarSource: IconAsset; @@ -67,7 +68,18 @@ type ResolvedIconAvatar = ResolvedAvatarBase & { iconColors: SVGAvatarColorStyle | null; }; -type ResolvedAvatar = ResolvedImageAvatar | ResolvedIconAvatar; +type ResolvedInitialsAvatar = { + /** Discriminant marking this as a locally rendered initials (letter) avatar. */ + variant: 'initials'; + + /** The initials to render. */ + initials: string; + + /** Background and fill colors parsed from the generated letter-avatar URL. */ + colors: LetterAvatarColorStyle; +}; + +type ResolvedAvatar = ResolvedImageAvatar | ResolvedIconAvatar | ResolvedInitialsAvatar; type AvatarBranchCommonProps = { /** Size of Avatar */ diff --git a/src/components/Avatar/useAvatarLoadError.ts b/src/components/Avatar/useAvatarLoadError.ts index 48162f561df0..a0d8c8575c6f 100644 --- a/src/components/Avatar/useAvatarLoadError.ts +++ b/src/components/Avatar/useAvatarLoadError.ts @@ -1,7 +1,9 @@ -import {useState} from 'react'; import useNetwork from '@hooks/useNetwork'; + import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {useState} from 'react'; + function useAvatarLoadError(originalSource?: AvatarSource) { const [errorSource, setErrorSource] = useState(); const hasImageError = errorSource !== undefined && errorSource === originalSource; diff --git a/src/components/Avatar/useUserAvatarSource.ts b/src/components/Avatar/useUserAvatarSource.ts index 4ac35ce8a128..0062036a35af 100644 --- a/src/components/Avatar/useUserAvatarSource.ts +++ b/src/components/Avatar/useUserAvatarSource.ts @@ -1,9 +1,12 @@ import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; + import type {AvatarSource} from '@libs/UserAvatarUtils'; -import {getAvatar, optimizeAvatarSource} from '@libs/UserAvatarUtils'; +import {getAvatar, optimizeAvatarSource, parseLetterAvatarURL} from '@libs/UserAvatarUtils'; + import type {ResolvedAvatar} from './types'; + import useAvatarLoadError from './useAvatarLoadError'; type UseUserAvatarSourceParams = { @@ -21,6 +24,17 @@ function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fa const userAccountID = typeof avatarID === 'number' ? avatarID : undefined; const source = getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars}); + + // Generated letter-avatar URLs are not fetched — their color and initials are parsed out and drawn locally. + const letterAvatar = parseLetterAvatarURL(source); + if (letterAvatar) { + return { + variant: 'initials', + initials: letterAvatar.initials, + colors: letterAvatar.colors, + }; + } + const optimizedSource = optimizeAvatarSource(source); const useFallBackAvatar = hasImageError || !source || source === defaultAvatars.FallbackAvatar; const fallbackAvatar = (fallbackIcon ?? defaultAvatars.FallbackAvatar) || defaultAvatars.FallbackAvatar; @@ -30,9 +44,8 @@ function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fa if (typeof avatarSource === 'string') { return { avatarSource, - isImageSource: true, + variant: 'image', hasImageError, - iconColors: null, fallbackAvatarTestID, onImageError, }; @@ -45,7 +58,7 @@ function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fa return { avatarSource, - isImageSource: false, + variant: 'icon', hasImageError, iconColors, fallbackAvatarTestID, diff --git a/src/components/Avatar/useWorkspaceAvatarSource.ts b/src/components/Avatar/useWorkspaceAvatarSource.ts index 174f16ed2dfa..70446155335b 100644 --- a/src/components/Avatar/useWorkspaceAvatarSource.ts +++ b/src/components/Avatar/useWorkspaceAvatarSource.ts @@ -1,8 +1,11 @@ import useStyleUtils from '@hooks/useStyleUtils'; + import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {optimizeAvatarSource} from '@libs/UserAvatarUtils'; + import type {ResolvedAvatar} from './types'; + import useAvatarLoadError from './useAvatarLoadError'; type UseWorkspaceAvatarSourceParams = { @@ -26,9 +29,8 @@ function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: if (typeof avatarSource === 'string') { return { avatarSource, - isImageSource: true, + variant: 'image', hasImageError, - iconColors: null, fallbackAvatarTestID, onImageError, }; @@ -36,7 +38,7 @@ function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: return { avatarSource, - isImageSource: false, + variant: 'icon', hasImageError, iconColors, fallbackAvatarTestID, diff --git a/tests/unit/AvatarTest.tsx b/tests/unit/AvatarTest.tsx index fe4ba977c81f..f43b2fb307f2 100644 --- a/tests/unit/AvatarTest.tsx +++ b/tests/unit/AvatarTest.tsx @@ -1,6 +1,5 @@ import {render, screen} from '@testing-library/react-native'; -import React from 'react'; -import {View} from 'react-native'; + import Avatar from '@components/Avatar'; import AvatarUser from '@components/Avatar/AvatarUser'; import AvatarWorkspace from '@components/Avatar/AvatarWorkspace'; @@ -9,13 +8,22 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ThemeProvider from '@components/ThemeProvider'; import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; + import {USER_AVATARS} from '@libs/Avatars/UserAvatarCatalog'; import {getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils'; + import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const UPLOADED_AVATAR_URL = 'https://example.com/uploaded-avatar.jpg'; const DEFAULT_AVATAR_URL = USER_AVATARS.entries['default-avatar_1'].url; +const LETTER_AVATAR_INITIALS = 'JD'; +const LETTER_AVATAR_URL = `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/blue100/${LETTER_AVATAR_INITIALS}.png`; +const SMALL_LETTER_AVATAR_URL = `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/blue100/${LETTER_AVATAR_INITIALS}_128.png`; const FALLBACK_ICON_TEST_ID = 'SvgFallbackAvatar Icon'; const AVATAR_IMAGE_TEST_ID = 'AvatarImage'; const WORKSPACE_NAME = "Cathy's Croissants"; @@ -147,9 +155,50 @@ describe('Avatar', () => { expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); expect(getHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeTruthy(); }); + + it('renders locally drawn initials for a generated letter-avatar URL instead of fetching the image', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: LETTER_AVATAR_URL, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.getByText(LETTER_AVATAR_INITIALS, hiddenElementOptions)).toBeTruthy(); + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + }); + + it('renders locally drawn initials for the small-size (_128) letter-avatar URL variant', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: SMALL_LETTER_AVATAR_URL, + avatarID: 1, + }); + + await waitForBatchedUpdates(); + + expect(screen.getByText(LETTER_AVATAR_INITIALS, hiddenElementOptions)).toBeTruthy(); + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + }); }); describe('workspace avatar', () => { + it('renders the Image branch for a letter-avatar URL because initials are user-avatar-only', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_WORKSPACE, + source: LETTER_AVATAR_URL, + name: WORKSPACE_NAME, + avatarID: 'policy_123', + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(screen.queryByText(LETTER_AVATAR_INITIALS, hiddenElementOptions)).toBeNull(); + }); + it('renders the Image branch for an uploaded workspace logo URL', async () => { renderAvatar({ type: CONST.ICON_TYPE_WORKSPACE, From d0ac78a040bb3b2e8352cf73a7346b83de29734c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 7 Jul 2026 15:25:34 +0200 Subject: [PATCH 4/9] add tests --- tests/unit/AvatarTest.tsx | 73 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/tests/unit/AvatarTest.tsx b/tests/unit/AvatarTest.tsx index f43b2fb307f2..c70875c4a293 100644 --- a/tests/unit/AvatarTest.tsx +++ b/tests/unit/AvatarTest.tsx @@ -25,9 +25,14 @@ const LETTER_AVATAR_INITIALS = 'JD'; const LETTER_AVATAR_URL = `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/blue100/${LETTER_AVATAR_INITIALS}.png`; const SMALL_LETTER_AVATAR_URL = `${CONST.CLOUDFRONT_URL}${CONST.GENERATED_LETTER_AVATAR_PATH}v1/blue100/${LETTER_AVATAR_INITIALS}_128.png`; const FALLBACK_ICON_TEST_ID = 'SvgFallbackAvatar Icon'; +const CUSTOM_FALLBACK_ICON_TEST_ID = 'CustomFallback Icon'; const AVATAR_IMAGE_TEST_ID = 'AvatarImage'; const WORKSPACE_NAME = "Cathy's Croissants"; +function CustomFallbackIcon() { + return mockRenderView({testID: 'CustomFallbackIconSvg'}); +} + // Captures the mocked 's onError callback so tests can simulate a failed image load. const mockImageErrorHandlerRef: {current?: () => void} = {current: undefined}; @@ -156,6 +161,45 @@ describe('Avatar', () => { expect(getHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeTruthy(); }); + it('renders the custom fallback Icon when no source is provided', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + avatarID: 1, + fallbackIcon: CustomFallbackIcon, + fallbackIconTestID: CUSTOM_FALLBACK_ICON_TEST_ID, + }); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(CUSTOM_FALLBACK_ICON_TEST_ID)).toBeTruthy(); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + }); + + it('switches from the Image branch to the custom fallback Icon when the image fails to load', async () => { + renderAvatar({ + type: CONST.ICON_TYPE_AVATAR, + source: UPLOADED_AVATAR_URL, + avatarID: 1, + fallbackIcon: CustomFallbackIcon, + fallbackIconTestID: CUSTOM_FALLBACK_ICON_TEST_ID, + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(queryHiddenTestId(CUSTOM_FALLBACK_ICON_TEST_ID)).toBeNull(); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + + mockImageErrorHandlerRef.current?.(); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(CUSTOM_FALLBACK_ICON_TEST_ID)).toBeTruthy(); + expect(queryHiddenTestId(FALLBACK_ICON_TEST_ID)).toBeNull(); + }); + it('renders locally drawn initials for a generated letter-avatar URL instead of fetching the image', async () => { renderAvatar({ type: CONST.ICON_TYPE_AVATAR, @@ -228,10 +272,33 @@ describe('Avatar', () => { expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); }); + + it('switches from the Image branch to the default workspace Icon when the uploaded logo fails to load', async () => { + const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); + + renderAvatar({ + type: CONST.ICON_TYPE_WORKSPACE, + source: UPLOADED_AVATAR_URL, + name: WORKSPACE_NAME, + avatarID: 'policy_123', + }); + + await waitForBatchedUpdates(); + + expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); + expect(queryHiddenTestId(workspaceFallbackTestID)).toBeNull(); + + mockImageErrorHandlerRef.current?.(); + + await waitForBatchedUpdates(); + + expect(screen.queryByTestId(AVATAR_IMAGE_TEST_ID)).toBeNull(); + expect(getHiddenTestId(workspaceFallbackTestID)).toBeTruthy(); + }); }); - describe('compound roots', () => { - it('Avatar.User renders the same as the back-compat default for user avatars', async () => { + describe('AvatarUser and AvatarWorkspace', () => { + it('AvatarUser renders the same as the back-compat default for user avatars', async () => { render( { expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); }); - it('Avatar.Workspace renders the same as the back-compat default for workspace avatars', async () => { + it('AvatarWorkspace renders the same as the back-compat default for workspace avatars', async () => { const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); render( From f90257498c1eec2495dc67b9a57df918fdb218fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 8 Jul 2026 15:34:20 +0200 Subject: [PATCH 5/9] rename avatar primitives, move LettersAvatar out of common body --- src/components/Avatar/AvatarBody.tsx | 20 ++---------- .../Avatar/{AvatarUser.tsx => UserAvatar.tsx} | 32 +++++++++++++------ ...vatarWorkspace.tsx => WorkspaceAvatar.tsx} | 4 +-- src/components/Avatar/index.tsx | 12 ++++--- src/components/Avatar/types.ts | 2 +- src/components/Avatar/useAvatarLoadError.ts | 1 + src/components/Avatar/useUserAvatarSource.ts | 1 + .../Avatar/useWorkspaceAvatarSource.ts | 5 +-- tests/unit/AvatarTest.tsx | 14 ++++---- 9 files changed, 47 insertions(+), 44 deletions(-) rename src/components/Avatar/{AvatarUser.tsx => UserAvatar.tsx} (58%) rename src/components/Avatar/{AvatarWorkspace.tsx => WorkspaceAvatar.tsx} (92%) diff --git a/src/components/Avatar/AvatarBody.tsx b/src/components/Avatar/AvatarBody.tsx index 00b7d71ac35b..0efefea74a36 100644 --- a/src/components/Avatar/AvatarBody.tsx +++ b/src/components/Avatar/AvatarBody.tsx @@ -8,15 +8,14 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import type {ResolvedAvatar} from './types'; +import type {ResolvedIconAvatar, ResolvedImageAvatar} from './types'; import AvatarIcon from './AvatarIcon'; import AvatarImage from './AvatarImage'; -import AvatarInitials from './AvatarInitials'; type AvatarBodyProps = { /** The resolved avatar model that selects which branch to render. */ - resolvedAvatar: ResolvedAvatar; + resolvedAvatar: ResolvedImageAvatar | ResolvedIconAvatar; /** Set the size of Avatar */ size: AvatarSizeName; @@ -34,22 +33,9 @@ type AvatarBodyProps = { iconAdditionalStyles?: StyleProp; }; -/** Renders a resolved avatar as a remote image, an SVG icon, or locally drawn initials. */ +/** Renders a resolved avatar as a remote image or an SVG icon. Initials are handled by the user path. */ function AvatarBody({resolvedAvatar, size, type, imageStyles, iconAdditionalStyles, fill}: AvatarBodyProps) { const theme = useTheme(); - if (resolvedAvatar.variant === 'initials') { - return ( - - ); - } - if (resolvedAvatar.variant === 'image') { return ( - + {resolvedAvatar.variant === 'initials' ? ( + + ) : ( + + )} ); } -export default AvatarUser; +export default UserAvatar; diff --git a/src/components/Avatar/AvatarWorkspace.tsx b/src/components/Avatar/WorkspaceAvatar.tsx similarity index 92% rename from src/components/Avatar/AvatarWorkspace.tsx rename to src/components/Avatar/WorkspaceAvatar.tsx index 77f3a7bb9609..2bdfc6e293ca 100644 --- a/src/components/Avatar/AvatarWorkspace.tsx +++ b/src/components/Avatar/WorkspaceAvatar.tsx @@ -16,7 +16,7 @@ type WorkspaceAvatarProps = AvatarCommonProps & { }; /** Renders a workspace avatar, falling back to a default icon derived from the workspace name. */ -function AvatarWorkspace({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, fill, name = '', avatarID, testID = 'Avatar'}: WorkspaceAvatarProps) { +function WorkspaceAvatar({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, fill, name = '', avatarID, testID = 'Avatar'}: WorkspaceAvatarProps) { const styles = useThemeStyles(); const resolvedAvatar = useWorkspaceAvatarSource({source, name, avatarID}); @@ -37,4 +37,4 @@ function AvatarWorkspace({source, imageStyles, iconAdditionalStyles, containerSt ); } -export default AvatarWorkspace; +export default WorkspaceAvatar; diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx index 4bf75a3d75c7..5babbba5e18a 100644 --- a/src/components/Avatar/index.tsx +++ b/src/components/Avatar/index.tsx @@ -7,8 +7,8 @@ import React from 'react'; import type {AvatarCommonProps} from './types'; -import AvatarUser from './AvatarUser'; -import AvatarWorkspace from './AvatarWorkspace'; +import UserAvatar from './UserAvatar'; +import WorkspaceAvatar from './WorkspaceAvatar'; type AvatarProps = AvatarCommonProps & { /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ @@ -24,11 +24,13 @@ type AvatarProps = AvatarCommonProps & { type: AvatarType; }; -/** Renders a user or workspace avatar depending on the `type` prop. */ +/** Renders a user or workspace avatar depending on the `type` prop. + * This wrapper exists for backward compatibility. If possible use WorkspaceAvatar and UserAvatar directly. + */ function Avatar({type, name, fallbackIcon, fallbackIconTestID, source, imageStyles, iconAdditionalStyles, containerStyles, size, fill, testID, avatarID}: AvatarProps) { if (type === CONST.ICON_TYPE_WORKSPACE) { return ( - (); const hasImageError = errorSource !== undefined && errorSource === originalSource; diff --git a/src/components/Avatar/useUserAvatarSource.ts b/src/components/Avatar/useUserAvatarSource.ts index 0062036a35af..fd9b63ce5bcb 100644 --- a/src/components/Avatar/useUserAvatarSource.ts +++ b/src/components/Avatar/useUserAvatarSource.ts @@ -16,6 +16,7 @@ type UseUserAvatarSourceParams = { fallbackIconTestID?: string; }; +/** Resolves a user avatar source into a renderable model: locally drawn initials, a remote image, or an SVG icon. */ function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fallbackIconTestID = ''}: UseUserAvatarSourceParams): ResolvedAvatar { const defaultAvatars = useDefaultAvatars(); const theme = useTheme(); diff --git a/src/components/Avatar/useWorkspaceAvatarSource.ts b/src/components/Avatar/useWorkspaceAvatarSource.ts index 70446155335b..71983d500bce 100644 --- a/src/components/Avatar/useWorkspaceAvatarSource.ts +++ b/src/components/Avatar/useWorkspaceAvatarSource.ts @@ -4,7 +4,7 @@ import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ import type {AvatarSource} from '@libs/UserAvatarUtils'; import {optimizeAvatarSource} from '@libs/UserAvatarUtils'; -import type {ResolvedAvatar} from './types'; +import type {ResolvedIconAvatar, ResolvedImageAvatar} from './types'; import useAvatarLoadError from './useAvatarLoadError'; @@ -14,7 +14,8 @@ type UseWorkspaceAvatarSourceParams = { avatarID?: number | string; }; -function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: UseWorkspaceAvatarSourceParams): ResolvedAvatar { +/** Resolves a workspace avatar source into a renderable model: a remote image or an SVG icon, never initials. */ +function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: UseWorkspaceAvatarSourceParams): ResolvedImageAvatar | ResolvedIconAvatar { const StyleUtils = useStyleUtils(); const {hasImageError, onImageError} = useAvatarLoadError(originalSource); diff --git a/tests/unit/AvatarTest.tsx b/tests/unit/AvatarTest.tsx index c70875c4a293..cdcbd0bbe663 100644 --- a/tests/unit/AvatarTest.tsx +++ b/tests/unit/AvatarTest.tsx @@ -1,8 +1,8 @@ import {render, screen} from '@testing-library/react-native'; import Avatar from '@components/Avatar'; -import AvatarUser from '@components/Avatar/AvatarUser'; -import AvatarWorkspace from '@components/Avatar/AvatarWorkspace'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -297,11 +297,11 @@ describe('Avatar', () => { }); }); - describe('AvatarUser and AvatarWorkspace', () => { - it('AvatarUser renders the same as the back-compat default for user avatars', async () => { + describe('UserAvatar and WorkspaceAvatar', () => { + it('UserAvatar renders the same as the back-compat default for user avatars', async () => { render( - @@ -314,12 +314,12 @@ describe('Avatar', () => { expect(screen.getByTestId(AVATAR_IMAGE_TEST_ID)).toBeTruthy(); }); - it('AvatarWorkspace renders the same as the back-compat default for workspace avatars', async () => { + it('WorkspaceAvatar renders the same as the back-compat default for workspace avatars', async () => { const workspaceFallbackTestID = getDefaultWorkspaceAvatarTestID(WORKSPACE_NAME); render( - From 4a8f8f12ebb1924d1932a0dad0085bd256bf1246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 9 Jul 2026 11:53:56 +0200 Subject: [PATCH 6/9] update folder structure --- src/components/Avatar/UserAvatar.tsx | 6 +++--- src/components/Avatar/WorkspaceAvatar.tsx | 4 ++-- src/components/Avatar/{ => hooks}/useAvatarLoadError.ts | 0 src/components/Avatar/{ => hooks}/useUserAvatarSource.ts | 6 +++--- .../Avatar/{ => hooks}/useWorkspaceAvatarSource.ts | 8 +++++--- src/components/Avatar/{ => primitives}/AvatarBody.tsx | 4 ++-- src/components/Avatar/{ => primitives}/AvatarIcon.tsx | 5 ++--- src/components/Avatar/{ => primitives}/AvatarImage.tsx | 5 ++--- src/components/Avatar/{ => primitives}/AvatarInitials.tsx | 5 ++--- src/components/Avatar/types.ts | 4 ++-- 10 files changed, 23 insertions(+), 24 deletions(-) rename src/components/Avatar/{ => hooks}/useAvatarLoadError.ts (100%) rename src/components/Avatar/{ => hooks}/useUserAvatarSource.ts (94%) rename src/components/Avatar/{ => hooks}/useWorkspaceAvatarSource.ts (85%) rename src/components/Avatar/{ => primitives}/AvatarBody.tsx (95%) rename src/components/Avatar/{ => primitives}/AvatarIcon.tsx (93%) rename src/components/Avatar/{ => primitives}/AvatarImage.tsx (92%) rename src/components/Avatar/{ => primitives}/AvatarInitials.tsx (92%) diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index b0337b9d1a24..4517e2082e98 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -9,9 +9,9 @@ import {View} from 'react-native'; import type {AvatarCommonProps} from './types'; -import AvatarBody from './AvatarBody'; -import AvatarInitials from './AvatarInitials'; -import useUserAvatarSource from './useUserAvatarSource'; +import useUserAvatarSource from './hooks/useUserAvatarSource'; +import AvatarBody from './primitives/AvatarBody'; +import AvatarInitials from './primitives/AvatarInitials'; type UserAvatarProps = AvatarCommonProps & { /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ diff --git a/src/components/Avatar/WorkspaceAvatar.tsx b/src/components/Avatar/WorkspaceAvatar.tsx index 2bdfc6e293ca..561fc6c261a4 100644 --- a/src/components/Avatar/WorkspaceAvatar.tsx +++ b/src/components/Avatar/WorkspaceAvatar.tsx @@ -7,8 +7,8 @@ import {View} from 'react-native'; import type {AvatarCommonProps} from './types'; -import AvatarBody from './AvatarBody'; -import useWorkspaceAvatarSource from './useWorkspaceAvatarSource'; +import useWorkspaceAvatarSource from './hooks/useWorkspaceAvatarSource'; +import AvatarBody from './primitives/AvatarBody'; type WorkspaceAvatarProps = AvatarCommonProps & { /** Owner of the avatar. Policy name */ diff --git a/src/components/Avatar/useAvatarLoadError.ts b/src/components/Avatar/hooks/useAvatarLoadError.ts similarity index 100% rename from src/components/Avatar/useAvatarLoadError.ts rename to src/components/Avatar/hooks/useAvatarLoadError.ts diff --git a/src/components/Avatar/useUserAvatarSource.ts b/src/components/Avatar/hooks/useUserAvatarSource.ts similarity index 94% rename from src/components/Avatar/useUserAvatarSource.ts rename to src/components/Avatar/hooks/useUserAvatarSource.ts index fd9b63ce5bcb..c72cf4356b77 100644 --- a/src/components/Avatar/useUserAvatarSource.ts +++ b/src/components/Avatar/hooks/useUserAvatarSource.ts @@ -1,3 +1,5 @@ +import type {ResolvedAvatar} from '@components/Avatar/types'; + import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -5,8 +7,6 @@ import useTheme from '@hooks/useTheme'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {getAvatar, optimizeAvatarSource, parseLetterAvatarURL} from '@libs/UserAvatarUtils'; -import type {ResolvedAvatar} from './types'; - import useAvatarLoadError from './useAvatarLoadError'; type UseUserAvatarSourceParams = { @@ -23,7 +23,7 @@ function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fa const StyleUtils = useStyleUtils(); const {hasImageError, onImageError} = useAvatarLoadError(originalSource); - const userAccountID = typeof avatarID === 'number' ? avatarID : undefined; + const userAccountID = typeof avatarID === 'string' ? parseInt(avatarID, 10) : avatarID; const source = getAvatar({avatarSource: originalSource, accountID: userAccountID, defaultAvatars}); // Generated letter-avatar URLs are not fetched — their color and initials are parsed out and drawn locally. diff --git a/src/components/Avatar/useWorkspaceAvatarSource.ts b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts similarity index 85% rename from src/components/Avatar/useWorkspaceAvatarSource.ts rename to src/components/Avatar/hooks/useWorkspaceAvatarSource.ts index 71983d500bce..87c28b8d6133 100644 --- a/src/components/Avatar/useWorkspaceAvatarSource.ts +++ b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts @@ -1,11 +1,12 @@ +import type {ResolvedIconAvatar, ResolvedImageAvatar} from '@components/Avatar/types'; + +import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useStyleUtils from '@hooks/useStyleUtils'; import {getDefaultWorkspaceAvatar, getDefaultWorkspaceAvatarTestID} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {optimizeAvatarSource} from '@libs/UserAvatarUtils'; -import type {ResolvedIconAvatar, ResolvedImageAvatar} from './types'; - import useAvatarLoadError from './useAvatarLoadError'; type UseWorkspaceAvatarSourceParams = { @@ -17,11 +18,12 @@ type UseWorkspaceAvatarSourceParams = { /** Resolves a workspace avatar source into a renderable model: a remote image or an SVG icon, never initials. */ function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: UseWorkspaceAvatarSourceParams): ResolvedImageAvatar | ResolvedIconAvatar { const StyleUtils = useStyleUtils(); + const defaultAvatars = useDefaultAvatars(); const {hasImageError, onImageError} = useAvatarLoadError(originalSource); const source = originalSource; const optimizedSource = optimizeAvatarSource(source); - const useFallBackAvatar = hasImageError || !source; + const useFallBackAvatar = hasImageError || !source || source === defaultAvatars.FallbackAvatar; const fallbackAvatar = getDefaultWorkspaceAvatar(name); const fallbackAvatarTestID = getDefaultWorkspaceAvatarTestID(name); const avatarSource = useFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); diff --git a/src/components/Avatar/AvatarBody.tsx b/src/components/Avatar/primitives/AvatarBody.tsx similarity index 95% rename from src/components/Avatar/AvatarBody.tsx rename to src/components/Avatar/primitives/AvatarBody.tsx index 0efefea74a36..1a80dd3e99bf 100644 --- a/src/components/Avatar/AvatarBody.tsx +++ b/src/components/Avatar/primitives/AvatarBody.tsx @@ -1,3 +1,5 @@ +import type {ResolvedIconAvatar, ResolvedImageAvatar} from '@components/Avatar/types'; + import useTheme from '@hooks/useTheme'; import type {AvatarSizeName} from '@styles/utils'; @@ -8,8 +10,6 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import type {ResolvedIconAvatar, ResolvedImageAvatar} from './types'; - import AvatarIcon from './AvatarIcon'; import AvatarImage from './AvatarImage'; diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/primitives/AvatarIcon.tsx similarity index 93% rename from src/components/Avatar/AvatarIcon.tsx rename to src/components/Avatar/primitives/AvatarIcon.tsx index ef1cac36a5a4..462d43c8e290 100644 --- a/src/components/Avatar/AvatarIcon.tsx +++ b/src/components/Avatar/primitives/AvatarIcon.tsx @@ -1,3 +1,4 @@ +import type {AvatarPrimitivesCommonProps} from '@components/Avatar/types'; import Icon from '@components/Icon'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,9 +13,7 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import type {AvatarBranchCommonProps} from './types'; - -type AvatarIconProps = AvatarBranchCommonProps & { +type AvatarIconProps = AvatarPrimitivesCommonProps & { /** Icon asset to render as the avatar. */ avatarSource: IconAsset; diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/primitives/AvatarImage.tsx similarity index 92% rename from src/components/Avatar/AvatarImage.tsx rename to src/components/Avatar/primitives/AvatarImage.tsx index bd55d10b8bca..5f4b2aea7dc4 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/primitives/AvatarImage.tsx @@ -1,3 +1,4 @@ +import type {AvatarPrimitivesCommonProps} from '@components/Avatar/types'; import Image from '@components/Image'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -8,9 +9,7 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import type {AvatarBranchCommonProps} from './types'; - -type AvatarImageProps = AvatarBranchCommonProps & { +type AvatarImageProps = AvatarPrimitivesCommonProps & { /** URL of the remote avatar image. */ avatarSource: string; diff --git a/src/components/Avatar/AvatarInitials.tsx b/src/components/Avatar/primitives/AvatarInitials.tsx similarity index 92% rename from src/components/Avatar/AvatarInitials.tsx rename to src/components/Avatar/primitives/AvatarInitials.tsx index 95da1e51bb77..756919a0b9ad 100644 --- a/src/components/Avatar/AvatarInitials.tsx +++ b/src/components/Avatar/primitives/AvatarInitials.tsx @@ -1,3 +1,4 @@ +import type {AvatarPrimitivesCommonProps} from '@components/Avatar/types'; import UserInitialsAvatar from '@components/UserInitialsAvatar'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -10,9 +11,7 @@ import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import type {AvatarBranchCommonProps} from './types'; - -type AvatarInitialsProps = AvatarBranchCommonProps & { +type AvatarInitialsProps = AvatarPrimitivesCommonProps & { /** The initials to render. */ initials: string; diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts index 2a59f9f69a1d..b17ea99003ed 100644 --- a/src/components/Avatar/types.ts +++ b/src/components/Avatar/types.ts @@ -81,7 +81,7 @@ type ResolvedInitialsAvatar = { type ResolvedAvatar = ResolvedImageAvatar | ResolvedIconAvatar | ResolvedInitialsAvatar; -type AvatarBranchCommonProps = { +type AvatarPrimitivesCommonProps = { /** Size of Avatar */ size: AvatarSizeName; @@ -89,4 +89,4 @@ type AvatarBranchCommonProps = { type: AvatarType; }; -export type {AvatarCommonProps, ResolvedAvatar, ResolvedImageAvatar, ResolvedIconAvatar, AvatarBranchCommonProps}; +export type {AvatarCommonProps, ResolvedAvatar, ResolvedImageAvatar, ResolvedIconAvatar, AvatarPrimitivesCommonProps}; From 601b3c61b54bb336eb24d40055746d332d9b8193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 10 Jul 2026 17:09:23 +0200 Subject: [PATCH 7/9] add jsdoc to props; simplify hooks --- .../Avatar/hooks/useAvatarLoadError.ts | 6 +++--- .../Avatar/hooks/useUserAvatarSource.ts | 11 +++++++++-- .../Avatar/hooks/useWorkspaceAvatarSource.ts | 16 ++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/Avatar/hooks/useAvatarLoadError.ts b/src/components/Avatar/hooks/useAvatarLoadError.ts index 532d5f4bfe09..4721317f4cf7 100644 --- a/src/components/Avatar/hooks/useAvatarLoadError.ts +++ b/src/components/Avatar/hooks/useAvatarLoadError.ts @@ -6,13 +6,13 @@ import {useState} from 'react'; /** Tracks whether the avatar's remote image failed to load, resetting the error state on network reconnect. */ function useAvatarLoadError(originalSource?: AvatarSource) { - const [errorSource, setErrorSource] = useState(); - const hasImageError = errorSource !== undefined && errorSource === originalSource; + const [errorSource, setErrorSource] = useState(); + const hasImageError = errorSource !== undefined; useNetwork({onReconnect: () => setErrorSource(undefined)}); const onImageError = () => { - setErrorSource(typeof originalSource === 'string' ? originalSource : undefined); + setErrorSource(originalSource); }; return {hasImageError, onImageError}; diff --git a/src/components/Avatar/hooks/useUserAvatarSource.ts b/src/components/Avatar/hooks/useUserAvatarSource.ts index c72cf4356b77..7b041a95f282 100644 --- a/src/components/Avatar/hooks/useUserAvatarSource.ts +++ b/src/components/Avatar/hooks/useUserAvatarSource.ts @@ -10,14 +10,21 @@ import {getAvatar, optimizeAvatarSource, parseLetterAvatarURL} from '@libs/UserA import useAvatarLoadError from './useAvatarLoadError'; type UseUserAvatarSourceParams = { + /** Avatar to render: an image URL or an SVG icon. Falls back to `fallbackIcon` when missing or it fails to load. */ source?: AvatarSource; + + /** Owning account/policy ID. Complements `source` — picks the default avatar when `source` is a default or absent. */ avatarID?: number | string; + + /** Icon rendered when `source` is missing or fails to load. Defaults to the shared fallback avatar. */ fallbackIcon?: AvatarSource; - fallbackIconTestID?: string; + + /** Test ID applied to the fallback icon so end-to-end tests can locate it. */ + fallbackIconTestID: string; }; /** Resolves a user avatar source into a renderable model: locally drawn initials, a remote image, or an SVG icon. */ -function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fallbackIconTestID = ''}: UseUserAvatarSourceParams): ResolvedAvatar { +function useUserAvatarSource({source: originalSource, avatarID, fallbackIcon, fallbackIconTestID}: UseUserAvatarSourceParams): ResolvedAvatar { const defaultAvatars = useDefaultAvatars(); const theme = useTheme(); const StyleUtils = useStyleUtils(); diff --git a/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts index 87c28b8d6133..65ce7ebde1cf 100644 --- a/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts +++ b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts @@ -10,23 +10,27 @@ import {optimizeAvatarSource} from '@libs/UserAvatarUtils'; import useAvatarLoadError from './useAvatarLoadError'; type UseWorkspaceAvatarSourceParams = { + /** Avatar to render: an image URL or an SVG icon. Falls back to the default workspace avatar when missing or it fails to load. */ source?: AvatarSource; - name?: string; + + /** Workspace name. Seeds the default workspace avatar (icon + test ID) used when `source` is missing. */ + name: string; + + /** Workspace/policy ID. Picks the background color of the default workspace avatar. */ avatarID?: number | string; }; /** Resolves a workspace avatar source into a renderable model: a remote image or an SVG icon, never initials. */ -function useWorkspaceAvatarSource({source: originalSource, name = '', avatarID}: UseWorkspaceAvatarSourceParams): ResolvedImageAvatar | ResolvedIconAvatar { +function useWorkspaceAvatarSource({source, name, avatarID}: UseWorkspaceAvatarSourceParams): ResolvedImageAvatar | ResolvedIconAvatar { const StyleUtils = useStyleUtils(); const defaultAvatars = useDefaultAvatars(); - const {hasImageError, onImageError} = useAvatarLoadError(originalSource); + const {hasImageError, onImageError} = useAvatarLoadError(source); - const source = originalSource; const optimizedSource = optimizeAvatarSource(source); - const useFallBackAvatar = hasImageError || !source || source === defaultAvatars.FallbackAvatar; + const shouldUseFallBackAvatar = hasImageError || !source || source === defaultAvatars.FallbackAvatar; const fallbackAvatar = getDefaultWorkspaceAvatar(name); const fallbackAvatarTestID = getDefaultWorkspaceAvatarTestID(name); - const avatarSource = useFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); + const avatarSource = shouldUseFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); const iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); if (typeof avatarSource === 'string') { From 77f2fd7407f02ba9dba41e9b14faa16273f9a4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 16 Jul 2026 00:05:02 +0200 Subject: [PATCH 8/9] simplify UserAvatar & WorkspaceAvatar types --- src/components/Avatar/UserAvatar.tsx | 3 +++ src/components/Avatar/WorkspaceAvatar.tsx | 9 ++++--- .../Avatar/hooks/useUserAvatarSource.ts | 4 ++-- .../Avatar/hooks/useWorkspaceAvatarSource.ts | 4 ++-- src/components/Avatar/index.tsx | 24 +++++++++++++++---- src/components/Avatar/types.ts | 9 +++---- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index 4517e2082e98..6801803eb546 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -19,6 +19,9 @@ type UserAvatarProps = AvatarCommonProps & { /** Used to locate fallback icon in end-to-end tests. */ fallbackIconTestID?: string; + + /** Owning account ID. Picks the default avatar when `source` is a default or absent. */ + avatarID: number | string; }; /** Renders a user avatar, falling back to a default icon when no source is available. */ diff --git a/src/components/Avatar/WorkspaceAvatar.tsx b/src/components/Avatar/WorkspaceAvatar.tsx index 561fc6c261a4..9e6bfcded7c7 100644 --- a/src/components/Avatar/WorkspaceAvatar.tsx +++ b/src/components/Avatar/WorkspaceAvatar.tsx @@ -11,12 +11,15 @@ import useWorkspaceAvatarSource from './hooks/useWorkspaceAvatarSource'; import AvatarBody from './primitives/AvatarBody'; type WorkspaceAvatarProps = AvatarCommonProps & { - /** Owner of the avatar. Policy name */ - name?: string; + /** Workspace name. Seeds the default workspace avatar (icon + test ID) used when `source` is missing. */ + name: string; + + /** Workspace/policy ID. Picks the background color of the default workspace avatar. */ + avatarID: number | string; }; /** Renders a workspace avatar, falling back to a default icon derived from the workspace name. */ -function WorkspaceAvatar({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, fill, name = '', avatarID, testID = 'Avatar'}: WorkspaceAvatarProps) { +function WorkspaceAvatar({source, imageStyles, iconAdditionalStyles, containerStyles, size = CONST.AVATAR_SIZE.DEFAULT, fill, name, avatarID, testID = 'Avatar'}: WorkspaceAvatarProps) { const styles = useThemeStyles(); const resolvedAvatar = useWorkspaceAvatarSource({source, name, avatarID}); diff --git a/src/components/Avatar/hooks/useUserAvatarSource.ts b/src/components/Avatar/hooks/useUserAvatarSource.ts index 7b041a95f282..f9c86896fb4a 100644 --- a/src/components/Avatar/hooks/useUserAvatarSource.ts +++ b/src/components/Avatar/hooks/useUserAvatarSource.ts @@ -13,8 +13,8 @@ type UseUserAvatarSourceParams = { /** Avatar to render: an image URL or an SVG icon. Falls back to `fallbackIcon` when missing or it fails to load. */ source?: AvatarSource; - /** Owning account/policy ID. Complements `source` — picks the default avatar when `source` is a default or absent. */ - avatarID?: number | string; + /** Owning account ID. Picks the default avatar when `source` is a default or absent. */ + avatarID: number | string; /** Icon rendered when `source` is missing or fails to load. Defaults to the shared fallback avatar. */ fallbackIcon?: AvatarSource; diff --git a/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts index 65ce7ebde1cf..3e64af006277 100644 --- a/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts +++ b/src/components/Avatar/hooks/useWorkspaceAvatarSource.ts @@ -17,7 +17,7 @@ type UseWorkspaceAvatarSourceParams = { name: string; /** Workspace/policy ID. Picks the background color of the default workspace avatar. */ - avatarID?: number | string; + avatarID: number | string; }; /** Resolves a workspace avatar source into a renderable model: a remote image or an SVG icon, never initials. */ @@ -31,7 +31,7 @@ function useWorkspaceAvatarSource({source, name, avatarID}: UseWorkspaceAvatarSo const fallbackAvatar = getDefaultWorkspaceAvatar(name); const fallbackAvatarTestID = getDefaultWorkspaceAvatarTestID(name); const avatarSource = shouldUseFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); - const iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); + const iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID.toString()); if (typeof avatarSource === 'string') { return { diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx index 5babbba5e18a..12bdd5cd8149 100644 --- a/src/components/Avatar/index.tsx +++ b/src/components/Avatar/index.tsx @@ -11,13 +11,16 @@ import UserAvatar from './UserAvatar'; import WorkspaceAvatar from './WorkspaceAvatar'; type AvatarProps = AvatarCommonProps & { - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + /** ID of the avatar owner: account ID for user avatars, policy ID for workspace avatars. Falls back to the anonymous default when omitted. */ + avatarID?: number | string; + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. Only used for user avatars. */ fallbackIcon?: AvatarSource; - /** Used to locate fallback icon in end-to-end tests. */ + /** Used to locate fallback icon in end-to-end tests. Only used for user avatars. */ fallbackIconTestID?: string; - /** Owner of the avatar. If user, displayName. If workspace, policy name */ + /** Workspace name. Only used for workspace avatars — seeds the default workspace avatar. */ name?: string; /** Denotes whether it is an avatar or a workspace avatar */ @@ -27,7 +30,20 @@ type AvatarProps = AvatarCommonProps & { /** Renders a user or workspace avatar depending on the `type` prop. * This wrapper exists for backward compatibility. If possible use WorkspaceAvatar and UserAvatar directly. */ -function Avatar({type, name, fallbackIcon, fallbackIconTestID, source, imageStyles, iconAdditionalStyles, containerStyles, size, fill, testID, avatarID}: AvatarProps) { +function Avatar({ + type, + name = '', + fallbackIcon, + fallbackIconTestID, + source, + imageStyles, + iconAdditionalStyles, + containerStyles, + size, + fill, + testID, + avatarID = CONST.DEFAULT_NUMBER_ID, +}: AvatarProps) { if (type === CONST.ICON_TYPE_WORKSPACE) { return ( ; - /** Additional styles to pass to Icon */ + /** Additional styles for the rendered icon/initials, or for the container of the rendered image */ iconAdditionalStyles?: StyleProp; /** Extra styles to pass to View wrapper */ @@ -27,15 +27,12 @@ type AvatarCommonProps = { /** * The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue' - * If the avatar is type === workspace, this fill color will be ignored and decided based on the name prop. + * For workspace avatars this fill color is ignored — the colors are derived from the avatarID prop instead. */ fill?: string; /** Test ID for the Avatar component */ testID?: string; - - /** Optional account id if it's user avatar */ - avatarID?: number | string; }; type ResolvedAvatarBase = { From b53c29718892f180c16857ffc1480ecaff2ad6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 16 Jul 2026 00:13:58 +0200 Subject: [PATCH 9/9] fix codex review --- src/components/Avatar/hooks/useAvatarLoadError.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Avatar/hooks/useAvatarLoadError.ts b/src/components/Avatar/hooks/useAvatarLoadError.ts index 4721317f4cf7..6dfcd966366b 100644 --- a/src/components/Avatar/hooks/useAvatarLoadError.ts +++ b/src/components/Avatar/hooks/useAvatarLoadError.ts @@ -4,10 +4,10 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import {useState} from 'react'; -/** Tracks whether the avatar's remote image failed to load, resetting the error state on network reconnect. */ +/** Tracks whether the avatar's remote image failed to load, resetting the error state on network reconnect or source change. */ function useAvatarLoadError(originalSource?: AvatarSource) { const [errorSource, setErrorSource] = useState(); - const hasImageError = errorSource !== undefined; + const hasImageError = errorSource !== undefined && errorSource === originalSource; useNetwork({onReconnect: () => setErrorSource(undefined)});