-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[No QA] Avatar component decomposition #95050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e55b38f
5630d21
ad4a884
eb13be1
d0ac78a
acd871a
f902574
889100d
af83ed3
4a8f8f1
88b1ae1
601b3c6
34fa6ee
77f2fd7
b53c297
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
|
|
||
| import type {AvatarSource} from '@libs/UserAvatarUtils'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
|
|
||
| import React from 'react'; | ||
| import {View} from 'react-native'; | ||
|
|
||
| import type {AvatarCommonProps} from './types'; | ||
|
|
||
| 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. */ | ||
| fallbackIcon?: AvatarSource; | ||
|
|
||
| /** 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. */ | ||
| function UserAvatar({ | ||
| 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 ( | ||
| <View | ||
| style={[containerStyles, styles.pointerEventsNone]} | ||
| testID={testID} | ||
| > | ||
| {resolvedAvatar.variant === 'initials' ? ( | ||
| <AvatarInitials | ||
| initials={resolvedAvatar.initials} | ||
| colors={resolvedAvatar.colors} | ||
| size={size} | ||
| type={CONST.ICON_TYPE_AVATAR} | ||
| initialsContainerStyles={imageStyles} | ||
| initialsAdditionalStyles={iconAdditionalStyles} | ||
| /> | ||
| ) : ( | ||
| <AvatarBody | ||
| resolvedAvatar={resolvedAvatar} | ||
| size={size} | ||
| type={CONST.ICON_TYPE_AVATAR} | ||
| fill={fill} | ||
| imageStyles={imageStyles} | ||
| iconAdditionalStyles={iconAdditionalStyles} | ||
| /> | ||
| )} | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| export default UserAvatar; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
|
|
||
| import React from 'react'; | ||
| import {View} from 'react-native'; | ||
|
|
||
| import type {AvatarCommonProps} from './types'; | ||
|
|
||
| import useWorkspaceAvatarSource from './hooks/useWorkspaceAvatarSource'; | ||
| import AvatarBody from './primitives/AvatarBody'; | ||
|
|
||
| type WorkspaceAvatarProps = AvatarCommonProps & { | ||
| /** 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) { | ||
| const styles = useThemeStyles(); | ||
| const resolvedAvatar = useWorkspaceAvatarSource({source, name, avatarID}); | ||
|
|
||
| return ( | ||
| <View | ||
| style={[containerStyles, styles.pointerEventsNone]} | ||
| testID={testID} | ||
| > | ||
| <AvatarBody | ||
| resolvedAvatar={resolvedAvatar} | ||
| size={size} | ||
| type={CONST.ICON_TYPE_WORKSPACE} | ||
| fill={fill} | ||
| imageStyles={imageStyles} | ||
| iconAdditionalStyles={iconAdditionalStyles} | ||
| /> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| export default WorkspaceAvatar; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import useNetwork from '@hooks/useNetwork'; | ||
|
|
||
| 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 or source change. */ | ||
| function useAvatarLoadError(originalSource?: AvatarSource) { | ||
| const [errorSource, setErrorSource] = useState<AvatarSource | undefined>(); | ||
| const hasImageError = errorSource !== undefined && errorSource === originalSource; | ||
|
|
||
| useNetwork({onReconnect: () => setErrorSource(undefined)}); | ||
|
|
||
| const onImageError = () => { | ||
| setErrorSource(originalSource); | ||
| }; | ||
|
|
||
| return {hasImageError, onImageError}; | ||
| } | ||
|
|
||
| export default useAvatarLoadError; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import type {ResolvedAvatar} from '@components/Avatar/types'; | ||
|
|
||
| import useDefaultAvatars from '@hooks/useDefaultAvatars'; | ||
| import useStyleUtils from '@hooks/useStyleUtils'; | ||
| import useTheme from '@hooks/useTheme'; | ||
|
|
||
| import type {AvatarSource} from '@libs/UserAvatarUtils'; | ||
| import {getAvatar, optimizeAvatarSource, parseLetterAvatarURL} from '@libs/UserAvatarUtils'; | ||
|
|
||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some comments explaining If we could make some of these fields non-optional it might ripple some simplifications through the hook. |
||
|
|
||
| /** 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; | ||
|
|
||
| /** 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 { | ||
| const defaultAvatars = useDefaultAvatars(); | ||
| const theme = useTheme(); | ||
| const StyleUtils = useStyleUtils(); | ||
| const {hasImageError, onImageError} = useAvatarLoadError(originalSource); | ||
|
|
||
| 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. | ||
| 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; | ||
| const fallbackAvatarTestID = fallbackIconTestID || 'SvgFallbackAvatar Icon'; | ||
| const avatarSource = useFallBackAvatar ? fallbackAvatar : (optimizedSource ?? fallbackAvatar); | ||
|
|
||
| if (typeof avatarSource === 'string') { | ||
| return { | ||
| avatarSource, | ||
| variant: 'image', | ||
| hasImageError, | ||
| fallbackAvatarTestID, | ||
| onImageError, | ||
| }; | ||
| } | ||
|
|
||
| let iconColors = null; | ||
| if (useFallBackAvatar && avatarSource === defaultAvatars.FallbackAvatar) { | ||
| iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); | ||
| } | ||
|
|
||
| return { | ||
| avatarSource, | ||
| variant: 'icon', | ||
| hasImageError, | ||
| iconColors, | ||
| fallbackAvatarTestID, | ||
| onImageError, | ||
| }; | ||
| } | ||
|
|
||
| export default useUserAvatarSource; | ||
Uh oh!
There was an error while loading. Please reload this page.