Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 0 additions & 175 deletions src/components/Avatar.tsx

This file was deleted.

71 changes: 71 additions & 0 deletions src/components/Avatar/UserAvatar.tsx
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;
43 changes: 43 additions & 0 deletions src/components/Avatar/WorkspaceAvatar.tsx
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;
21 changes: 21 additions & 0 deletions src/components/Avatar/hooks/useAvatarLoadError.ts
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>();
Comment thread
jmusial marked this conversation as resolved.
const hasImageError = errorSource !== undefined && errorSource === originalSource;

useNetwork({onReconnect: () => setErrorSource(undefined)});

const onImageError = () => {
setErrorSource(originalSource);
};

return {hasImageError, onImageError};
}

export default useAvatarLoadError;
77 changes: 77 additions & 0 deletions src/components/Avatar/hooks/useUserAvatarSource.ts
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some comments explaining source and avatarID would be helpful. It is ever valid to have both, or would a discriminated union be more precise?

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;
Loading
Loading