import React, { createContext, useContext, useEffect, useState } from 'react' import { StyleSheet, View, ViewProps } from 'react-native' import { useFontScale, useTheme } from '../../hooks' import { MAX_FONT_SIZE_MULTIPLIER } from '../../utils' import { Icon, IconString } from '../display/icon' import { Image, ImageProps } from '../display/image' import { Spinner } from '../display/spinner' // ================================= // ====== Exports ================== // ================================= const Avatar = { Root: AvatarRoot, Image: AvatarImage, ImageFallback: AvatarImageFallback, Presence: AvatarPresence, Group: AvatarGroup, GroupLoader: AvatarGroupLoader, Mask: AvatarMask, } as const type AvatarComponents = { Root: React.FC Image: React.FC ImageFallback: React.FC Presence: React.FC Group: React.FC GroupLoader: React.FC> Mask: React.FC } export default Avatar as AvatarComponents export type { AvatarImageProps, AvatarImageFallbackProps, AvatarPresenceProps, AvatarGroupProps, AvatarMaskProps, AvatarRootProps, AvatarSize, } // ================================= // ====== Constants & Types ======== // ================================= const AVATAR_SIZES = { xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', '2xl': '2xl', } as const const AVATAR_PRESENCE_TYPES = { online: 'online', offline: 'offline', } as const // Progrmatically creates type unions type AvatarSize = (typeof AVATAR_SIZES)[keyof typeof AVATAR_SIZES] type AvatarPresenceType = (typeof AVATAR_PRESENCE_TYPES)[keyof typeof AVATAR_PRESENCE_TYPES] const AVATAR_PX: Record = { [AVATAR_SIZES.xs]: 20, [AVATAR_SIZES.sm]: 24, [AVATAR_SIZES.md]: 32, [AVATAR_SIZES.lg]: 40, [AVATAR_SIZES.xl]: 56, [AVATAR_SIZES['2xl']]: 80, } const AVATAR_PRESENCE_PX: Record = { [AVATAR_SIZES.xs]: 8, [AVATAR_SIZES.sm]: 10, [AVATAR_SIZES.md]: 12, [AVATAR_SIZES.lg]: 14, [AVATAR_SIZES.xl]: 16, [AVATAR_SIZES['2xl']]: 20, } const AVATAR_FALLBACK_ICON_PX: Record = { [AVATAR_SIZES.xs]: 10, [AVATAR_SIZES.sm]: 12, [AVATAR_SIZES.md]: 16, [AVATAR_SIZES.lg]: 20, [AVATAR_SIZES.xl]: 28, [AVATAR_SIZES['2xl']]: 40, } // ================================= // ====== Context ================== // ================================= interface AvatarContextType { size: AvatarSize allImagesLoaded: boolean setAllImagesLoaded: React.Dispatch> maxFontSizeMultiplier: number minFontSizeMultiplier?: number } const AvatarContext = createContext(null) function useAvatarContext() { const context = useContext(AvatarContext) if (!context) { throw new Error('Avatar components must be used within Avatar.Root') } return context } // ================================= // ====== AvatarRoot =============== // ================================= interface AvatarRootProps { children: React.ReactNode size?: AvatarSize style?: ViewProps['style'] maxFontSizeMultiplier?: number minFontSizeMultiplier?: number } function AvatarRoot({ children, size = 'md', style, maxFontSizeMultiplier = MAX_FONT_SIZE_MULTIPLIER, minFontSizeMultiplier = undefined, }: AvatarRootProps) { const [allImagesLoaded, setAllImagesLoaded] = useState(false) const styles = useStyles({ size, maxFontSizeMultiplier, minFontSizeMultiplier }) return ( {children} ) } AvatarRoot.displayName = 'Avatar.Root' // ================================= // ====== AvatarMask =============== // ================================= type AvatarMaskProps = ViewProps function AvatarMask({ children, ...props }: AvatarMaskProps) { const { size, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const styles = useStyles({ size, maxFontSizeMultiplier, minFontSizeMultiplier }) return ( {children} ) } AvatarMask.displayName = 'Avatar.Mask' // ================================= // ====== AvatarImage ============ // ================================= interface AvatarImageProps extends Omit { sourceUri: string } function AvatarImage({ sourceUri, ...props }: AvatarImageProps) { const { size, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const fontScale = useFontScale({ maxFontSizeMultiplier, minFontSizeMultiplier }) const scaledAvatarSize = AVATAR_PX[size] * fontScale return } AvatarImage.displayName = 'Avatar.Image' interface AvatarGroupImageProps { sourceUri: string style?: ImageProps['wrapperStyle'] onLoad?: () => void } function AvatarGroupImage({ sourceUri, style, onLoad }: AvatarGroupImageProps) { return ( ) } interface AvatarImageFallbackProps { name?: IconString } function AvatarImageFallback({ name = 'general.person' }: AvatarImageFallbackProps) { const { size, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const styles = useStyles({ maxFontSizeMultiplier, minFontSizeMultiplier }) const fontScale = useFontScale({ maxFontSizeMultiplier, minFontSizeMultiplier }) const scaledIconSize = AVATAR_FALLBACK_ICON_PX[size] * fontScale return ( ) } AvatarImageFallback.displayName = 'Avatar.ImageFallback' // ================================= // ====== AvatarGroup ============ // ================================= interface AvatarGroupProps { sourceUris: string[] } type AvatarIndex = 0 | 1 | 2 | 3 function AvatarGroup({ sourceUris }: AvatarGroupProps) { const { setAllImagesLoaded, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const styles = useStyles({ maxFontSizeMultiplier, minFontSizeMultiplier }) const [loadingStatus, setLoadingStatus] = useState>({ 0: false, 1: false, 2: false, 3: false, }) const displayUris = sourceUris.slice(0, 4) const hasDisplayUris = displayUris.length > 0 const handleImageLoaded = (index: AvatarIndex) => { setLoadingStatus(prev => ({ ...prev, [index]: true, })) } useEffect(() => { const allImagesLoaded = hasDisplayUris && displayUris.every((_, index) => loadingStatus[index as AvatarIndex] === true) setAllImagesLoaded(allImagesLoaded) }, [displayUris, hasDisplayUris, loadingStatus, setAllImagesLoaded]) if (displayUris.length === 1) { return handleImageLoaded(0)} /> } if (displayUris.length === 2) { return ( handleImageLoaded(0)} /> handleImageLoaded(1)} /> ) } if (displayUris.length === 3) { return ( handleImageLoaded(0)} /> handleImageLoaded(1)} /> handleImageLoaded(2)} /> ) } return ( handleImageLoaded(0)} /> handleImageLoaded(1)} /> handleImageLoaded(2)} /> handleImageLoaded(3)} /> ) } AvatarGroup.displayName = 'Avatar.Group' // ================================= // ====== AvatarGroupLoader ========= // ================================= function AvatarGroupLoader() { const { size, allImagesLoaded, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const styles = useStyles({ size, maxFontSizeMultiplier, minFontSizeMultiplier }) const fontScale = useFontScale({ maxFontSizeMultiplier, minFontSizeMultiplier }) const scaledSpinnerSize = AVATAR_PX[size] * fontScale if (allImagesLoaded) return null return ( ) } AvatarGroupLoader.displayName = 'Avatar.GroupLoader' // ================================= // ====== AvatarPresence ========= // ================================= interface AvatarPresenceProps extends ViewProps { presence: AvatarPresenceType } function AvatarPresence({ presence, ...props }: AvatarPresenceProps) { const { size, maxFontSizeMultiplier, minFontSizeMultiplier } = useAvatarContext() const styles = useStyles({ size, presence, maxFontSizeMultiplier, minFontSizeMultiplier }) return } AvatarPresence.displayName = 'Avatar.Presence' // ================================= // ====== Styles =================== // ================================= interface Styles { size?: AvatarSize presence?: AvatarPresenceType maxFontSizeMultiplier?: number minFontSizeMultiplier?: number } const useStyles = ({ size = 'md', presence = 'offline', maxFontSizeMultiplier = MAX_FONT_SIZE_MULTIPLIER, minFontSizeMultiplier = undefined, }: Styles = {}) => { const { colors } = useTheme() const fontScale = useFontScale({ maxFontSizeMultiplier, minFontSizeMultiplier }) const PRESENCE_COLOR = { online: colors.fillColorInteractionOnlineDefault, offline: colors.iconColorDefaultDisabled, } const presenceDiameter = AVATAR_PRESENCE_PX[size] * fontScale const avatarDiameter = AVATAR_PX[size] * fontScale const groupGap = 1 return StyleSheet.create({ rootContainer: { height: avatarDiameter, width: avatarDiameter, }, mask: { borderRadius: avatarDiameter, overflow: 'hidden', width: '100%', height: '100%', }, presence: { height: presenceDiameter, width: presenceDiameter, backgroundColor: PRESENCE_COLOR[presence], borderColor: colors.fillColorNeutral100Inverted, borderWidth: 2, borderRadius: presenceDiameter, position: 'absolute', bottom: -1, right: -1, }, groupLoader: { position: 'absolute', borderRadius: avatarDiameter, width: avatarDiameter, height: avatarDiameter, }, groupColumn: { flex: 1, gap: groupGap, }, groupRow: { flexDirection: 'row', flex: 1, gap: groupGap, }, halfWidthFullHeight: { width: '50%', height: '100%', }, fullWidthHalfHeight: { width: '100%', height: '50%', }, fallbackContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.fillColorNeutral070, }, fallbackIcon: { color: colors.iconColorDefaultDim, }, }) }